diff --git a/bun.lock b/bun.lock index 6404fce15419..5b9a1fcb34ce 100644 --- a/bun.lock +++ b/bun.lock @@ -1070,6 +1070,7 @@ "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch", "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch", + "@opentui/core@0.4.5": "patches/@opentui%2Fcore@0.4.5.patch", "@ai-sdk/xai@3.0.139": "patches/@ai-sdk%2Fxai@3.0.139.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", diff --git a/package.json b/package.json index 003e39a89fce..7eb9c166f649 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch", "@ai-sdk/anthropic@3.0.111": "patches/@ai-sdk%2Fanthropic@3.0.111.patch", "@ai-sdk/amazon-bedrock@4.0.166": "patches/@ai-sdk%2Famazon-bedrock@4.0.166.patch", - "@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch" + "@ai-sdk/openai@3.0.88": "patches/@ai-sdk%2Fopenai@3.0.88.patch", + "@opentui/core@0.4.5": "patches/@opentui%2Fcore@0.4.5.patch" } } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1b21aafade52..1e9ac048f45a 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -9,7 +9,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" -import { createCliRenderer, MouseButton } from "@opentui/core" +import { createCliRenderer, MouseButton, type MouseEvent } from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" import { Switch, @@ -66,6 +66,8 @@ import { openUrl } from "@opencode-ai/core/open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" import { createTuiApiAdapters } from "./plugin/adapters" +import { copyText, onClick } from "./ui/click" +import { idUnder } from "./ui/id-click" import { createTuiApi } from "./plugin/api" import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" import { CommandPaletteDialog } from "./component/command-palette" @@ -372,6 +374,14 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const clipboard = useClipboard() const update = useUpdateNotification() + // A click on a session or message ID outside a dialog copies it. + const idClick = onClick((evt: MouseEvent) => { + if (dialog.stack.length > 0) return false + const found = idUnder(renderer.currentRenderBuffer, evt) + if (!found) return false + copyText(clipboard, toast.show, found.value) + return true + }) const api = createTuiApi( createTuiApiAdapters({ @@ -1042,6 +1052,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi flexDirection="column" backgroundColor={theme.background} onMouseDown={(evt) => { + idClick.onMouseDown(evt) if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return @@ -1049,11 +1060,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi evt.preventDefault() evt.stopPropagation() }} - onMouseUp={ - !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT - ? () => Selection.copy(renderer, toast, clipboard) - : undefined - } + onMouseUp={(evt) => { + if (idClick.onMouseUp(evt)) return + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) Selection.copy(renderer, toast, clipboard) + }} > diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index c48c0f8ee188..d2f1ca1f6022 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -5,7 +5,6 @@ import { map, pipe, entries, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select" import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" -import { useSDK } from "../context/sdk" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -21,14 +20,12 @@ function Status(props: { enabled: boolean; loading: boolean }) { export function DialogMcp() { const local = useLocal() const sync = useSync() - const sdk = useSDK() const [, setRef] = createSignal>() - const [loading, setLoading] = createSignal(null) const options = createMemo(() => { // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp - const loadingMcp = loading() + const loadingMcp = local.mcp.pending() return pipe( mcpData ?? {}, @@ -48,26 +45,7 @@ export function DialogMcp() { { command: "dialog.mcp.toggle", title: "toggle", - onTrigger: async (option: DialogSelectOption) => { - // Prevent toggling while an operation is already in progress - if (loading() !== null) return - - setLoading(option.value) - try { - await local.mcp.toggle(option.value) - // Refresh MCP status from server - const status = await sdk.client.mcp.status() - if (status.data) { - sync.set("mcp", status.data) - } else { - console.error("Failed to refresh MCP status: no data returned") - } - } catch (error) { - console.error("Failed to toggle MCP:", error) - } finally { - setLoading(null) - } - }, + onTrigger: (option: DialogSelectOption) => local.mcp.toggle(option.value), }, ]) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 38db569aaa24..1b7f0897841c 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -9,7 +9,9 @@ import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" import { useSync } from "../context/sync" -export function DialogModel(props: { providerID?: string }) { +// `current` puts the cursor on another model than the selected one, e.g. the +// model a past turn ran on. +export function DialogModel(props: { providerID?: string; current?: { providerID: string; modelID: string } }) { const local = useLocal() const sync = useSync() const dialog = useDialog() @@ -178,7 +180,7 @@ export function DialogModel(props: { providerID?: string }) { flat={true} skipFilter={true} title={title()} - current={local.model.current()} + current={props.current ?? local.model.current()} /> ) } diff --git a/packages/tui/src/component/dialog-paste-options.tsx b/packages/tui/src/component/dialog-paste-options.tsx new file mode 100644 index 000000000000..88c376c93af5 --- /dev/null +++ b/packages/tui/src/component/dialog-paste-options.tsx @@ -0,0 +1,284 @@ +import { ScrollBoxRenderable, TextareaRenderable, TextAttributes, type RGBA } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createEffect, createSignal, For, onMount } from "solid-js" +import { useTheme } from "../context/theme" +import { useKV } from "../context/kv" +import { useTuiConfig } from "../config" +import { useBindings } from "../keymap" +import { useDialog } from "../ui/dialog" +import { onClick } from "../ui/click" +import { pasteLabel } from "../prompt/paste" +import { + cleanPaste, + pasteOptionDisabled, + PasteOptionList, + PasteOptionsDefault, + PasteOptionsKVKey, + togglePasteOption, + unwrapPaste, + wrapPaste, + type PasteOptionKey, + type PasteOptions, +} from "../prompt/paste-options" +import { PromptFrame, promptFrameRows } from "./prompt/frame" + +// A paste as it came in and what was chosen for it. `body` is the text inside the wraps once it was +// edited by hand; until then it is the source with the cleanups applied, and they can be undone. +export type PasteOrigin = { source: string; options: PasteOptions; body?: string } +export type PasteResult = { text: string; inline: boolean; origin: PasteOrigin } + +// The paste dialog of prompt.paste_mode 'dialog': the options on the left, and on the right the +// paste as it will go in, in the prompt's own editor and box, updated as options change and +// editable. Space toggles the selected option, enter pastes, esc cancels. Right or tab moves from the +// options to the editor, where space and arrows work as usual and enter still pastes; tab goes back. +export function DialogPasteOptions(props: { origin: PasteOrigin; color: RGBA; onPaste(result: PasteResult): void }) { + const dialog = useDialog() + const { theme, syntax } = useTheme() + const tuiConfig = useTuiConfig() + const kv = useKV() + const dimensions = useTerminalDimensions() + const [options, setOptions] = createSignal(props.origin.options) + const [body, setBody] = createSignal(props.origin.body ?? cleanPaste(props.origin.source, props.origin.options)) + const [text, setText] = createSignal(wrapPaste(body(), options())) + const [focus, setFocus] = createSignal<"options" | "editor">("options") + const [selected, setSelected] = createSignal(0) + const [rows, setRows] = createSignal(1) + const [target, setTarget] = createSignal() + let edited = props.origin.body !== undefined + let textarea: TextareaRenderable + let list: ScrollBoxRenderable | undefined + + // The dialog starts a quarter of the way down the screen (ui/dialog.tsx). Around the editor it has + // 6 rows: its top padding, the title, a gap, the hint row under the box, its bottom padding and a + // one-row margin, plus the prompt box's own rows. The editor is as tall as the paste once wrapped, + // and at least as tall as the options beside it, up to the room left; longer text scrolls. + const editorHeight = () => { + const room = dimensions().height - Math.ceil(dimensions().height / 4) - 6 - promptFrameRows(true) + const beside = listHeight() - promptFrameRows(true) - 1 + return Math.max(1, Math.min(room, Math.max(beside, rows()))) + } + + // The options get the same rows as the editor's column; on a short screen they scroll, following + // the selection. + const listHeight = () => + Math.max(1, Math.min(PasteOptionList.length, dimensions().height - Math.ceil(dimensions().height / 4) - 6)) + + createEffect(() => { + const index = selected() + const height = listHeight() + if (!list || list.isDestroyed) return + if (index < list.scrollTop) list.scrollTo(index) + if (index >= list.scrollTop + height) list.scrollTo(index - height + 1) + }) + + function measure() { + if (!textarea || textarea.isDestroyed) return + setRows(textarea.editorView.getTotalVirtualLineCount()) + } + + function show() { + setText(wrapPaste(body(), options())) + if (!textarea || textarea.isDestroyed) return + textarea.setText(text()) + measure() + } + + // Before an edit, a cleanup turned off brings back the source; after one, only turning a cleanup + // on can change the text, since what the edit replaced is gone. + function toggle(key: PasteOptionKey) { + if (pasteOptionDisabled(key, body(), options())) return + const next = togglePasteOption(options(), key) + setOptions(next) + kv.set(PasteOptionsKVKey, next) + if (!edited) setBody(cleanPaste(props.origin.source, next)) + if (edited && next[key]) setBody(cleanPaste(body(), { ...PasteOptionsDefault, [key]: true })) + show() + } + + // The editor reports a change a microtask after it happened, so the dialog's own setText reaches + // here too, once the dialog is done; a flag set around setText would already be cleared. A change + // is a hand edit only when the editor no longer holds the text the dialog last showed. + function adoptEdit() { + measure() + if (textarea.plainText === text()) return + setText(textarea.plainText) + const unwrapped = unwrapPaste(textarea.plainText, options()) + setBody(unwrapped.body) + setOptions(unwrapped.options) + edited = true + } + + function focusOn(next: "options" | "editor") { + setFocus(next) + if (!textarea || textarea.isDestroyed) return + if (next === "editor") return textarea.focus() + textarea.blur() + } + + function paste() { + props.onPaste({ + text: text(), + inline: options().inline, + origin: { source: props.origin.source, options: options(), body: edited ? body() : undefined }, + }) + dialog.clear() + } + + const checked = (key: PasteOptionKey) => options()[key] && !pasteOptionDisabled(key, body(), options()) + + useBindings(() => ({ + priority: 1, + bindings: [ + { key: "tab", desc: "Options or editor", group: "Dialog", cmd: () => focusOn(focus() === "editor" ? "options" : "editor") }, + { + key: "shift+tab", + desc: "Options or editor", + group: "Dialog", + cmd: () => focusOn(focus() === "editor" ? "options" : "editor"), + }, + ], + })) + + useBindings(() => ({ + target, + enabled: target() !== undefined, + priority: 1, + commands: [{ name: "input.submit", title: "Paste", category: "Dialog", run: paste }], + })) + + // While the options have focus the editor does not, so these keys are not tied to it. + useBindings(() => ({ + enabled: focus() === "options", + priority: 1, + commands: [{ name: "dialog.paste.paste", title: "Paste", category: "Dialog", run: paste }], + bindings: [ + ...tuiConfig.keybinds.get("input.submit").map((binding) => ({ ...binding, cmd: "dialog.paste.paste" })), + { key: "right", desc: "To the editor", group: "Dialog", cmd: () => focusOn("editor") }, + { + key: "space", + desc: "Toggle paste option", + group: "Dialog", + cmd: () => toggle(PasteOptionList[selected()].key), + }, + { + key: "up", + desc: "Previous paste option", + group: "Dialog", + cmd: () => setSelected((index) => (index + PasteOptionList.length - 1) % PasteOptionList.length), + }, + { + key: "down", + desc: "Next paste option", + group: "Dialog", + cmd: () => setSelected((index) => (index + 1) % PasteOptionList.length), + }, + ], + })) + + onMount(() => { + dialog.setSize("xlarge") + textarea.gotoBufferHome() + setTimeout(measure, 1) + }) + + return ( + + + + Paste {text().split("\n").length} lines + + + {options().inline ? "goes in as text" : `goes in as ${pasteLabel(text(), tuiConfig.prompt?.paste_summary)}`} + + + + {/* A scroll box grows to fill the row unless told otherwise; this one is as wide as its rows: + the widest label, its checkbox and its padding. */} + option.label.length)) + 6} + height={listHeight()} + scrollbarOptions={{ visible: false }} + ref={(value: ScrollBoxRenderable) => (list = value)} + > + + {(option, index) => { + const current = () => selected() === index() + const disabled = () => pasteOptionDisabled(option.key, body(), options()) + return ( + { + setSelected(index()) + focusOn("options") + toggle(option.key) + })} + > + + {checked(option.key) ? "[x] " : "[ ] "} + {option.label} + + + ) + }} + + + + +