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 aad0fd76c4b..92da932251d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -914,6 +914,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..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/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4050ef15738..cc569c058c0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1335,6 +1335,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 25fe2a1d910..877679dd9fb 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 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" + } +} +``` ---