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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 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,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) => {
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 @@ -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("<leader>right").describe("Next child session"),
Expand Down
162 changes: 162 additions & 0 deletions packages/opencode/test/tui/text-transform.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading
Loading