From a18b09cffdb59697c474087cd34824bbd3e537c3 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Sat, 3 Jan 2026 20:47:15 +0000 Subject: [PATCH 1/4] 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 | 181 ++++++++++++++++++ packages/opencode/src/config/config.ts | 5 + .../opencode/test/tui/text-transform.test.ts | 139 ++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 16 ++ packages/web/src/content/docs/keybinds.mdx | 65 +++++-- 5 files changed, 387 insertions(+), 19 deletions(-) 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 8576dd5763a..cb2b38f80f9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -55,6 +55,54 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] +function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { + if (text.length === 0) return null + + // Check if cursor is on a word character (inside a word) + const effectiveOffset = Math.min(cursorOffset, text.length) + if (effectiveOffset < text.length && !/\s/.test(text[effectiveOffset])) { + // Inside a word - transform from cursor to end of word (Emacs-style behavior) + let end = effectiveOffset + while (end < text.length && !/\s/.test(text[end])) end++ + + return { start: effectiveOffset, end } + } + + // Cursor is on whitespace or at end - find the next word + let end = effectiveOffset + while (end < text.length && /\s/.test(text[end])) end++ + + let nextEnd = end + while (nextEnd < text.length && !/\s/.test(text[nextEnd])) nextEnd++ + + if (nextEnd > end) { + return { start: end, end: nextEnd } + } + + // No next word - find the previous word + let start = effectiveOffset + while (start > 0 && /\s/.test(text[start - 1])) start-- + + let wordStart = start + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart-- + + return { start: wordStart, end: start } +} + +function lowercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end) +} + +function uppercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end) +} + +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) +} + export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -123,6 +171,7 @@ export function Prompt(props: PromptProps) { extmarkToPartIndex: Map interrupt: number placeholder: number + killBuffer: string }>({ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), prompt: { @@ -132,6 +181,7 @@ export function Prompt(props: PromptProps) { mode: "normal", extmarkToPartIndex: new Map(), interrupt: 0, + killBuffer: "", }) // Initialize agent/model/variant from last user message when session changes @@ -886,6 +936,137 @@ 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 = getWordBoundariesForTransformation(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 && !/\s/.test(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 = getWordBoundariesForTransformation(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/config/config.ts b/packages/opencode/src/config/config.ts index b0164e8aa86..6afe6b7d6fe 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -827,6 +827,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_cycle: z.string().optional().default("right").describe("Next 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..dc8d3052d47 --- /dev/null +++ b/packages/opencode/test/tui/text-transform.test.ts @@ -0,0 +1,139 @@ +import { describe, test, expect } from "bun:test" + +function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { + if (text.length === 0) return null + + const effectiveOffset = Math.min(cursorOffset, text.length) + if (effectiveOffset < text.length && !/\s/.test(text[effectiveOffset])) { + // Inside a word - transform from cursor to end of word (Emacs-style behavior) + let end = effectiveOffset + while (end < text.length && !/\s/.test(text[end])) end++ + + return { start: effectiveOffset, end } + } + + let end = effectiveOffset + while (end < text.length && /\s/.test(text[end])) end++ + + let nextEnd = end + while (nextEnd < text.length && !/\s/.test(text[nextEnd])) nextEnd++ + + if (nextEnd > end) { + return { start: end, end: nextEnd } + } + + let start = effectiveOffset + while (start > 0 && /\s/.test(text[start - 1])) start-- + + let wordStart = start + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart-- + + return { start: wordStart, end: start } +} + +function lowercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end) +} + +function uppercaseWord(text: string, start: number, end: number): string { + return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end) +} + +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) +} + +describe("getWordBoundariesForTransformation", () => { + test("should transform from cursor to end of word when cursor is inside a word", () => { + const result = getWordBoundariesForTransformation("hello world", 3) + expect(result).toEqual({ start: 3, end: 5 }) + }) + + test("should find word boundaries when cursor is at start of word", () => { + const result = getWordBoundariesForTransformation("hello world", 6) + expect(result).toEqual({ start: 6, end: 11 }) + }) + + test("should find next word when cursor is on whitespace", () => { + const result = getWordBoundariesForTransformation("hello world", 5) + expect(result).toEqual({ start: 6, end: 11 }) + }) + + test("should find next word when cursor is on multiple spaces", () => { + const result = getWordBoundariesForTransformation("hello world", 5) + expect(result).toEqual({ start: 8, end: 13 }) + }) + + test("should find previous word when cursor is after last word", () => { + const result = getWordBoundariesForTransformation("hello world", 12) + expect(result).toEqual({ start: 6, end: 11 }) + }) + + test("should return null for empty string", () => { + const result = getWordBoundariesForTransformation("", 0) + expect(result).toEqual(null) + }) + + test("should handle cursor at start of empty buffer", () => { + const result = getWordBoundariesForTransformation("", 0) + expect(result).toEqual(null) + }) + + test("should find word when cursor is at end of text", () => { + const result = getWordBoundariesForTransformation("hello world", 11) + expect(result).toEqual({ start: 6, end: 11 }) + }) + + test("should handle cursor past end of text on whitespace", () => { + const result = getWordBoundariesForTransformation("hello world ", 12) + expect(result).toEqual({ start: 6, end: 11 }) + }) +}) + +describe("lowercaseWord", () => { + test("should lowercase word in middle of text", () => { + const result = lowercaseWord("HELLO world", 0, 5) + expect(result).toBe("hello world") + }) + + test("should lowercase partial word", () => { + const result = lowercaseWord("HELLO world", 2, 5) + expect(result).toBe("HEllo world") + }) + + test("should handle empty range", () => { + const result = lowercaseWord("hello world", 3, 3) + expect(result).toBe("hello world") + }) +}) + +describe("uppercaseWord", () => { + test("should uppercase word in middle of text", () => { + const result = uppercaseWord("hello WORLD", 6, 11) + expect(result).toBe("hello WORLD") + }) + + test("should uppercase partial word", () => { + const result = uppercaseWord("hello world", 6, 9) + expect(result).toBe("hello WORld") + }) +}) + +describe("capitalizeWord", () => { + test("should capitalize word in middle of text", () => { + const result = capitalizeWord("hello WORLD", 6, 11) + expect(result).toBe("hello World") + }) + + test("should capitalize word with mixed case", () => { + const result = capitalizeWord("hello hElLo", 6, 11) + expect(result).toBe("hello Hello") + }) + + test("should only uppercase first letter", () => { + const result = capitalizeWord("hello WORLD", 0, 5) + expect(result).toBe("Hello WORLD") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad945..da049ef7f3b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1276,6 +1276,22 @@ export type KeybindsConfig = { * Delete word backward in input */ input_delete_word_backward?: string + /** + * Lowercase word in input + */ + input_lowercase_word?: string + /** + * Uppercase word in input + */ + input_uppercase_word?: string + /** + * Capitalize word in input + */ + input_capitalize_word?: string + /** + * Yank (paste) last killed text + */ + input_yank?: string /** * Previous history item */ diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 51508a4f864..b4dc54f3787 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -129,25 +129,52 @@ You can disable a keybind by adding the key to your config 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" + } +} +``` --- From 4165fd5bb8dd5fe4c1fb54343b4612e510cdcfb5 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 22 Feb 2026 01:47:10 -0500 Subject: [PATCH 2/4] fix(tui): align word boundaries with Readline/Emacs behavior Punctuation characters (., -, etc.) are now treated as word boundaries instead of being part of words, matching the behavior of Readline and Emacs where only alphanumeric characters and underscores are word constituents. --- .../cli/cmd/tui/component/prompt/index.tsx | 20 ++++----- .../opencode/test/tui/text-transform.test.ts | 42 +++++++++++++++---- 2 files changed, 45 insertions(+), 17 deletions(-) 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 24171d171c7..d0545d762ac 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -58,36 +58,36 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] +function isWordChar(ch: string): boolean { + return /\w/.test(ch) +} + function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { if (text.length === 0) return null - // Check if cursor is on a word character (inside a word) const effectiveOffset = Math.min(cursorOffset, text.length) - if (effectiveOffset < text.length && !/\s/.test(text[effectiveOffset])) { - // Inside a word - transform from cursor to end of word (Emacs-style behavior) + if (effectiveOffset < text.length && isWordChar(text[effectiveOffset])) { let end = effectiveOffset - while (end < text.length && !/\s/.test(text[end])) end++ + while (end < text.length && isWordChar(text[end])) end++ return { start: effectiveOffset, end } } - // Cursor is on whitespace or at end - find the next word let end = effectiveOffset - while (end < text.length && /\s/.test(text[end])) end++ + while (end < text.length && !isWordChar(text[end])) end++ let nextEnd = end - while (nextEnd < text.length && !/\s/.test(text[nextEnd])) nextEnd++ + while (nextEnd < text.length && isWordChar(text[nextEnd])) nextEnd++ if (nextEnd > end) { return { start: end, end: nextEnd } } - // No next word - find the previous word let start = effectiveOffset - while (start > 0 && /\s/.test(text[start - 1])) start-- + while (start > 0 && !isWordChar(text[start - 1])) start-- let wordStart = start - while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart-- + while (wordStart > 0 && isWordChar(text[wordStart - 1])) wordStart-- return { start: wordStart, end: start } } diff --git a/packages/opencode/test/tui/text-transform.test.ts b/packages/opencode/test/tui/text-transform.test.ts index dc8d3052d47..2e8c2d0283f 100644 --- a/packages/opencode/test/tui/text-transform.test.ts +++ b/packages/opencode/test/tui/text-transform.test.ts @@ -1,32 +1,35 @@ import { describe, test, expect } from "bun:test" +function isWordChar(ch: string): boolean { + return /\w/.test(ch) +} + function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { if (text.length === 0) return null const effectiveOffset = Math.min(cursorOffset, text.length) - if (effectiveOffset < text.length && !/\s/.test(text[effectiveOffset])) { - // Inside a word - transform from cursor to end of word (Emacs-style behavior) + if (effectiveOffset < text.length && isWordChar(text[effectiveOffset])) { let end = effectiveOffset - while (end < text.length && !/\s/.test(text[end])) end++ + while (end < text.length && isWordChar(text[end])) end++ return { start: effectiveOffset, end } } let end = effectiveOffset - while (end < text.length && /\s/.test(text[end])) end++ + while (end < text.length && !isWordChar(text[end])) end++ let nextEnd = end - while (nextEnd < text.length && !/\s/.test(text[nextEnd])) nextEnd++ + while (nextEnd < text.length && isWordChar(text[nextEnd])) nextEnd++ if (nextEnd > end) { return { start: end, end: nextEnd } } let start = effectiveOffset - while (start > 0 && /\s/.test(text[start - 1])) start-- + while (start > 0 && !isWordChar(text[start - 1])) start-- let wordStart = start - while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart-- + while (wordStart > 0 && isWordChar(text[wordStart - 1])) wordStart-- return { start: wordStart, end: start } } @@ -90,6 +93,31 @@ describe("getWordBoundariesForTransformation", () => { const result = getWordBoundariesForTransformation("hello world ", 12) expect(result).toEqual({ start: 6, end: 11 }) }) + + test("should treat period as word boundary", () => { + const result = getWordBoundariesForTransformation("foo.bar", 4) + expect(result).toEqual({ start: 4, end: 7 }) + }) + + test("should treat hyphen as word boundary", () => { + const result = getWordBoundariesForTransformation("foo-bar", 4) + expect(result).toEqual({ start: 4, end: 7 }) + }) + + test("should handle punctuation in filename", () => { + const result = getWordBoundariesForTransformation("branches.md", 8) + expect(result).toEqual({ start: 9, end: 11 }) + }) + + test("should find word before period when cursor on period", () => { + const result = getWordBoundariesForTransformation("foo.bar", 3) + expect(result).toEqual({ start: 4, end: 7 }) + }) + + test("should handle mixed punctuation and words", () => { + const result = getWordBoundariesForTransformation("MERGED-branches.md", 6) + expect(result).toEqual({ start: 7, end: 15 }) + }) }) describe("lowercaseWord", () => { From ed63df8278f76bf82e2c50e472dfdd32069569d1 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 22 Feb 2026 14:20:25 -0500 Subject: [PATCH 3/4] fix(tui): use consistent word boundary logic in delete-word-backward - input_delete_word_backward now uses isWordChar() like other word operations, treating punctuation as word boundaries - Remove duplicate input_transpose_characters handler (dead code) - Add tests for underscore handling and merged-branches.md scenarios --- .../cli/cmd/tui/component/prompt/index.tsx | 19 ++---------- .../opencode/test/tui/text-transform.test.ts | 30 +++++++++++++++---- 2 files changed, 27 insertions(+), 22 deletions(-) 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 d0545d762ac..59abb908dee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1021,7 +1021,8 @@ export function Prompt(props: PromptProps) { const text = input.plainText const cursorOffset = input.cursorOffset let start = cursorOffset - while (start > 0 && !/\s/.test(text[start - 1])) start-- + while (start > 0 && !isWordChar(text[start - 1])) start-- + while (start > 0 && isWordChar(text[start - 1])) start-- setStore("killBuffer", text.slice(start, cursorOffset)) } if ( @@ -1074,22 +1075,6 @@ export function Prompt(props: PromptProps) { 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/test/tui/text-transform.test.ts b/packages/opencode/test/tui/text-transform.test.ts index 2e8c2d0283f..cf99f06c013 100644 --- a/packages/opencode/test/tui/text-transform.test.ts +++ b/packages/opencode/test/tui/text-transform.test.ts @@ -79,11 +79,6 @@ describe("getWordBoundariesForTransformation", () => { expect(result).toEqual(null) }) - test("should handle cursor at start of empty buffer", () => { - const result = getWordBoundariesForTransformation("", 0) - expect(result).toEqual(null) - }) - test("should find word when cursor is at end of text", () => { const result = getWordBoundariesForTransformation("hello world", 11) expect(result).toEqual({ start: 6, end: 11 }) @@ -118,6 +113,31 @@ describe("getWordBoundariesForTransformation", () => { const result = getWordBoundariesForTransformation("MERGED-branches.md", 6) expect(result).toEqual({ start: 7, end: 15 }) }) + + test("underscore is a word character (not a boundary)", () => { + const result = getWordBoundariesForTransformation("foo_bar", 0) + expect(result).toEqual({ start: 0, end: 7 }) + }) + + test("cursor on hyphen in merged-branches.md finds 'merged' only", () => { + const result = getWordBoundariesForTransformation("merged-branches.md", 0) + expect(result).toEqual({ start: 0, end: 6 }) + }) + + test("cursor after 'MERGED-' in merged-branches.md finds 'branches' only", () => { + const result = getWordBoundariesForTransformation("MERGED-branches.md", 6) + expect(result).toEqual({ start: 7, end: 15 }) + }) + + test("cursor on dot in MERGED-BRANCHES.md finds 'md'", () => { + const result = getWordBoundariesForTransformation("MERGED-BRANCHES.md", 15) + expect(result).toEqual({ start: 16, end: 18 }) + }) + + test("cursor past end after trailing punctuation falls back to previous word", () => { + const result = getWordBoundariesForTransformation("MERGED-BRANCHES.", 16) + expect(result).toEqual({ start: 7, end: 15 }) + }) }) describe("lowercaseWord", () => { From 384322eb8622a229afc8b26c8bf749aa97c2dfdc Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 23 Feb 2026 10:48:57 -0500 Subject: [PATCH 4/4] refactor(tui): extract word utilities to separate module - Move isWordChar, getWordBoundaries, and case transformation functions to word.ts - Use /[A-Za-z0-9]/ for word chars (underscore is now a boundary, matching Readline/Emacs) - Update tests to import from actual module - Add isWordChar tests and underscore boundary tests --- .../cli/cmd/tui/component/prompt/index.tsx | 53 +---- .../src/cli/cmd/tui/component/prompt/word.ts | 45 ++++ .../opencode/test/tui/text-transform.test.ts | 213 ++++++++---------- 3 files changed, 140 insertions(+), 171 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/word.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 59abb908dee..908916d82a8 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" @@ -58,54 +59,6 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] -function isWordChar(ch: string): boolean { - return /\w/.test(ch) -} - -function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { - if (text.length === 0) return null - - const effectiveOffset = Math.min(cursorOffset, text.length) - if (effectiveOffset < text.length && isWordChar(text[effectiveOffset])) { - let end = effectiveOffset - while (end < text.length && isWordChar(text[end])) end++ - - return { start: effectiveOffset, end } - } - - let end = effectiveOffset - while (end < text.length && !isWordChar(text[end])) end++ - - let nextEnd = end - while (nextEnd < text.length && isWordChar(text[nextEnd])) nextEnd++ - - if (nextEnd > end) { - return { start: end, end: nextEnd } - } - - let start = effectiveOffset - while (start > 0 && !isWordChar(text[start - 1])) start-- - - let wordStart = start - while (wordStart > 0 && isWordChar(text[wordStart - 1])) wordStart-- - - return { start: wordStart, end: start } -} - -function lowercaseWord(text: string, start: number, end: number): string { - return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end) -} - -function uppercaseWord(text: string, start: number, end: number): string { - return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end) -} - -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) -} - export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -1010,7 +963,7 @@ export function Prompt(props: PromptProps) { ) { const text = input.plainText const cursorOffset = input.cursorOffset - const boundaries = getWordBoundariesForTransformation(text, cursorOffset) + const boundaries = getWordBoundaries(text, cursorOffset) if (boundaries) { setStore("killBuffer", text.slice(boundaries.start, boundaries.end)) } @@ -1041,7 +994,7 @@ export function Prompt(props: PromptProps) { start = selection.start end = selection.end } else { - const boundaries = getWordBoundariesForTransformation(text, cursorOffset) + const boundaries = getWordBoundaries(text, cursorOffset) if (!boundaries) { e.preventDefault() return 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/test/tui/text-transform.test.ts b/packages/opencode/test/tui/text-transform.test.ts index cf99f06c013..8cda94065c9 100644 --- a/packages/opencode/test/tui/text-transform.test.ts +++ b/packages/opencode/test/tui/text-transform.test.ts @@ -1,187 +1,158 @@ import { describe, test, expect } from "bun:test" +import { + isWordChar, + getWordBoundaries, + lowercaseWord, + uppercaseWord, + capitalizeWord, +} from "../../src/cli/cmd/tui/component/prompt/word" -function isWordChar(ch: string): boolean { - return /\w/.test(ch) -} - -function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null { - if (text.length === 0) return null - - const effectiveOffset = Math.min(cursorOffset, text.length) - if (effectiveOffset < text.length && isWordChar(text[effectiveOffset])) { - let end = effectiveOffset - while (end < text.length && isWordChar(text[end])) end++ - - return { start: effectiveOffset, end } - } - - let end = effectiveOffset - while (end < text.length && !isWordChar(text[end])) end++ - - let nextEnd = end - while (nextEnd < text.length && isWordChar(text[nextEnd])) nextEnd++ - - if (nextEnd > end) { - return { start: end, end: nextEnd } - } - - let start = effectiveOffset - while (start > 0 && !isWordChar(text[start - 1])) start-- +describe("isWordChar", () => { + test("letters are word chars", () => { + expect(isWordChar("a")).toBe(true) + expect(isWordChar("Z")).toBe(true) + }) - let wordStart = start - while (wordStart > 0 && isWordChar(text[wordStart - 1])) wordStart-- + test("digits are word chars", () => { + expect(isWordChar("0")).toBe(true) + expect(isWordChar("9")).toBe(true) + }) - return { start: wordStart, end: start } -} + test("underscore is NOT a word char (matches Readline/Emacs)", () => { + expect(isWordChar("_")).toBe(false) + }) -function lowercaseWord(text: string, start: number, end: number): string { - return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end) -} + test("punctuation is not a word char", () => { + expect(isWordChar("-")).toBe(false) + expect(isWordChar(".")).toBe(false) + expect(isWordChar(" ")).toBe(false) + }) +}) -function uppercaseWord(text: string, start: number, end: number): string { - return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end) -} +describe("getWordBoundaries", () => { + test("cursor inside word: transforms from cursor to end of word", () => { + expect(getWordBoundaries("hello world", 3)).toEqual({ start: 3, end: 5 }) + }) -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) -} + test("cursor at start of word: transforms full word", () => { + expect(getWordBoundaries("hello world", 6)).toEqual({ start: 6, end: 11 }) + }) -describe("getWordBoundariesForTransformation", () => { - test("should transform from cursor to end of word when cursor is inside a word", () => { - const result = getWordBoundariesForTransformation("hello world", 3) - expect(result).toEqual({ start: 3, end: 5 }) + test("cursor on space: skips to next word", () => { + expect(getWordBoundaries("hello world", 5)).toEqual({ start: 6, end: 11 }) }) - test("should find word boundaries when cursor is at start of word", () => { - const result = getWordBoundariesForTransformation("hello world", 6) - expect(result).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("should find next word when cursor is on whitespace", () => { - const result = getWordBoundariesForTransformation("hello world", 5) - expect(result).toEqual({ start: 6, end: 11 }) + test("empty string returns null", () => { + expect(getWordBoundaries("", 0)).toBeNull() }) - test("should find next word when cursor is on multiple spaces", () => { - const result = getWordBoundariesForTransformation("hello world", 5) - expect(result).toEqual({ start: 8, end: 13 }) + test("cursor at end of text falls back to previous word", () => { + expect(getWordBoundaries("hello world", 11)).toEqual({ start: 6, end: 11 }) }) - test("should find previous word when cursor is after last word", () => { - const result = getWordBoundariesForTransformation("hello world", 12) - expect(result).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("should return null for empty string", () => { - const result = getWordBoundariesForTransformation("", 0) - expect(result).toEqual(null) + test("cursor on trailing space falls back to previous word", () => { + expect(getWordBoundaries("hello world ", 12)).toEqual({ start: 6, end: 11 }) }) - test("should find word when cursor is at end of text", () => { - const result = getWordBoundariesForTransformation("hello world", 11) - expect(result).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 }) }) - test("should handle cursor past end of text on whitespace", () => { - const result = getWordBoundariesForTransformation("hello world ", 12) - expect(result).toEqual({ start: 6, end: 11 }) + 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("should treat period as word boundary", () => { - const result = getWordBoundariesForTransformation("foo.bar", 4) - expect(result).toEqual({ start: 4, end: 7 }) + test("cursor on hyphen: skips to 'branches', not 'branches.md'", () => { + expect(getWordBoundaries("MERGED-branches.md", 6)).toEqual({ start: 7, end: 15 }) }) - test("should treat hyphen as word boundary", () => { - const result = getWordBoundariesForTransformation("foo-bar", 4) - expect(result).toEqual({ start: 4, end: 7 }) + test("dot is a word boundary: cursor on '.' finds 'md'", () => { + expect(getWordBoundaries("MERGED-BRANCHES.md", 15)).toEqual({ start: 16, end: 18 }) }) - test("should handle punctuation in filename", () => { - const result = getWordBoundariesForTransformation("branches.md", 8) - expect(result).toEqual({ start: 9, end: 11 }) + test("cursor on '-': skips to next word", () => { + expect(getWordBoundaries("foo-bar", 3)).toEqual({ start: 4, end: 7 }) }) - test("should find word before period when cursor on period", () => { - const result = getWordBoundariesForTransformation("foo.bar", 3) - expect(result).toEqual({ start: 4, end: 7 }) + test("underscore is a word boundary: 'foo_bar' from 0 finds only 'foo'", () => { + expect(getWordBoundaries("foo_bar", 0)).toEqual({ start: 0, end: 3 }) }) - test("should handle mixed punctuation and words", () => { - const result = getWordBoundariesForTransformation("MERGED-branches.md", 6) - expect(result).toEqual({ start: 7, end: 15 }) + test("underscore is a word boundary: cursor on '_' finds 'bar'", () => { + expect(getWordBoundaries("foo_bar", 3)).toEqual({ start: 4, end: 7 }) }) - test("underscore is a word character (not a boundary)", () => { - const result = getWordBoundariesForTransformation("foo_bar", 0) - expect(result).toEqual({ start: 0, end: 7 }) + test("digits are word chars: 'foo123' is one word", () => { + expect(getWordBoundaries("foo123 bar", 0)).toEqual({ start: 0, end: 6 }) }) +}) - test("cursor on hyphen in merged-branches.md finds 'merged' only", () => { - const result = getWordBoundariesForTransformation("merged-branches.md", 0) - expect(result).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("cursor after 'MERGED-' in merged-branches.md finds 'branches' only", () => { - const result = getWordBoundariesForTransformation("MERGED-branches.md", 6) - expect(result).toEqual({ start: 7, end: 15 }) + 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("cursor on dot in MERGED-BRANCHES.md finds 'md'", () => { - const result = getWordBoundariesForTransformation("MERGED-BRANCHES.md", 15) - expect(result).toEqual({ start: 16, end: 18 }) + 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("cursor past end after trailing punctuation falls back to previous word", () => { - const result = getWordBoundariesForTransformation("MERGED-BRANCHES.", 16) - expect(result).toEqual({ start: 7, end: 15 }) + 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("should lowercase word in middle of text", () => { - const result = lowercaseWord("HELLO world", 0, 5) - expect(result).toBe("hello world") + test("lowercases word in range", () => { + expect(lowercaseWord("HELLO world", 0, 5)).toBe("hello world") }) - test("should lowercase partial word", () => { - const result = lowercaseWord("HELLO world", 2, 5) - expect(result).toBe("HEllo world") + test("lowercases partial word from cursor", () => { + expect(lowercaseWord("HELLO world", 2, 5)).toBe("HEllo world") }) - test("should handle empty range", () => { - const result = lowercaseWord("hello world", 3, 3) - expect(result).toBe("hello world") + test("empty range is a no-op", () => { + expect(lowercaseWord("hello world", 3, 3)).toBe("hello world") }) }) describe("uppercaseWord", () => { - test("should uppercase word in middle of text", () => { - const result = uppercaseWord("hello WORLD", 6, 11) - expect(result).toBe("hello WORLD") + test("uppercases word in range", () => { + expect(uppercaseWord("hello world", 6, 11)).toBe("hello WORLD") }) - test("should uppercase partial word", () => { - const result = uppercaseWord("hello world", 6, 9) - expect(result).toBe("hello WORld") + test("uppercases partial word from cursor", () => { + expect(uppercaseWord("hello world", 6, 9)).toBe("hello WORld") }) }) describe("capitalizeWord", () => { - test("should capitalize word in middle of text", () => { - const result = capitalizeWord("hello WORLD", 6, 11) - expect(result).toBe("hello World") + test("capitalizes word (first char up, rest down)", () => { + expect(capitalizeWord("hello WORLD", 6, 11)).toBe("hello World") }) - test("should capitalize word with mixed case", () => { - const result = capitalizeWord("hello hElLo", 6, 11) - expect(result).toBe("hello Hello") + test("capitalizes mixed-case word", () => { + expect(capitalizeWord("hello hElLo", 6, 11)).toBe("hello Hello") }) - test("should only uppercase first letter", () => { - const result = capitalizeWord("hello WORLD", 0, 5) - expect(result).toBe("Hello WORLD") + test("only upcases first letter", () => { + expect(capitalizeWord("hello WORLD", 0, 5)).toBe("Hello WORLD") }) })