Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/run/runtime.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/run/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]

export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]

export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style" | "input">

// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
Expand Down
11 changes: 11 additions & 0 deletions packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
32 changes: 31 additions & 1 deletion packages/tui/src/keymap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -42,7 +43,7 @@ type BindingLookup = {
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
}
type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number; input?: { line_discard?: LineDiscard } }

const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()

Expand Down Expand Up @@ -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 <input>
// 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)
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions packages/tui/src/util/line-discard.ts
Original file line number Diff line number Diff line change
@@ -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()
}
68 changes: 68 additions & 0 deletions packages/tui/test/keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<OpencodeKeymapProvider keymap={keymap}>
{field === "textarea" ? (
<textarea ref={ref} focused initialValue={text} />
) : (
<input ref={ref} focused value={text} />
)}
</OpencodeKeymapProvider>
)
}

const app = await testRender(() => <Harness />)
try {
if (!editor) throw new Error(`${field} did not mount`)
editor.cursorOffset = caret
app.renderer.stdin.emit("data", Buffer.from("\x15"))
await Bun.sleep(50)
const after = { text: editor.plainText, caret: editor.cursorOffset }
editor.undo()
return { ...after, undone: editor.plainText }
} finally {
app.renderer.destroy()
}
}

expect(await run(undefined, "textarea", "hello world", 5)).toEqual({
text: " world",
caret: 0,
undone: "hello world",
})
expect(await run("bash", "textarea", "hello world", 5)).toEqual({ text: " world", caret: 0, undone: "hello world" })
expect(await run("zsh", "textarea", "hello world", 5)).toEqual({ text: "", caret: 0, undone: "hello world" })
expect(await run("bash", "textarea", "first\nhello world\nlast", 11)).toEqual({
text: "first\n world\nlast",
caret: 6,
undone: "first\nhello world\nlast",
})
expect(await run("zsh", "textarea", "first\nhello world\nlast", 11)).toEqual({
text: "first\n\nlast",
caret: 6,
undone: "first\nhello world\nlast",
})
// A second press on the emptied line joins it to the previous one.
expect(await run("zsh", "textarea", "first\n\nlast", 6)).toEqual({
text: "first\nlast",
caret: 5,
undone: "first\n\nlast",
})
expect(await run("bash", "input", "hello world", 5)).toEqual({ text: " world", caret: 0, undone: "hello world" })
expect(await run("zsh", "input", "hello world", 5)).toEqual({ text: "", caret: 0, undone: "hello world" })
})
1 change: 1 addition & 0 deletions packages/web/src/content/docs/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
- `input.line_discard` - What <kbd>ctrl+u</kbd> (`input_delete_to_line_start`) deletes in the prompt and every other text field: `"bash"` (default) deletes from the cursor back to the start of its line, as bash does; `"zsh"` deletes the whole line wherever the cursor is, as zsh does. In a multi-line prompt it is the cursor's line, never the rest of the prompt; on a line that is already empty, or with the cursor at its start under `"bash"`, it joins the line to the one above. <kbd>ctrl+k</kbd> (`input_delete_to_line_end`) and `input_delete_line` are unchanged, and `input_undo` brings the text back.
- `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect.
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.
Expand Down
Loading