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
184 changes: 184 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 @@ -53,6 +53,57 @@ 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 - find boundaries of this word
let start = effectiveOffset
while (start > 0 && !/\s/.test(text[start - 1])) start--

let end = effectiveOffset
while (end < text.length && !/\s/.test(text[end])) end++

return { start, 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
Expand Down Expand Up @@ -118,6 +169,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 @@ -127,6 +179,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
Expand Down Expand Up @@ -845,6 +898,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) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,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
141 changes: 141 additions & 0 deletions packages/opencode/test/tui/text-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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])) {
let start = effectiveOffset
while (start > 0 && !/\s/.test(text[start - 1])) start--

let end = effectiveOffset
while (end < text.length && !/\s/.test(text[end])) end++

return { start, 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 find word boundaries when cursor is inside a word", () => {
const result = getWordBoundariesForTransformation("hello world", 3)
expect(result).toEqual({ start: 0, 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")
})
})
16 changes: 16 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,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
*/
Expand Down
Loading