Skip to content

Commit 397fc34

Browse files
Adam SpiersAdam Spiers
authored andcommitted
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.
1 parent aaf8317 commit 397fc34

File tree

5 files changed

+434
-19
lines changed

5 files changed

+434
-19
lines changed

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,44 @@ export type PromptRef = {
5858
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
5959
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
6060

61+
// Word characters are [A-Za-z0-9] only, matching Readline's isalnum() and
62+
// Emacs' word syntax class. Underscore and punctuation are non-word chars.
63+
function isWordChar(ch: string): boolean {
64+
return /[A-Za-z0-9]/.test(ch)
65+
}
66+
67+
function getWordBoundariesForTransformation(text: string, cursorOffset: number): { start: number; end: number } | null {
68+
if (text.length === 0) return null
69+
70+
const effectiveOffset = Math.min(cursorOffset, text.length)
71+
72+
// Readline/Emacs forward-word semantics: skip non-word chars, then advance
73+
// through word chars. Returns null if no next word exists (no fallback to
74+
// previous word — upcase-word/downcase-word at end of buffer is a no-op).
75+
let pos = effectiveOffset
76+
while (pos < text.length && !isWordChar(text[pos])) pos++
77+
78+
if (pos >= text.length) return null
79+
80+
const start = pos
81+
while (pos < text.length && isWordChar(text[pos])) pos++
82+
return { start, end: pos }
83+
}
84+
85+
function lowercaseWord(text: string, start: number, end: number): string {
86+
return text.slice(0, start) + text.slice(start, end).toLowerCase() + text.slice(end)
87+
}
88+
89+
function uppercaseWord(text: string, start: number, end: number): string {
90+
return text.slice(0, start) + text.slice(start, end).toUpperCase() + text.slice(end)
91+
}
92+
93+
function capitalizeWord(text: string, start: number, end: number): string {
94+
const segment = text.slice(start, end)
95+
const capitalized = segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()
96+
return text.slice(0, start) + capitalized + text.slice(end)
97+
}
98+
6199
export function Prompt(props: PromptProps) {
62100
let input: TextareaRenderable
63101
let anchor: BoxRenderable
@@ -126,6 +164,7 @@ export function Prompt(props: PromptProps) {
126164
extmarkToPartIndex: Map<number, number>
127165
interrupt: number
128166
placeholder: number
167+
killBuffer: string
129168
}>({
130169
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
131170
prompt: {
@@ -135,6 +174,7 @@ export function Prompt(props: PromptProps) {
135174
mode: "normal",
136175
extmarkToPartIndex: new Map(),
137176
interrupt: 0,
177+
killBuffer: "",
138178
})
139179

140180
createEffect(
@@ -909,6 +949,138 @@ export function Prompt(props: PromptProps) {
909949
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
910950
input.cursorOffset = input.plainText.length
911951
}
952+
if (
953+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_to_line_end", e)
954+
) {
955+
const text = input.plainText
956+
const cursorOffset = input.cursorOffset
957+
const textToEnd = text.slice(cursorOffset)
958+
setStore("killBuffer", textToEnd)
959+
}
960+
if (
961+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_transpose_characters", e)
962+
) {
963+
const text = input.plainText
964+
const cursorOffset = input.cursorOffset
965+
966+
let char1Pos: number, char2Pos: number, newCursorOffset: number
967+
968+
if (text.length < 2) {
969+
return
970+
} else if (cursorOffset === 0) {
971+
char1Pos = 0
972+
char2Pos = 1
973+
newCursorOffset = 1
974+
} else if (cursorOffset === text.length) {
975+
char1Pos = text.length - 2
976+
char2Pos = text.length - 1
977+
newCursorOffset = cursorOffset
978+
} else {
979+
char1Pos = cursorOffset - 1
980+
char2Pos = cursorOffset
981+
newCursorOffset = cursorOffset + 1
982+
}
983+
984+
const char1 = text[char1Pos]
985+
const char2 = text[char2Pos]
986+
const newText =
987+
text.slice(0, char1Pos) +
988+
char2 +
989+
text.slice(char1Pos + 1, char2Pos) +
990+
char1 +
991+
text.slice(char2Pos + 1)
992+
input.setText(newText)
993+
input.cursorOffset = newCursorOffset
994+
setStore("prompt", "input", newText)
995+
e.preventDefault()
996+
return
997+
}
998+
if (
999+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_word_forward", e)
1000+
) {
1001+
const text = input.plainText
1002+
const cursorOffset = input.cursorOffset
1003+
const boundaries = getWordBoundariesForTransformation(text, cursorOffset)
1004+
if (boundaries) {
1005+
setStore("killBuffer", text.slice(boundaries.start, boundaries.end))
1006+
}
1007+
}
1008+
if (
1009+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_delete_word_backward", e)
1010+
) {
1011+
const text = input.plainText
1012+
const cursorOffset = input.cursorOffset
1013+
let start = cursorOffset
1014+
while (start > 0 && !isWordChar(text[start - 1])) start--
1015+
while (start > 0 && isWordChar(text[start - 1])) start--
1016+
setStore("killBuffer", text.slice(start, cursorOffset))
1017+
}
1018+
if (
1019+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_lowercase_word", e) ||
1020+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_uppercase_word", e) ||
1021+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_capitalize_word", e)
1022+
) {
1023+
const text = input.plainText
1024+
const cursorOffset = input.cursorOffset
1025+
const selection = input.getSelection()
1026+
const hasSelection = selection !== null
1027+
1028+
let start: number, end: number
1029+
1030+
if (hasSelection && selection) {
1031+
start = selection.start
1032+
end = selection.end
1033+
} else {
1034+
const boundaries = getWordBoundariesForTransformation(text, cursorOffset)
1035+
if (!boundaries) {
1036+
e.preventDefault()
1037+
return
1038+
}
1039+
start = boundaries.start
1040+
end = boundaries.end
1041+
}
1042+
1043+
let newText: string
1044+
if ((keybind as { match: (key: string, evt: unknown) => boolean }).match("input_lowercase_word", e)) {
1045+
newText = lowercaseWord(text, start, end)
1046+
} else if (
1047+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_uppercase_word", e)
1048+
) {
1049+
newText = uppercaseWord(text, start, end)
1050+
} else {
1051+
newText = capitalizeWord(text, start, end)
1052+
}
1053+
1054+
input.setText(newText)
1055+
input.cursorOffset = end
1056+
setStore("prompt", "input", newText)
1057+
e.preventDefault()
1058+
return
1059+
}
1060+
if ((keybind as { match: (key: string, evt: unknown) => boolean }).match("input_yank", e)) {
1061+
if (store.killBuffer) {
1062+
input.insertText(store.killBuffer)
1063+
setStore("prompt", "input", input.plainText)
1064+
e.preventDefault()
1065+
return
1066+
}
1067+
}
1068+
if (
1069+
(keybind as { match: (key: string, evt: unknown) => boolean }).match("input_transpose_characters", e)
1070+
) {
1071+
const text = input.plainText
1072+
const cursorOffset = input.cursorOffset
1073+
if (cursorOffset >= 2) {
1074+
const before = text.slice(cursorOffset - 2, cursorOffset - 1)
1075+
const current = text.slice(cursorOffset - 1, cursorOffset)
1076+
const newText = text.slice(0, cursorOffset - 2) + current + before + text.slice(cursorOffset)
1077+
input.setText(newText)
1078+
input.cursorOffset = cursorOffset
1079+
setStore("prompt", "input", newText)
1080+
e.preventDefault()
1081+
}
1082+
return
1083+
}
9121084
}}
9131085
onSubmit={submit}
9141086
onPaste={async (event: PasteEvent) => {

packages/opencode/src/config/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,11 @@ export namespace Config {
914914
.optional()
915915
.default("ctrl+w,ctrl+backspace,alt+backspace")
916916
.describe("Delete word backward in input"),
917+
input_lowercase_word: z.string().optional().default("alt+l").describe("Lowercase word in input"),
918+
input_uppercase_word: z.string().optional().default("alt+u").describe("Uppercase word in input"),
919+
input_capitalize_word: z.string().optional().default("alt+c").describe("Capitalize word in input"),
920+
input_yank: z.string().optional().default("ctrl+y").describe("Yank (paste) last killed text"),
921+
input_transpose_characters: z.string().optional().describe("Transpose characters in input"),
917922
history_previous: z.string().optional().default("up").describe("Previous history item"),
918923
history_next: z.string().optional().default("down").describe("Next history item"),
919924
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),

0 commit comments

Comments
 (0)