diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index b1f62178460e..fa9033a00ce4 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -82,6 +82,7 @@ function runTuiConfig(config: Config | undefined): RunTuiConfig { keybinds: config.keybinds, leader_timeout: config.leader_timeout, diff_style: config.diff_style ?? "auto", + input: config.input, } } diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e4870..9cfd1ef01f8b 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -286,7 +286,7 @@ export type QuestionReply = Parameters[0] export type QuestionReject = Parameters[0] -export type RunTuiConfig = Pick +export type RunTuiConfig = Pick // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..6e4c7efa956b 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -58,6 +58,16 @@ export const Prompt = Schema.Struct({ }), }).annotate({ description: "Prompt size settings" }) +export const Input = Schema.Struct({ + line_discard: Schema.optional(Schema.Literals(["bash", "zsh"])).annotate({ + description: [ + "What input_delete_to_line_start (ctrl+u) deletes in the prompt and every other text field:", + "'bash' (default) from the cursor back to the start of its line, 'zsh' that whole line wherever", + "the cursor is. In a multi-line prompt only the cursor's line, not the whole prompt", + ].join(" "), + }), +}).annotate({ description: "Text field editing settings" }) + export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String), theme: Schema.optional(Schema.String), @@ -67,6 +77,7 @@ export const Info = Schema.Struct({ leader_timeout: Schema.optional(LeaderTimeout), attention: Schema.optional(Attention), prompt: Schema.optional(Prompt), + input: Schema.optional(Input), scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 8d0aefc8f5dc..f996e62e1d2f 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -16,6 +16,7 @@ import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@open import { createMemo, type Accessor } from "solid-js" import { useTuiConfig } from "./config" import { TuiKeybind } from "./config/keybind" +import { discardLine, type LineDiscard } from "./util/line-discard" export const LEADER_TOKEN = "leader" export const OPENCODE_BASE_MODE = "base" @@ -42,7 +43,7 @@ type BindingLookup = { gather(name: string, commands: readonly string[]): readonly Binding[] } type FormatConfig = { keybinds: BindingLookup } -type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number } +type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number; input?: { line_discard?: LineDiscard } } const modeStacks = new WeakMap() @@ -172,6 +173,33 @@ const inputCommands = [ "input.submit", ] as const +// Replaces the textarea's delete-to-line-start command, which a later layer +// runs first, with one that follows input.line_discard. A single-line +// handles ctrl+u itself, so its own layer routes input_delete_to_line_start +// to the same command while one has focus. +function registerLineDiscardCommand(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) { + const offCommand = keymap.registerLayer({ + commands: [ + { + name: "input.delete.to.line.start", + run() { + const editor = renderer.currentFocusedEditor + if (!editor || editor.isDestroyed) return false + return discardLine(editor, config.input?.line_discard) + }, + }, + ], + }) + const offInputBindings = keymap.registerLayer({ + enabled: () => renderer.currentFocusedEditor instanceof InputRenderable, + bindings: config.keybinds.gather("input-line-discard", ["input.delete.to.line.start"]), + }) + return () => { + offInputBindings() + offCommand() + } +} + function hasManagedTextareaFocus(renderer: CliRenderer) { const editor = renderer.currentFocusedEditor return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable) @@ -230,8 +258,10 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende enabled: () => hasManagedTextareaFocus(renderer), bindings: config.keybinds.gather("input", inputCommands), }) + const offLineDiscard = registerLineDiscardCommand(keymap, renderer, config) return () => { + offLineDiscard() offInputBindings() offBackspace() offEscape() diff --git a/packages/tui/src/util/line-discard.ts b/packages/tui/src/util/line-discard.ts new file mode 100644 index 000000000000..dde2884812d2 --- /dev/null +++ b/packages/tui/src/util/line-discard.ts @@ -0,0 +1,15 @@ +import type { EditBufferRenderable } from "@opentui/core" + +export type LineDiscard = "bash" | "zsh" + +// input_delete_to_line_start (ctrl+u) in every text field. 'bash' deletes from +// the caret back to the start of its logical line, as readline's +// unix-line-discard does; 'zsh' deletes that whole line wherever the caret is, +// as zsh's kill-whole-line does. Either way the line itself stays: a press at +// the start of a line ('bash'), or on an empty one ('zsh'), joins it to the +// previous line, as opentui's deleteToLineStart does. The deletion goes +// through the edit buffer, so input_undo restores it. +export function discardLine(editor: EditBufferRenderable, mode: LineDiscard | undefined) { + if (mode === "zsh") editor.setCursor(editor.logicalCursor.row, editor.editBuffer.getEOL().col) + return editor.deleteToLineStart() +} diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index b0eed3081248..7985e59ca17e 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -4,8 +4,10 @@ import { createBindingLookup } from "@opentui/keymap/extras" import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" import { onCleanup } from "solid-js" +import type { InputRenderable, TextareaRenderable } from "@opentui/core" import { TuiKeybind } from "../src/config/keybind" import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap" +import type { LineDiscard } from "../src/util/line-discard" function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) { const keybinds = TuiKeybind.parse(input) @@ -139,3 +141,69 @@ test("mode-less bindings stay active when opencode mode changes", async () => { app.renderer.destroy() } }) + +test("ctrl+u follows input.line_discard in a textarea, on the caret's line only, and in an input", async () => { + async function run(mode: LineDiscard | undefined, field: "textarea" | "input", text: string, caret: number) { + let editor: TextareaRenderable | InputRenderable | undefined + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const offKeymap = registerOpencodeKeymap(keymap, renderer, { + ...createResolvedKeymapConfig(), + input: { line_discard: mode }, + }) + onCleanup(offKeymap) + const ref = (el: TextareaRenderable) => (editor = el) + + return ( + + {field === "textarea" ? ( +