introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
||||
export type Slash = {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = getOwner()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
||||
|
||||
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
)
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
}
|
||||
})
|
||||
},
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
show() {
|
||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const owner = getOwner() ?? root
|
||||
if (!owner) return () => {}
|
||||
|
||||
let list: Accessor<CommandOption[]> | undefined
|
||||
|
||||
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||
runWithOwner(owner, () => {
|
||||
list = createMemo(cb)
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => [ref, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
})
|
||||
})
|
||||
|
||||
if (!list) return () => {}
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault()
|
||||
value.show()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return props.options
|
||||
return [...props.suggestedOptions, ...props.options]
|
||||
}
|
||||
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import open from "open"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
@@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import { Link } from "@tui/ui/link"
|
||||
import { GoLogo } from "./logo"
|
||||
import { BgPulse, type BgPulseMask } from "./bg-pulse"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
@@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
|
||||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
if (selected() === "subscribe") subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
}
|
||||
})
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "left",
|
||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
||||
},
|
||||
{
|
||||
key: "tab",
|
||||
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
cmd: () => {
|
||||
if (selected() === "subscribe") subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box ref={(item: BoxRenderable) => (content = item)}>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync"
|
||||
import { map, pipe, entries, sortBy } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
|
||||
@@ -45,9 +44,9 @@ export function DialogMcp() {
|
||||
)
|
||||
})
|
||||
|
||||
const keybinds = createMemo(() => [
|
||||
const actions = createMemo(() => [
|
||||
{
|
||||
keybind: Keybind.parse("space")[0],
|
||||
command: "dialog.action.toggle",
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
@@ -77,7 +76,7 @@ export function DialogMcp() {
|
||||
ref={setRef}
|
||||
title="MCPs"
|
||||
options={options()}
|
||||
keybind={keybinds()}
|
||||
actions={actions()}
|
||||
onSelect={(_option) => {
|
||||
// Don't close on select, only on escape
|
||||
}}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useTuiConfig } from "../context/tui-config"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
@@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
return (
|
||||
<DialogSelect<ReturnType<typeof options>[number]["value"]>
|
||||
options={options()}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
keybind: keybind.all.model_provider_list?.[0],
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect provider" : "View all providers",
|
||||
onTrigger() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_favorite_toggle?.[0],
|
||||
command: "model.dialog.favorite",
|
||||
title: "Favorite",
|
||||
disabled: !connected(),
|
||||
onTrigger: (option) => {
|
||||
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
},
|
||||
},
|
||||
]}
|
||||
bindings={tuiConfig.keymap.sections.model}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
|
||||
@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) {
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
|
||||
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
Clipboard.copy(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
}
|
||||
})
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "c",
|
||||
cmd: () => {
|
||||
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
Clipboard.copy(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(async () => {
|
||||
const result = await sdk.client.provider.oauth.callback({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogSessionDeleteFailed(props: {
|
||||
session: string
|
||||
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
|
||||
if (!props.onDone) dialog.clear()
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
void confirm()
|
||||
}
|
||||
if (evt.name === "left" || evt.name === "up") {
|
||||
setStore("active", "delete")
|
||||
}
|
||||
if (evt.name === "right" || evt.name === "down") {
|
||||
setStore("active", "restore")
|
||||
}
|
||||
})
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", cmd: () => void confirm() },
|
||||
{ key: "left", cmd: () => setStore("active", "delete") },
|
||||
{ key: "up", cmd: () => setStore("active", "delete") },
|
||||
{ key: "right", cmd: () => setStore("active", "restore") },
|
||||
{ key: "down", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
import { WorkspaceLabel } from "./workspace-label"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -156,7 +156,7 @@ export function DialogSessionList() {
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
@@ -187,9 +187,9 @@ export function DialogSessionList() {
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
command: "dialog.action.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
@@ -237,7 +237,7 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
command: "dialog.action.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
@@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const isDeleting = toDelete() === index
|
||||
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
|
||||
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
@@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
}
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
keybind: keybind.all.stash_delete?.[0],
|
||||
command: "dialog.action.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
void confirm()
|
||||
return
|
||||
}
|
||||
if (evt.name === "left") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "cancel")
|
||||
return
|
||||
}
|
||||
if (evt.name === "right") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
setStore("active", "restore")
|
||||
}
|
||||
})
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", cmd: () => void confirm() },
|
||||
{ key: "left", cmd: () => setStore("active", "cancel") },
|
||||
{ key: "right", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { pathToFileURL } from "bun"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { useTheme, selectedForeground } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useCommandPalette } from "../../context/command-palette"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Locale } from "@/util/locale"
|
||||
import type { PromptInfo } from "./history"
|
||||
import { useFrecency } from "./frecency"
|
||||
import { useBindings } from "../../keymap"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
const command = useCommandPalette()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const frecency = useFrecency()
|
||||
const tuiConfig = useTuiConfig()
|
||||
|
||||
const {
|
||||
keymap: { sections },
|
||||
} = tuiConfig
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
|
||||
const { filename, part } = createFilePart(item, lineRange)
|
||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||
|
||||
command.keybinds(true)
|
||||
command.suspend(false)
|
||||
setStore("visible", false)
|
||||
setStore("index", index)
|
||||
insertPart(filename, part)
|
||||
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
target: props.input,
|
||||
enabled: () => Boolean(store.visible),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.autocomplete.prev",
|
||||
run() {
|
||||
setStore("input", "keyboard")
|
||||
move(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.next",
|
||||
run() {
|
||||
setStore("input", "keyboard")
|
||||
move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.hide",
|
||||
run() {
|
||||
hide()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.select",
|
||||
run() {
|
||||
select()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt.autocomplete.complete",
|
||||
run() {
|
||||
const selected = options()[store.selected]
|
||||
if (selected?.isDirectory) {
|
||||
expandDirectory()
|
||||
return
|
||||
}
|
||||
|
||||
select()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: sections.autocomplete,
|
||||
}))
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
command.keybinds(false)
|
||||
command.suspend(true)
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorOffset,
|
||||
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
|
||||
draft.input = props.input().plainText
|
||||
})
|
||||
}
|
||||
command.keybinds(true)
|
||||
command.suspend(false)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
|
||||
setStore("index", idx)
|
||||
}
|
||||
},
|
||||
onKeyDown(e: KeyEvent) {
|
||||
if (store.visible) {
|
||||
const name = e.name?.toLowerCase()
|
||||
const ctrlOnly = e.ctrl && !e.meta && !e.shift
|
||||
const isNavUp = name === "up" || (ctrlOnly && name === "p")
|
||||
const isNavDown = name === "down" || (ctrlOnly && name === "n")
|
||||
|
||||
if (isNavUp) {
|
||||
setStore("input", "keyboard")
|
||||
move(-1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isNavDown) {
|
||||
setStore("input", "keyboard")
|
||||
move(1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "escape") {
|
||||
hide()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "return") {
|
||||
select()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "tab") {
|
||||
const selected = options()[store.selected]
|
||||
if (selected?.isDirectory) {
|
||||
expandDirectory()
|
||||
} else {
|
||||
select()
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!store.visible) {
|
||||
if (e.name === "@") {
|
||||
const cursorOffset = props.input().cursorOffset
|
||||
const charBeforeCursor =
|
||||
cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
|
||||
const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
|
||||
if (canTrigger) show("@")
|
||||
}
|
||||
|
||||
if (e.name === "/") {
|
||||
if (props.input().cursorOffset === 0) show("/")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
|
||||
import {
|
||||
BoxRenderable,
|
||||
RGBA,
|
||||
TextareaRenderable,
|
||||
MouseEvent,
|
||||
PasteEvent,
|
||||
decodePasteBytes,
|
||||
type KeyEvent,
|
||||
type Renderable,
|
||||
} from "@opentui/core"
|
||||
import type { CommandContext } from "@opentui/keymap"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import path from "path"
|
||||
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { computePromptTraits } from "./traits"
|
||||
import { assign } from "./part"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import * as Editor from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
@@ -51,7 +58,15 @@ import {
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
||||
import { type WorkspaceStatus } from "../workspace-label"
|
||||
import { useCommandPalette } from "../../context/command-palette"
|
||||
import {
|
||||
useBindings,
|
||||
useCommandShortcut,
|
||||
useLeaderActive,
|
||||
useOpencodeKeymap,
|
||||
} from "../../keymap"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||
|
||||
const keybind = useKeybind()
|
||||
const leader = useLeaderActive()
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const sdk = useSDK()
|
||||
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
const command = useCommandDialog()
|
||||
const command = useCommandPalette()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
|
||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
|
||||
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
|
||||
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
|
||||
editor.clearSelection()
|
||||
}
|
||||
|
||||
const textareaKeybindings = useTextareaKeybindings()
|
||||
|
||||
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
const promptCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
name: "prompt.clear",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: (dialog) => {
|
||||
input.extmarks.clear()
|
||||
run: () => {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Submit prompt",
|
||||
value: "prompt.submit",
|
||||
keybind: "input_submit",
|
||||
name: "prompt.submit",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: async (dialog) => {
|
||||
run: async () => {
|
||||
if (!input.focused) return
|
||||
const handled = await submit()
|
||||
if (!handled) return
|
||||
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
{
|
||||
title: "Remove editor context",
|
||||
value: "prompt.editor_context.clear",
|
||||
name: "prompt.editor_context.clear",
|
||||
category: "Prompt",
|
||||
enabled: Boolean(editorContext()),
|
||||
onSelect: (dialog) => {
|
||||
run: () => {
|
||||
dismissEditorContext()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paste",
|
||||
value: "prompt.paste",
|
||||
keybind: "input_paste",
|
||||
name: "prompt.paste",
|
||||
category: "Prompt",
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
|
||||
ctx.event.preventDefault()
|
||||
ctx.event.stopPropagation()
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Interrupt session",
|
||||
value: "session.interrupt",
|
||||
keybind: "session_interrupt",
|
||||
name: "session.interrupt",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
enabled: status().type !== "idle",
|
||||
onSelect: (dialog) => {
|
||||
if (autocomplete.visible) return
|
||||
run: () => {
|
||||
if (auto()?.visible) return
|
||||
if (!input.focused) return
|
||||
// TODO: this should be its own command
|
||||
if (store.mode === "shell") {
|
||||
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
|
||||
{
|
||||
title: "Open editor",
|
||||
category: "Session",
|
||||
keybind: "editor_open",
|
||||
value: "prompt.editor",
|
||||
slash: {
|
||||
name: "editor",
|
||||
},
|
||||
onSelect: async (dialog) => {
|
||||
name: "prompt.editor",
|
||||
slashName: "editor",
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
|
||||
// replace summarized text parts with the actual text
|
||||
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
{
|
||||
title: "Skills",
|
||||
value: "prompt.skills",
|
||||
name: "prompt.skills",
|
||||
category: "Prompt",
|
||||
slash: {
|
||||
name: "skills",
|
||||
},
|
||||
onSelect: () => {
|
||||
slashName: "skills",
|
||||
run: () => {
|
||||
dialog.replace(() => (
|
||||
<DialogSkill
|
||||
onSelect={(skill) => {
|
||||
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
{
|
||||
title: "Warp",
|
||||
description: "Change the workspace for the session",
|
||||
value: "workspace.set",
|
||||
desc: "Change the workspace for the session",
|
||||
name: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slash: {
|
||||
name: "warp",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
].map((entry) => ({
|
||||
namespace: "palette",
|
||||
...entry,
|
||||
})),
|
||||
)
|
||||
|
||||
useBindings(() => ({
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: keymapConfig.pick("prompt", [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
"session.interrupt",
|
||||
"workspace.set",
|
||||
]),
|
||||
}))
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (store.prompt.input) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
|
||||
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.traits = computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
})
|
||||
input.traits = {
|
||||
...input.traits,
|
||||
...computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: "Stash prompt",
|
||||
value: "prompt.stash",
|
||||
category: "Prompt",
|
||||
enabled: !!store.prompt.input,
|
||||
onSelect: (dialog) => {
|
||||
if (!store.prompt.input) return
|
||||
stash.push({
|
||||
input: store.prompt.input,
|
||||
parts: store.prompt.parts,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
const stashCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
title: "Stash prompt",
|
||||
name: "prompt.stash",
|
||||
category: "Prompt",
|
||||
enabled: !!store.prompt.input,
|
||||
run: () => {
|
||||
if (!store.prompt.input) return
|
||||
stash.push({
|
||||
input: store.prompt.input,
|
||||
parts: store.prompt.parts,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash pop",
|
||||
value: "prompt.stash.pop",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
onSelect: (dialog) => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}
|
||||
dialog.clear()
|
||||
{
|
||||
title: "Stash pop",
|
||||
name: "prompt.stash.pop",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
run: () => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash list",
|
||||
value: "prompt.stash.list",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
onSelect={(entry) => {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
{
|
||||
title: "Stash list",
|
||||
name: "prompt.stash.list",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
run: () => {
|
||||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
onSelect={(entry) => {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
].map((entry) => ({
|
||||
namespace: "palette",
|
||||
...entry,
|
||||
})),
|
||||
)
|
||||
|
||||
useBindings(() => ({
|
||||
commands: stashCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
bindings: [
|
||||
{
|
||||
key: "!",
|
||||
cmd: () => {
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setStore("mode", "shell")
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
|
||||
)
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.history.previous",
|
||||
run() {
|
||||
if (input.cursorOffset !== 0) {
|
||||
input.cursorOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
if (!item) return
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
input.cursorOffset = 0
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
|
||||
)
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.history.next",
|
||||
run() {
|
||||
if (input.cursorOffset !== input.plainText.length) {
|
||||
input.cursorOffset = input.plainText.length
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(1, input.plainText)
|
||||
if (!item) return
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
input.cursorOffset = input.plainText.length
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
setWarpNotice(undefined)
|
||||
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspaceCreating()) return false
|
||||
if (autocomplete?.visible) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.input) return false
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = iife(() => {
|
||||
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (process.platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
})
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const mime = await Filesystem.mimeType(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (mime.startsWith("image/") || mime === "application/pdf") {
|
||||
const content = await Filesystem.readArrayBuffer(filepath)
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
filepath,
|
||||
mime,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
input.insertText(normalizedText)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (keybind.leader) return theme.border
|
||||
if (leader()) return theme.border
|
||||
if (store.mode === "shell") return theme.primary
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return theme.border
|
||||
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => {
|
||||
autocomplete = r
|
||||
setAuto(() => r)
|
||||
}}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore("prompt", produce(cb))
|
||||
}}
|
||||
setExtmark={(partIndex, extmarkId) => {
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}}
|
||||
value={store.prompt.input}
|
||||
fileStyleId={fileStyleId}
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={borderHighlight()}
|
||||
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
|
||||
<textarea
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={keybind.leader ? theme.textMuted : theme.text}
|
||||
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
||||
textColor={leader() ? theme.textMuted : theme.text}
|
||||
focusedTextColor={leader() ? theme.textMuted : theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "input", value)
|
||||
autocomplete.onInput(value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
}}
|
||||
keyBindings={textareaKeybindings()}
|
||||
onKeyDown={async (e) => {
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// Check clipboard for images before terminal-handled paste runs.
|
||||
// This helps terminals that forward Ctrl+V to the app; Windows
|
||||
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
||||
if (keybind.match("input_paste", e)) {
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
e.preventDefault()
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
// If no image, let the default paste behavior continue
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
return
|
||||
}
|
||||
if (keybind.match("app_exit", e)) {
|
||||
if (store.prompt.input === "") {
|
||||
await exit()
|
||||
// Don't preventDefault - let textarea potentially handle the event
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.name === "!" && input.visualCursor.offset === 0) {
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setStore("mode", "shell")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (store.mode === "shell") {
|
||||
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
|
||||
setStore("mode", "normal")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (
|
||||
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
||||
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
|
||||
) {
|
||||
const direction = keybind.match("history_previous", e) ? -1 : 1
|
||||
const item = history.move(direction, input.plainText)
|
||||
|
||||
if (item) {
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
e.preventDefault()
|
||||
if (direction === -1) input.cursorOffset = 0
|
||||
if (direction === 1) input.cursorOffset = input.plainText.length
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
||||
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
|
||||
input.cursorOffset = input.plainText.length
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
keymap.dispatchCommand("prompt.paste")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
const filepath = iife(() => {
|
||||
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (process.platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
})
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const mime = await Filesystem.mimeType(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
// Handle SVG as raw text content, not as base64 image
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (mime.startsWith("image/") || mime === "application/pdf") {
|
||||
const content = await Filesystem.readArrayBuffer(filepath)
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
filepath,
|
||||
mime,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
input.insertText(normalizedText)
|
||||
|
||||
// Force layout update and render for the pasted content
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
await pasteInputText(normalizedText)
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
setInputTarget(r)
|
||||
if (promptPartTypeId === 0) {
|
||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||
}
|
||||
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
|
||||
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
|
||||
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => {
|
||||
setAuto(() => r)
|
||||
}}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore("prompt", produce(cb))
|
||||
}}
|
||||
setExtmark={(partIndex, extmarkId) => {
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}}
|
||||
value={store.prompt.input}
|
||||
fileStyleId={fileStyleId}
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
export type PromptTraits = EditorTraits & {
|
||||
owner: "opencode"
|
||||
role: "prompt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
const capture =
|
||||
input.mode === "normal"
|
||||
? input.autocompleteVisible
|
||||
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
owner: "opencode",
|
||||
role: "prompt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { KeyBinding } from "@opentui/core"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
const TEXTAREA_ACTIONS = [
|
||||
"submit",
|
||||
"newline",
|
||||
"move-left",
|
||||
"move-right",
|
||||
"move-up",
|
||||
"move-down",
|
||||
"select-left",
|
||||
"select-right",
|
||||
"select-up",
|
||||
"select-down",
|
||||
"line-home",
|
||||
"line-end",
|
||||
"select-line-home",
|
||||
"select-line-end",
|
||||
"visual-line-home",
|
||||
"visual-line-end",
|
||||
"select-visual-line-home",
|
||||
"select-visual-line-end",
|
||||
"buffer-home",
|
||||
"buffer-end",
|
||||
"select-buffer-home",
|
||||
"select-buffer-end",
|
||||
"delete-line",
|
||||
"delete-to-line-end",
|
||||
"delete-to-line-start",
|
||||
"backspace",
|
||||
"delete",
|
||||
"undo",
|
||||
"redo",
|
||||
"word-forward",
|
||||
"word-backward",
|
||||
"select-word-forward",
|
||||
"select-word-backward",
|
||||
"delete-word-forward",
|
||||
"delete-word-backward",
|
||||
] as const
|
||||
|
||||
function mapTextareaKeybindings(
|
||||
keybinds: Record<string, Keybind.Info[]>,
|
||||
action: (typeof TEXTAREA_ACTIONS)[number],
|
||||
): KeyBinding[] {
|
||||
const configKey = `input_${action.replace(/-/g, "_")}`
|
||||
const bindings = keybinds[configKey]
|
||||
if (!bindings) return []
|
||||
return bindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
super: binding.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useTextareaKeybindings() {
|
||||
const keybind = useKeybind()
|
||||
|
||||
return createMemo(() => {
|
||||
const keybinds = keybind.all
|
||||
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
|
||||
] satisfies KeyBinding[]
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user