From 9388a7ac37cff9f6863320bd387a80646cbd9cea Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Sat, 3 Jan 2026 20:47:15 +0000 Subject: [PATCH] feat(tui): add configurable readline-style text transformations Without this patch, users must manually retype text to change case or paste deleted content in the TUI prompt, and some keybindings conflict with other TUI functions. This is a problem because it slows down text editing and limits the ability to customize keybindings to match user preferences. This patch solves the problem by adding configurable shortcuts for lowercase, uppercase, capitalize word, transpose characters, and yank operations, along with a kill buffer for storing deleted text. --- .../cli/cmd/tui/component/prompt/index.tsx | 135 +++++++++++++++ .../src/cli/cmd/tui/component/prompt/word.ts | 45 +++++ packages/opencode/src/config/config.ts | 5 + .../opencode/test/tui/text-transform.test.ts | 162 ++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 65 +++++-- 5 files changed, 393 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/word.ts create mode 100644 packages/opencode/test/tui/text-transform.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83..92491b54b40 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -13,6 +13,7 @@ import { Identifier } from "@/id/id" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" +import { isWordChar, getWordBoundaries, lowercaseWord, uppercaseWord, capitalizeWord } from "./word" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -126,6 +127,7 @@ export function Prompt(props: PromptProps) { extmarkToPartIndex: Map interrupt: number placeholder: number + killBuffer: string }>({ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), prompt: { @@ -135,6 +137,7 @@ export function Prompt(props: PromptProps) { mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, + killBuffer: "", }) createEffect( @@ -909,6 +912,138 @@ export function Prompt(props: PromptProps) { if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) input.cursorOffset = input.plainText.length } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_to_line_end", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + const textToEnd = text.slice(cursorOffset) + setStore("killBuffer", textToEnd) + } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_transpose_characters", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + + let char1Pos: number, char2Pos: number, newCursorOffset: number + + if (text.length < 2) { + return + } else if (cursorOffset === 0) { + char1Pos = 0 + char2Pos = 1 + newCursorOffset = 1 + } else if (cursorOffset === text.length) { + char1Pos = text.length - 2 + char2Pos = text.length - 1 + newCursorOffset = cursorOffset + } else { + char1Pos = cursorOffset - 1 + char2Pos = cursorOffset + newCursorOffset = cursorOffset + 1 + } + + const char1 = text[char1Pos] + const char2 = text[char2Pos] + const newText = + text.slice(0, char1Pos) + + char2 + + text.slice(char1Pos + 1, char2Pos) + + char1 + + text.slice(char2Pos + 1) + input.setText(newText) + input.cursorOffset = newCursorOffset + setStore("prompt", "input", newText) + e.preventDefault() + return + } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_word_forward", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + const boundaries = getWordBoundaries(text, cursorOffset) + if (boundaries) { + setStore("killBuffer", text.slice(boundaries.start, boundaries.end)) + } + } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_word_backward", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + let start = cursorOffset + while (start > 0 && !isWordChar(text[start - 1])) start-- + while (start > 0 && isWordChar(text[start - 1])) start-- + setStore("killBuffer", text.slice(start, cursorOffset)) + } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_lowercase_word", e) || + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_uppercase_word", e) || + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_capitalize_word", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + const selection = input.getSelection() + const hasSelection = selection !== null + + let start: number, end: number + + if (hasSelection && selection) { + start = selection.start + end = selection.end + } else { + const boundaries = getWordBoundaries(text, cursorOffset) + if (!boundaries) { + e.preventDefault() + return + } + start = boundaries.start + end = boundaries.end + } + + let newText: string + if ((keybind as { match: (key: string, evt: unknown) => boolean }).match("input_lowercase_word", e)) { + newText = lowercaseWord(text, start, end) + } else if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_uppercase_word", e) + ) { + newText = uppercaseWord(text, start, end) + } else { + newText = capitalizeWord(text, start, end) + } + + input.setText(newText) + input.cursorOffset = end + setStore("prompt", "input", newText) + e.preventDefault() + return + } + if ((keybind as { match: (key: string, evt: unknown) => boolean }).match("input_yank", e)) { + if (store.killBuffer) { + input.insertText(store.killBuffer) + setStore("prompt", "input", input.plainText) + e.preventDefault() + return + } + } + if ( + (keybind as { match: (key: string, evt: unknown) => boolean }).match("input_transpose_characters", e) + ) { + const text = input.plainText + const cursorOffset = input.cursorOffset + if (cursorOffset >= 2) { + const before = text.slice(cursorOffset - 2, cursorOffset - 1) + const current = text.slice(cursorOffset - 1, cursorOffset) + const newText = text.slice(0, cursorOffset - 2) + current + before + text.slice(cursorOffset) + input.setText(newText) + input.cursorOffset = cursorOffset + setStore("prompt", "input", newText) + e.preventDefault() + } + return + } }} onSubmit={submit} onPaste={async (event: PasteEvent) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/word.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/word.ts new file mode 100644 index 00000000000..62ee05a74a8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/word.ts @@ -0,0 +1,45 @@ +// Word characters are [A-Za-z0-9] only, matching Readline's isalnum() and +// Emacs' word syntax class. Underscore and punctuation are non-word chars. +export function isWordChar(ch: string): boolean { + return /[A-Za-z0-9]/.test(ch) +} + +export function getWordBoundaries(text: string, cursorOffset: number): { start: number; end: number } | null { + if (text.length === 0) return null + + const effectiveOffset = Math.min(cursorOffset, text.length) + + // Readline/Emacs forward-word semantics: skip non-word chars, then advance + // through word chars. If no next word exists, fall back to the previous word + // (more useful than Emacs' silent no-op at end of buffer). + let pos = effectiveOffset + while (pos < text.length && !isWordChar(text[pos])) pos++ + + if (pos >= text.length) { + // No next word — fall back to previous word + let end = effectiveOffset + while (end > 0 && !isWordChar(text[end - 1])) end-- + if (end === 0) return null + let start = end + while (start > 0 && isWordChar(text[start - 1])) start-- + return { start, end } + } + + const start = pos + while (pos < text.length && isWordChar(text[pos])) pos++ + return { start, end: pos } +} + +export function lowercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end) +} + +export function uppercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end) +} + +export function capitalizeWord(text: string, start: number, end: number): string { + const segment = text.slice(start, end) + const capitalized = segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase() + return text.slice(0, start) + capitalized + text.slice(end) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225a..f952993270e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -895,6 +895,11 @@ export namespace Config { .optional() .default("ctrl+w,ctrl+backspace,alt+backspace") .describe("Delete word backward in input"), + input_lowercase_word: z.string().optional().default("alt+l").describe("Lowercase word in input"), + input_uppercase_word: z.string().optional().default("alt+u").describe("Uppercase word in input"), + input_capitalize_word: z.string().optional().default("alt+c").describe("Capitalize word in input"), + input_yank: z.string().optional().default("ctrl+y").describe("Yank (paste) last killed text"), + input_transpose_characters: z.string().optional().describe("Transpose characters in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), session_child_first: z.string().optional().default("down").describe("Go to first child session"), diff --git a/packages/opencode/test/tui/text-transform.test.ts b/packages/opencode/test/tui/text-transform.test.ts new file mode 100644 index 00000000000..6b3d400b375 --- /dev/null +++ b/packages/opencode/test/tui/text-transform.test.ts @@ -0,0 +1,162 @@ +import { describe, test, expect } from "bun:test" +import { + isWordChar, + getWordBoundaries, + lowercaseWord, + uppercaseWord, + capitalizeWord, +} from "../../src/cli/cmd/tui/component/prompt/word" + +describe("isWordChar", () => { + test("letters are word chars", () => { + expect(isWordChar("a")).toBe(true) + expect(isWordChar("Z")).toBe(true) + }) + + test("digits are word chars", () => { + expect(isWordChar("0")).toBe(true) + expect(isWordChar("9")).toBe(true) + }) + + test("underscore is NOT a word char (matches Readline/Emacs)", () => { + expect(isWordChar("_")).toBe(false) + }) + + test("punctuation is not a word char", () => { + expect(isWordChar("-")).toBe(false) + expect(isWordChar(".")).toBe(false) + expect(isWordChar(" ")).toBe(false) + }) +}) + +describe("getWordBoundaries", () => { + // Basic cases + test("cursor inside word: transforms from cursor to end of word", () => { + expect(getWordBoundaries("hello world", 3)).toEqual({ start: 3, end: 5 }) + }) + + test("cursor at start of word: transforms full word", () => { + expect(getWordBoundaries("hello world", 6)).toEqual({ start: 6, end: 11 }) + }) + + test("cursor on space: skips to next word", () => { + expect(getWordBoundaries("hello world", 5)).toEqual({ start: 6, end: 11 }) + }) + + test("cursor on multiple spaces: skips to next word", () => { + expect(getWordBoundaries("hello world", 5)).toEqual({ start: 8, end: 13 }) + }) + + test("empty string returns null", () => { + expect(getWordBoundaries("", 0)).toBeNull() + }) + + test("cursor at end of text falls back to previous word", () => { + expect(getWordBoundaries("hello world", 11)).toEqual({ start: 6, end: 11 }) + }) + + test("cursor past end falls back to previous word", () => { + expect(getWordBoundaries("hello world", 12)).toEqual({ start: 6, end: 11 }) + }) + + test("cursor on trailing space falls back to previous word", () => { + expect(getWordBoundaries("hello world ", 12)).toEqual({ start: 6, end: 11 }) + }) + + test("cursor past trailing punctuation falls back to previous word", () => { + expect(getWordBoundaries("MERGED-BRANCHES.", 16)).toEqual({ start: 7, end: 15 }) + }) + + // Punctuation as word boundaries (the ariane-emory bug report) + test("hyphen is a word boundary: first alt+u on 'merged-branches.md' finds only 'merged'", () => { + expect(getWordBoundaries("merged-branches.md", 0)).toEqual({ start: 0, end: 6 }) + }) + + test("cursor on hyphen: skips to 'branches', not 'branches.md'", () => { + expect(getWordBoundaries("MERGED-branches.md", 6)).toEqual({ start: 7, end: 15 }) + }) + + test("dot is a word boundary: cursor on '.' finds 'md'", () => { + expect(getWordBoundaries("MERGED-BRANCHES.md", 15)).toEqual({ start: 16, end: 18 }) + }) + + test("cursor on '-': skips to next word", () => { + expect(getWordBoundaries("foo-bar", 3)).toEqual({ start: 4, end: 7 }) + }) + + // Underscore is NOT a word char + test("underscore is a word boundary: 'foo_bar' from 0 finds only 'foo'", () => { + expect(getWordBoundaries("foo_bar", 0)).toEqual({ start: 0, end: 3 }) + }) + + test("underscore is a word boundary: cursor on '_' finds 'bar'", () => { + expect(getWordBoundaries("foo_bar", 3)).toEqual({ start: 4, end: 7 }) + }) + + // Digits + test("digits are word chars: 'foo123' is one word", () => { + expect(getWordBoundaries("foo123 bar", 0)).toEqual({ start: 0, end: 6 }) + }) +}) + +describe("uppercaseWord integration", () => { + test("first alt+u on 'merged-branches.md' upcases only 'merged'", () => { + const bounds = getWordBoundaries("merged-branches.md", 0)! + expect(bounds).toEqual({ start: 0, end: 6 }) + expect(uppercaseWord("merged-branches.md", bounds.start, bounds.end)).toBe("MERGED-branches.md") + }) + + test("second alt+u (cursor on '-') upcases only 'branches'", () => { + const bounds = getWordBoundaries("MERGED-branches.md", 6)! + expect(bounds).toEqual({ start: 7, end: 15 }) + expect(uppercaseWord("MERGED-branches.md", bounds.start, bounds.end)).toBe("MERGED-BRANCHES.md") + }) + + test("third alt+u (cursor on '.') upcases only 'md'", () => { + const bounds = getWordBoundaries("MERGED-BRANCHES.md", 15)! + expect(bounds).toEqual({ start: 16, end: 18 }) + expect(uppercaseWord("MERGED-BRANCHES.md", bounds.start, bounds.end)).toBe("MERGED-BRANCHES.MD") + }) + + test("alt+u at end of buffer falls back to previous word", () => { + expect(getWordBoundaries("hello world", 11)).toEqual({ start: 6, end: 11 }) + }) +}) + +describe("lowercaseWord", () => { + test("lowercases word in range", () => { + expect(lowercaseWord("HELLO world", 0, 5)).toBe("hello world") + }) + + test("lowercases partial word from cursor", () => { + expect(lowercaseWord("HELLO world", 2, 5)).toBe("HEllo world") + }) + + test("empty range is a no-op", () => { + expect(lowercaseWord("hello world", 3, 3)).toBe("hello world") + }) +}) + +describe("uppercaseWord", () => { + test("uppercases word in range", () => { + expect(uppercaseWord("hello world", 6, 11)).toBe("hello WORLD") + }) + + test("uppercases partial word from cursor", () => { + expect(uppercaseWord("hello world", 6, 9)).toBe("hello WORld") + }) +}) + +describe("capitalizeWord", () => { + test("capitalizes word (first char up, rest down)", () => { + expect(capitalizeWord("hello WORLD", 6, 11)).toBe("hello World") + }) + + test("capitalizes mixed-case word", () => { + expect(capitalizeWord("hello hElLo", 6, 11)).toBe("hello Hello") + }) + + test("only upcases first letter", () => { + expect(capitalizeWord("hello WORLD", 0, 5)).toBe("Hello WORLD") + }) +}) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 95b3d496391..b8b9838b4a0 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -130,25 +130,52 @@ You can disable a keybind by adding the key to `tui.json` with a value of "none" --- -## Desktop prompt shortcuts - -The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. - -| Shortcut | Action | -| -------- | ---------------------------------------- | -| `ctrl+a` | Move to start of current line | -| `ctrl+e` | Move to end of current line | -| `ctrl+b` | Move cursor back one character | -| `ctrl+f` | Move cursor forward one character | -| `alt+b` | Move cursor back one word | -| `alt+f` | Move cursor forward one word | -| `ctrl+d` | Delete character under cursor | -| `ctrl+k` | Kill to end of line | -| `ctrl+u` | Kill to start of line | -| `ctrl+w` | Kill previous word | -| `alt+d` | Kill next word | -| `ctrl+t` | Transpose characters | -| `ctrl+g` | Cancel popovers / abort running response | +## Prompt shortcuts + +The prompt input supports common Readline/Emacs-style shortcuts for editing text. + +### Desktop app + +The desktop app prompt shortcuts are built-in and not configurable. + +### TUI (terminal) + +The TUI prompt shortcuts are configurable via `opencode.json`. + +### Shortcut reference + +| Shortcut | Action | Desktop | TUI | +| -------- | ---------------------------------------- | :-----: | :-: | +| `ctrl+a` | Move to start of current line | ✓ | ✓ | +| `ctrl+e` | Move to end of current line | ✓ | ✓ | +| `ctrl+b` | Move cursor back one character | ✓ | ✓ | +| `ctrl+f` | Move cursor forward one character | ✓ | ✓ | +| `alt+b` | Move cursor back one word | ✓ | ✓ | +| `alt+f` | Move cursor forward one word | ✓ | ✓ | +| `ctrl+d` | Delete character under cursor | ✓ | ✓ | +| `ctrl+k` | Kill to end of line | ✓ | ✓ | +| `ctrl+u` | Kill to start of line | ✓ | ✓ | +| `ctrl+w` | Kill previous word | ✓ | ✓ | +| `alt+d` | Kill next word | ✓ | ✓ | +| `ctrl+y` | Yank (paste) last killed text | ✓ | ✓ | +| `ctrl+t` | Transpose characters | ✓ | ✓ | +| `ctrl+g` | Cancel popovers / abort running response | ✓ | | +| `alt+u` | Uppercase word from cursor | | ✓ | +| `alt+l` | Lowercase word from cursor | | ✓ | +| `alt+c` | Capitalize word from cursor | | ✓ | + +#### Note on TUI keybinding conflicts + +Some shortcuts may conflict with default TUI keybindings. For example, `ctrl+t` is used to cycle model variants in the TUI. You can rebind it to transpose characters by rebinding the default and adding a new binding, e.g.: + +```json title="opencode.json" +{ + "keybinds": { + "variant_cycle": "v", + "input_transpose_characters": "ctrl+t" + } +} +``` ---