Skip to content

Commit afe0817

Browse files
authored
fix: keep terminal input sources explicit (#383)
1 parent 14c2c48 commit afe0817

4 files changed

Lines changed: 59 additions & 17 deletions

File tree

frontend/src/renderer/components/XtermTerminal.test.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
1010
options: Record<string, unknown>;
1111
modes: { bracketedPasteMode: boolean };
1212
dataListeners: Set<(data: string) => void>;
13+
keyListeners: Set<(event: { key: string }) => void>;
1314
selectionListeners: Set<() => void>;
1415
_core: {
1516
element: { classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> } };
@@ -31,6 +32,7 @@ vi.mock("@xterm/xterm", () => ({
3132
wheelHandler?: (event: WheelEvent) => boolean;
3233
modes = { bracketedPasteMode: false };
3334
dataListeners = new Set<(data: string) => void>();
35+
keyListeners = new Set<(event: { key: string }) => void>();
3436
selectionListeners = new Set<() => void>();
3537
_core = {
3638
element: { classList: { add: vi.fn(), remove: vi.fn() } },
@@ -62,8 +64,9 @@ vi.mock("@xterm/xterm", () => ({
6264
onRender() {
6365
return { dispose: () => undefined };
6466
}
65-
onKey() {
66-
return { dispose: () => undefined };
67+
onKey(listener: (event: { key: string }) => void) {
68+
this.keyListeners.add(listener);
69+
return { dispose: () => this.keyListeners.delete(listener) };
6770
}
6871
onSelectionChange(listener: () => void) {
6972
this.selectionListeners.add(listener);
@@ -359,16 +362,25 @@ describe("XtermTerminal", () => {
359362
expect(allowed).toBe(false);
360363
expect(event.preventDefault).toHaveBeenCalled();
361364
expect(event.stopPropagation).toHaveBeenCalled();
362-
expect(onInput).toHaveBeenCalledWith(expected, "terminal");
365+
expect(onInput).toHaveBeenCalledWith(expected, "shortcut");
363366
});
364367

365-
it("forwards generated xterm input data such as wheel scroll reports", () => {
368+
it("forwards keyboard input from explicit key events", () => {
366369
const onInput = vi.fn();
367370
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
368371

369-
state.lastTerminal!.dataListeners.forEach((listener) => listener("\x1b[A"));
372+
state.lastTerminal!.keyListeners.forEach((listener) => listener({ key: "a" }));
370373

371-
expect(onInput).toHaveBeenCalledWith("\x1b[A", "terminal");
374+
expect(onInput).toHaveBeenCalledWith("a", "keyboard");
375+
});
376+
377+
it("does not forward raw xterm data/control bytes as user input", () => {
378+
const onInput = vi.fn();
379+
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
380+
381+
expect(state.lastTerminal!.dataListeners.size).toBe(0);
382+
state.lastTerminal!.dataListeners.forEach((listener) => listener("\x1b[A"));
383+
expect(onInput).not.toHaveBeenCalled();
372384
});
373385

374386
it("translates wheel motion into SGR wheel reports for zellij scrollback", () => {
@@ -378,7 +390,7 @@ describe("XtermTerminal", () => {
378390
const suppressed = state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent);
379391

380392
expect(suppressed).toBe(false);
381-
expect(onInput).toHaveBeenCalledWith("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M", "terminal");
393+
expect(onInput).toHaveBeenCalledWith("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M", "wheel");
382394
});
383395

384396
it("handles line- and page-mode wheels (Linux/Windows mice), not just pixel deltas", () => {
@@ -387,27 +399,27 @@ describe("XtermTerminal", () => {
387399

388400
// DOM_DELTA_LINE: deltaY is already in lines, so one notch up => one report.
389401
expect(state.lastTerminal!.wheelHandler!({ deltaY: -1, deltaMode: 1 } as WheelEvent)).toBe(false);
390-
expect(onInput).toHaveBeenLastCalledWith("\x1b[<64;1;1M", "terminal");
402+
expect(onInput).toHaveBeenLastCalledWith("\x1b[<64;1;1M", "wheel");
391403

392404
// DOM_DELTA_PAGE: one page down => rows (24) line reports down.
393405
onInput.mockClear();
394406
expect(state.lastTerminal!.wheelHandler!({ deltaY: 1, deltaMode: 2 } as WheelEvent)).toBe(false);
395-
expect(onInput).toHaveBeenLastCalledWith("\x1b[<65;1;1M".repeat(24), "terminal");
407+
expect(onInput).toHaveBeenLastCalledWith("\x1b[<65;1;1M".repeat(24), "wheel");
396408
});
397409

398410
it("scrolls down on positive wheel delta and leaves zoom (ctrl/meta) wheel alone", () => {
399411
const onInput = vi.fn();
400412
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
401413

402414
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
403-
expect(onInput).toHaveBeenCalledWith("\x1b[<65;1;1M", "terminal");
415+
expect(onInput).toHaveBeenCalledWith("\x1b[<65;1;1M", "wheel");
404416

405417
onInput.mockClear();
406418
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50, ctrlKey: true } as WheelEvent)).toBe(false);
407419
expect(onInput).not.toHaveBeenCalled();
408420
});
409421

410-
it("forces plain drag selection while preserving xterm data forwarding", () => {
422+
it("forces plain drag selection without raw xterm data forwarding", () => {
411423
render(<XtermTerminal theme="dark" />);
412424

413425
expect(state.lastTerminal!.options.macOptionClickForcesSelection).toBe(true);

frontend/src/renderer/components/XtermTerminal.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
326326
const normalized = normalizedTerminalShortcut(event);
327327
if (!normalized) return true;
328328
consumeTerminalShortcut(event);
329-
emitUserInput(normalized, "terminal");
329+
emitUserInput(normalized, "shortcut");
330330
return false;
331331
});
332332
const copyInput = (event: ClipboardEvent) => {
@@ -430,7 +430,12 @@ export function XtermTerminal(props: XtermTerminalProps) {
430430
// misses them. Listen on window directly as a session-long recovery path.
431431
window.addEventListener("resize", fitTerminal);
432432

433-
const terminalInput = term.onData((data) => emitUserInput(data, "terminal"));
433+
// Do not replace this with term.onData. xterm's raw data stream can include
434+
// terminal-generated control responses during attach/repaint; forwarding
435+
// those bytes through the mux writes dirty input into the real Codex PTY and
436+
// corrupts the TUI. Keyboard is the only safe generic text path here; paste,
437+
// composition, shortcuts, and wheel reports are emitted explicitly below.
438+
const keyInput = term.onKey(({ key }) => emitUserInput(key, "keyboard"));
434439

435440
// Translate wheel motion into SGR wheel reports for zellij (see
436441
// sgrWheelReport), one report per scrolled line. WheelEvent.deltaMode
@@ -457,7 +462,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
457462
}
458463
if (lines === 0) return false;
459464
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
460-
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "terminal");
465+
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "wheel");
461466
return false;
462467
});
463468
const pasteInput = (event: ClipboardEvent) => {
@@ -505,7 +510,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
505510
selectionChange.dispose();
506511
host.removeEventListener("paste", pasteInput, true);
507512
host.removeEventListener("compositionend", compositionInput, true);
508-
terminalInput.dispose();
513+
keyInput.dispose();
509514
userInputListeners.clear();
510515
try {
511516
term.dispose();

frontend/src/renderer/hooks/useTerminalSession.test.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ type FakeTerminal = AttachableTerminal & {
9191
clears: number;
9292
typeKeys(data: string): void;
9393
paste(data: string): void;
94+
compose(data: string): void;
95+
shortcut(data: string): void;
96+
wheel(data: string): void;
9497
emitResize(cols: number, rows: number): void;
9598
};
9699

@@ -115,8 +118,11 @@ function createFakeTerminal(): FakeTerminal {
115118
resizeListeners.add(listener);
116119
return { dispose: () => resizeListeners.delete(listener) };
117120
},
118-
typeKeys: (data) => inputListeners.forEach((listener) => listener(data, "terminal")),
121+
typeKeys: (data) => inputListeners.forEach((listener) => listener(data, "keyboard")),
119122
paste: (data) => inputListeners.forEach((listener) => listener(data, "paste")),
123+
compose: (data) => inputListeners.forEach((listener) => listener(data, "composition")),
124+
shortcut: (data) => inputListeners.forEach((listener) => listener(data, "shortcut")),
125+
wheel: (data) => inputListeners.forEach((listener) => listener(data, "wheel")),
120126
emitResize: (cols, rows) => resizeListeners.forEach((listener) => listener({ cols, rows })),
121127
};
122128
return terminal;
@@ -188,6 +194,25 @@ describe("useTerminalSession", () => {
188194
expect(muxes[0].resizes).toContainEqual(["handle-1", 120, 40]);
189195
});
190196

197+
it("forwards every explicit input source after the attachment opens", () => {
198+
const { terminal, muxes } = setup();
199+
act(() => muxes[0].emitOpened("handle-1"));
200+
201+
terminal.typeKeys("a");
202+
terminal.paste("paste\r");
203+
terminal.compose("é");
204+
terminal.shortcut("\x1b[1;5D");
205+
terminal.wheel("\x1b[<64;1;1M");
206+
207+
expect(muxes[0].inputs).toEqual([
208+
["handle-1", "a"],
209+
["handle-1", "paste\r"],
210+
["handle-1", "é"],
211+
["handle-1", "\x1b[1;5D"],
212+
["handle-1", "\x1b[<64;1;1M"],
213+
]);
214+
});
215+
191216
it("collapses a drag's burst of grid changes into one trailing PTY resize, then re-asserts it", () => {
192217
const { terminal, muxes } = setup();
193218
const initialResizes = muxes[0].resizes.length; // connect() sends the opening size

frontend/src/renderer/hooks/useTerminalSession.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { workspaceQueryKey } from "./useWorkspaceQuery";
1818
* The slice of xterm's Terminal the attachment needs. Structural, so tests can
1919
* drive the hook with a tiny fake instead of a real xterm + DOM.
2020
*/
21-
export type TerminalUserInputSource = "terminal" | "paste" | "composition";
21+
export type TerminalUserInputSource = "keyboard" | "paste" | "composition" | "shortcut" | "wheel";
2222

2323
export type AttachableTerminal = {
2424
cols: number;

0 commit comments

Comments
 (0)