Skip to content

Commit 3c15003

Browse files
committed
refactor: extracted terminal data dispatch into dispatchTerminalInput()
1 parent fb68eea commit 3c15003

5 files changed

Lines changed: 86 additions & 28 deletions

File tree

src/tests/promptInputKeys.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,23 @@ import {
2020
renderBufferWithCursor,
2121
buildInitPromptSubmission,
2222
buildPromptDraftFromSessionMessage,
23+
dispatchTerminalInput,
2324
disableTerminalExtendedKeys,
2425
enableTerminalExtendedKeys,
26+
EMPTY_BUFFER,
27+
insertText,
28+
backspace,
2529
} from "../ui";
2630
import type { SessionMessage, SkillInfo } from "../session";
2731

32+
function collectDispatchedInput(data: string) {
33+
const events: ReturnType<typeof parseTerminalInput>[] = [];
34+
dispatchTerminalInput(data, (input, key) => {
35+
events.push({ input, key });
36+
});
37+
return events;
38+
}
39+
2840
test("parseTerminalInput treats DEL bytes as backspace", () => {
2941
const { input, key } = parseTerminalInput("\u007F");
3042
assert.equal(input, "");
@@ -72,6 +84,45 @@ test("parseTerminalInput keeps DEL payload for meta+backspace", () => {
7284
assert.equal(key.backspace, false);
7385
});
7486

87+
test("dispatchTerminalInput splits iOS CJK composition packets", () => {
88+
const events = collectDispatchedInput("가\u007F나");
89+
assert.equal(events.length, 3);
90+
assert.equal(events[0]?.input, "가");
91+
assert.equal(events[1]?.input, "");
92+
assert.equal(events[1]?.key.backspace, true);
93+
assert.equal(events[2]?.input, "나");
94+
});
95+
96+
test("dispatchTerminalInput applies multi-step CJK composition to the prompt buffer", () => {
97+
let state = EMPTY_BUFFER;
98+
dispatchTerminalInput("ㄱ\u007F가\u007F각", (input, key) => {
99+
if (key.backspace) {
100+
state = backspace(state);
101+
return;
102+
}
103+
state = insertText(state, input);
104+
});
105+
106+
assert.equal(state.text, "각");
107+
assert.equal(state.cursor, 1);
108+
});
109+
110+
test("dispatchTerminalInput preserves meta+backspace as one event", () => {
111+
const events = collectDispatchedInput("\u001B\u007F");
112+
assert.equal(events.length, 1);
113+
assert.equal(events[0]?.input, "\u007F");
114+
assert.equal(events[0]?.key.meta, true);
115+
assert.equal(events[0]?.key.backspace, false);
116+
assert.equal(events[0]?.key.escape, false);
117+
});
118+
119+
test("dispatchTerminalInput emits consecutive backspaces from one packet", () => {
120+
const events = collectDispatchedInput("\u007F\u007F");
121+
assert.equal(events.length, 2);
122+
assert.equal(events[0]?.key.backspace, true);
123+
assert.equal(events[1]?.key.backspace, true);
124+
});
125+
75126
test("parseTerminalInput keeps BS payload for meta+backspace", () => {
76127
const { input, key } = parseTerminalInput("\u001B\b");
77128
assert.equal(input, "\b");

src/ui/PromptInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { readClipboardImageAsync } from "./clipboard";
4242
import type { SessionEntry, SkillInfo } from "../session";
4343

4444
// Re-exported from prompt modules for backward compatibility
45-
export { useTerminalInput, parseTerminalInput } from "./prompt";
45+
export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt";
4646
export type { InputKey } from "./prompt";
4747

4848
import { useTerminalInput } from "./prompt";

src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
MODEL_COMMAND_THINKING_OPTIONS,
3131
useTerminalInput,
3232
parseTerminalInput,
33+
dispatchTerminalInput,
3334
type PromptSubmission,
3435
type PromptDraft,
3536
type InputKey,

src/ui/prompt/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { useTerminalInput, parseTerminalInput } from "./useTerminalInput";
1+
export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput";
22
export type { InputKey } from "./useTerminalInput";
33

44
export {

src/ui/prompt/useTerminalInput.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,37 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key:
169169
return { input, key };
170170
}
171171

172+
export function dispatchTerminalInput(
173+
data: Buffer | string,
174+
inputHandler: (input: string, key: InputKey) => void
175+
): void {
176+
const raw = String(data);
177+
178+
// Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.).
179+
// iOS keyboards can send composed characters as a single packet like:
180+
// "가\x7f나" (character + backspace + replacement character)
181+
// Do not split escape-prefixed sequences such as Alt+Backspace.
182+
if (!raw.startsWith("\u001B") && raw.includes("\x7f") && raw.length > 1) {
183+
const parts = raw.split("\x7f");
184+
if (parts[0]) {
185+
const { input, key } = parseTerminalInput(parts[0]);
186+
inputHandler(input, key);
187+
}
188+
for (let i = 1; i < parts.length; i++) {
189+
const bs = parseTerminalInput("\x7f");
190+
inputHandler(bs.input, bs.key);
191+
if (parts[i]) {
192+
const { input, key } = parseTerminalInput(parts[i]);
193+
inputHandler(input, key);
194+
}
195+
}
196+
return;
197+
}
198+
199+
const { input, key } = parseTerminalInput(data);
200+
inputHandler(input, key);
201+
}
202+
172203
export function useTerminalInput(
173204
inputHandler: (input: string, key: InputKey) => void,
174205
options: { isActive?: boolean } = {}
@@ -193,32 +224,7 @@ export function useTerminalInput(
193224
return;
194225
}
195226
const handleData = (data: Buffer | string) => {
196-
const raw = String(data);
197-
198-
// Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.).
199-
// iOS keyboards send composed characters as a single packet like:
200-
// "가\x7f나" (character + backspace + new character)
201-
// Without splitting, parseTerminalInput treats the whole packet as
202-
// one input and drops the composition backspaces, corrupting the text.
203-
if (raw.includes("\x7f") && raw.length > 1) {
204-
const parts = raw.split("\x7f");
205-
if (parts[0]) {
206-
const { input, key } = parseTerminalInput(parts[0]);
207-
handlerRef.current(input, key);
208-
}
209-
for (let i = 1; i < parts.length; i++) {
210-
const bs = parseTerminalInput("\x7f");
211-
handlerRef.current(bs.input, bs.key);
212-
if (parts[i]) {
213-
const { input, key } = parseTerminalInput(parts[i]);
214-
handlerRef.current(input, key);
215-
}
216-
}
217-
return;
218-
}
219-
220-
const { input, key } = parseTerminalInput(data);
221-
handlerRef.current(input, key);
227+
dispatchTerminalInput(data, handlerRef.current);
222228
};
223229

224230
stdin?.on("data", handleData);

0 commit comments

Comments
 (0)