Skip to content
119 changes: 119 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -126,6 +127,7 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
killBuffer: string
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
Expand All @@ -135,6 +137,7 @@ export function Prompt(props: PromptProps) {
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
killBuffer: "",
})

createEffect(
Expand Down Expand Up @@ -909,6 +912,122 @@ 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
}
}
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/word.ts
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,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("<leader>right").describe("Next child session"),
Expand Down
158 changes: 158 additions & 0 deletions packages/opencode/test/tui/text-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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", () => {
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 })
})

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 })
})

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 })
})

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")
})
})
Loading