Skip to content

Commit 99c933b

Browse files
koki-developclaude
andcommitted
feat: Add advanced terminal-style key bindings to input field
Replace ink-text-input with custom implementation using useInput hook to support comprehensive terminal editor key bindings: - Cursor movement: Arrow keys, Ctrl+b/f (left/right), Ctrl+a/e (home/end) - Multi-line navigation: Up/Down arrows, Ctrl+n/p for line navigation - Text editing: Ctrl+u (kill to line start), Ctrl+w (kill word backward) - Enhanced deletion: Ctrl+k (kill to line end), Ctrl+d/Delete (delete char) - Input management: Ctrl+l (clear input), Backspace (delete previous char) - Improved cursor display: Visible cursor in all positions including empty input 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 09f431c commit 99c933b

File tree

1 file changed

+237
-9
lines changed

1 file changed

+237
-9
lines changed

src/components/InputField.tsx

Lines changed: 237 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Box, Text } from "ink";
2-
import TextInput from "ink-text-input";
1+
import { Box, Text, useInput } from "ink";
32
import type React from "react";
3+
import { useCallback, useMemo, useState } from "react";
44

55
interface InputFieldProps {
66
value: string;
@@ -15,16 +15,244 @@ export const InputField: React.FC<InputFieldProps> = ({
1515
onSubmit,
1616
showCursor,
1717
}) => {
18+
const [cursorPosition, setCursorPosition] = useState(value.length);
19+
20+
const lines = useMemo(() => value.split("\n"), [value]);
21+
const { currentLineIndex, currentColumnIndex } = useMemo(() => {
22+
let pos = 0;
23+
for (let i = 0; i < lines.length; i++) {
24+
if (pos + (lines[i]?.length || 0) >= cursorPosition) {
25+
return {
26+
currentLineIndex: i,
27+
currentColumnIndex: cursorPosition - pos,
28+
};
29+
}
30+
pos += (lines[i]?.length || 0) + 1; // +1 for newline
31+
}
32+
return {
33+
currentLineIndex: lines.length - 1,
34+
currentColumnIndex: lines[lines.length - 1]?.length || 0,
35+
};
36+
}, [lines, cursorPosition]);
37+
38+
const updateValue = useCallback(
39+
(newValue: string, newCursorPos?: number) => {
40+
onChange(newValue);
41+
if (newCursorPos !== undefined) {
42+
setCursorPosition(Math.max(0, Math.min(newValue.length, newCursorPos)));
43+
}
44+
},
45+
[onChange],
46+
);
47+
48+
useInput(
49+
useCallback(
50+
(input, key) => {
51+
if (!showCursor) return;
52+
53+
if (key.return) {
54+
onSubmit();
55+
return;
56+
}
57+
58+
if (key.backspace) {
59+
if (cursorPosition > 0) {
60+
const newValue =
61+
value.slice(0, cursorPosition - 1) + value.slice(cursorPosition);
62+
updateValue(newValue, cursorPosition - 1);
63+
}
64+
return;
65+
}
66+
67+
// Delete character at cursor position (Delete key and Ctrl+d)
68+
if (key.delete || (key.ctrl && input === "d")) {
69+
if (cursorPosition < value.length) {
70+
const newValue =
71+
value.slice(0, cursorPosition) + value.slice(cursorPosition + 1);
72+
updateValue(newValue, cursorPosition);
73+
}
74+
return;
75+
}
76+
77+
// Cursor movement
78+
if (key.leftArrow || (key.ctrl && input === "b")) {
79+
setCursorPosition(Math.max(0, cursorPosition - 1));
80+
return;
81+
}
82+
83+
if (key.rightArrow || (key.ctrl && input === "f")) {
84+
setCursorPosition(Math.min(value.length, cursorPosition + 1));
85+
return;
86+
}
87+
88+
if (key.upArrow || (key.ctrl && input === "p")) {
89+
if (currentLineIndex > 0) {
90+
const prevLineLength = lines[currentLineIndex - 1]?.length || 0;
91+
const newColumnIndex = Math.min(currentColumnIndex, prevLineLength);
92+
let newPos = 0;
93+
for (let i = 0; i < currentLineIndex - 1; i++) {
94+
newPos += (lines[i]?.length || 0) + 1;
95+
}
96+
newPos += newColumnIndex;
97+
setCursorPosition(newPos);
98+
}
99+
return;
100+
}
101+
102+
if (key.downArrow || (key.ctrl && input === "n")) {
103+
if (currentLineIndex < lines.length - 1) {
104+
const nextLineLength = lines[currentLineIndex + 1]?.length || 0;
105+
const newColumnIndex = Math.min(currentColumnIndex, nextLineLength);
106+
let newPos = 0;
107+
for (let i = 0; i <= currentLineIndex; i++) {
108+
newPos += (lines[i]?.length || 0) + 1;
109+
}
110+
newPos += newColumnIndex;
111+
setCursorPosition(newPos);
112+
}
113+
return;
114+
}
115+
116+
// Home/End
117+
if (key.ctrl && input === "a") {
118+
let lineStartPos = 0;
119+
for (let i = 0; i < currentLineIndex; i++) {
120+
lineStartPos += (lines[i]?.length || 0) + 1;
121+
}
122+
setCursorPosition(lineStartPos);
123+
return;
124+
}
125+
126+
if (key.ctrl && input === "e") {
127+
let lineEndPos = 0;
128+
for (let i = 0; i <= currentLineIndex; i++) {
129+
lineEndPos += lines[i]?.length || 0;
130+
if (i < currentLineIndex) lineEndPos += 1;
131+
}
132+
setCursorPosition(lineEndPos);
133+
return;
134+
}
135+
136+
// Kill line from cursor to beginning
137+
if (key.ctrl && input === "u") {
138+
let lineStartPos = 0;
139+
for (let i = 0; i < currentLineIndex; i++) {
140+
lineStartPos += (lines[i]?.length || 0) + 1;
141+
}
142+
const newValue =
143+
value.slice(0, lineStartPos) + value.slice(cursorPosition);
144+
updateValue(newValue, lineStartPos);
145+
return;
146+
}
147+
148+
// Kill word backward
149+
if (key.ctrl && input === "w") {
150+
const beforeCursor = value.slice(0, cursorPosition);
151+
// Find the start of the word to delete
152+
let wordStart = cursorPosition;
153+
154+
// Skip trailing whitespace
155+
while (
156+
wordStart > 0 &&
157+
/\s/.test(beforeCursor[wordStart - 1] || "")
158+
) {
159+
wordStart--;
160+
}
161+
162+
// Find the beginning of the word
163+
while (
164+
wordStart > 0 &&
165+
!/\s/.test(beforeCursor[wordStart - 1] || "")
166+
) {
167+
wordStart--;
168+
}
169+
170+
const newValue =
171+
value.slice(0, wordStart) + value.slice(cursorPosition);
172+
updateValue(newValue, wordStart);
173+
return;
174+
}
175+
176+
// Kill line from cursor to end
177+
if (key.ctrl && input === "k") {
178+
let lineEndPos = 0;
179+
for (let i = 0; i <= currentLineIndex; i++) {
180+
lineEndPos += lines[i]?.length || 0;
181+
if (i < currentLineIndex) lineEndPos += 1;
182+
}
183+
const newValue =
184+
value.slice(0, cursorPosition) + value.slice(lineEndPos);
185+
updateValue(newValue, cursorPosition);
186+
return;
187+
}
188+
189+
// Clear input (like screen clear in terminal)
190+
if (key.ctrl && input === "l") {
191+
updateValue("", 0);
192+
return;
193+
}
194+
195+
// Regular character input
196+
if (input && !key.ctrl && !key.meta) {
197+
const newValue =
198+
value.slice(0, cursorPosition) +
199+
input +
200+
value.slice(cursorPosition);
201+
updateValue(newValue, cursorPosition + input.length);
202+
}
203+
},
204+
[
205+
value,
206+
cursorPosition,
207+
currentLineIndex,
208+
currentColumnIndex,
209+
lines,
210+
showCursor,
211+
onSubmit,
212+
updateValue,
213+
],
214+
),
215+
);
216+
217+
const renderText = useMemo(() => {
218+
if (!showCursor) {
219+
return value || <Text color="gray">Talk to the cat...</Text>;
220+
}
221+
222+
if (value.length === 0) {
223+
return (
224+
<Text>
225+
<Text inverse> </Text>
226+
</Text>
227+
);
228+
}
229+
230+
const beforeCursor = value.slice(0, cursorPosition);
231+
const cursorChar = value.slice(cursorPosition, cursorPosition + 1);
232+
const afterCursor = value.slice(cursorPosition + 1);
233+
234+
// Handle cursor display for newline characters and end of text
235+
let displayCursorChar = cursorChar;
236+
if (cursorChar === "\n") {
237+
displayCursorChar = " ";
238+
} else if (cursorChar === "") {
239+
displayCursorChar = " ";
240+
}
241+
242+
return (
243+
<Text>
244+
{beforeCursor}
245+
<Text inverse>{displayCursorChar}</Text>
246+
{cursorChar === "\n" ? "\n" : ""}
247+
{afterCursor}
248+
</Text>
249+
);
250+
}, [value, cursorPosition, showCursor]);
251+
18252
return (
19253
<Box borderStyle="single" borderColor="gray" paddingX={1}>
20254
<Text color="yellow">&gt; </Text>
21-
<TextInput
22-
value={value}
23-
onChange={onChange}
24-
onSubmit={onSubmit}
25-
showCursor={showCursor}
26-
placeholder="Talk to the cat..."
27-
/>
255+
{renderText}
28256
</Box>
29257
);
30258
};

0 commit comments

Comments
 (0)