From 2955549d9dfd56a6a20e51a3a8591672ac6450fe Mon Sep 17 00:00:00 2001 From: Kane Hsieh Date: Wed, 4 Nov 2026 10:35:12 -0800 Subject: [PATCH] Fix doubled keystrokes in the apply form after a resize Opening the form via a deep link (/#apply-1) and then changing the terminal width reran `apply`: runDeepLink's replay only skipped `tui`, not the rest of NO_REPLAY. The second form's input listener stacked on the first, so every character was echoed twice. Also redraw the pending form question and typed text after a resize, instead of a shell prompt that hid the still-active form. Co-Authored-By: Claude Opus 5.5 --- js/terminal-ext.js | 20 +++++++++++-- tests/terminal-ext.test.js | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/js/terminal-ext.js b/js/terminal-ext.js index c365d63a..4dc987e0 100644 --- a/js/terminal-ext.js +++ b/js/terminal-ext.js @@ -376,7 +376,13 @@ const extend = (term) => { term.command(c); } } - term.prompt(); + // A form (collectInput) still owns the keyboard, so put its question and + // what has been typed back on screen rather than a misleading shell prompt. + if (term.activeInput) { + term.write(`\r\n${term.activeInput.label}${term.activeInput.buffer}`); + } else { + term.prompt(); + } term.scrollToBottom(); term._initialized = true; }; @@ -433,7 +439,10 @@ const extend = (term) => { // not be counted again — the history replay right below it calls term.command // directly rather than executeCommandLine for exactly this reason. term.runDeepLink = ({ replay = false } = {}) => { - if (replay && term.parseCommandLine(term.deepLink).cmd === "tui") return; + // Same rule as the history replay in resizeListener: interactive commands + // are never rerun. Rerunning `apply` stacked a second form on the live one, + // so every keystroke was echoed twice. + if (replay && term.NO_REPLAY.includes(term.parseCommandLine(term.deepLink).cmd)) return; if (term.deepLink != "") { term.executeCommandLine(term.deepLink, { addToHistory: false, @@ -458,19 +467,24 @@ const extend = (term) => { term.collectInput = (prompt, isOptional = false) => { return new Promise((resolve) => { term.locked = true; - term.write(`\r\n${prompt}${isOptional ? ' (optional)' : ''}: `); + const label = `${prompt}${isOptional ? ' (optional)' : ''}: `; + term.write(`\r\n${label}`); let inputBuffer = ''; + // Read by resizeListener to redraw this question after a resize. + term.activeInput = { label, get buffer() { return inputBuffer; } }; const inputHandler = term.onData((e) => { switch (e) { case '\r': // Enter — submit term.write('\r\n'); + term.activeInput = null; inputHandler.dispose(); term.locked = false; resolve(inputBuffer.trim()); break; case '\u0003': // Ctrl+C — cancel, resolves to null term.write('^C\r\n'); + term.activeInput = null; inputHandler.dispose(); term.locked = false; resolve(null); diff --git a/tests/terminal-ext.test.js b/tests/terminal-ext.test.js index 3101cb33..e609f5ad 100644 --- a/tests/terminal-ext.test.js +++ b/tests/terminal-ext.test.js @@ -124,6 +124,65 @@ describe("terminal-ext", () => { expect(echoed).toEqual(expect.arrayContaining([" upgrade\r\n", " apply 1\r\n"])); }); + it("does not rerun an interactive deep link when a resize replays it", () => { + // Job posts link to /#apply-1. Deep links are not in history, so the + // NO_REPLAY check on history alone let a resize start a second form whose + // input listener stacked on the first: every keystroke echoed twice. + const commands = Object.fromEntries( + ["upgrade", "apply", "test", "claude"].map((name) => [name, vi.fn()]) + ); + const { extend } = loadTerminalExt({ commands }); + const term = createTerm({ command: vi.fn() }); + env.window.__term = term; + extend(term); + term.init = vi.fn(); + term.prompt = vi.fn(); + term.executeCommandLine = vi.fn(() => Promise.resolve()); + + for (const line of ["apply 1", "upgrade", "test", "claude"]) { + term.deepLink = line; + term.runDeepLink({ replay: true }); + } + + expect(term.executeCommandLine).not.toHaveBeenCalled(); + }); + + it("redraws the pending form question, not a shell prompt, after a resize", () => { + // A resize wipes the screen while apply's collectInput still owns the + // keyboard. Redrawing a shell prompt there made it look like the form was + // gone while keystrokes kept landing in the name field. + const { extend } = loadTerminalExt(); + let onData; + const term = createTerm({ + onData: vi.fn((handler) => { + onData = handler; + return { dispose: vi.fn() }; + }), + }); + env.window.__term = term; + extend(term); + term.init = vi.fn(); + term.prompt = vi.fn(); + + const answer = term.collectInput("What's your name?"); + for (const ch of "Jane") onData(ch); + fitTo(40); + term.write.mockClear(); + term.resizeListener(); + + expect(term.prompt).not.toHaveBeenCalledWith(); + expect(term.write).toHaveBeenLastCalledWith("\r\nWhat's your name?: Jane"); + + // Once answered, resizes go back to drawing the normal prompt. + onData("\r"); + return answer.then((value) => { + expect(value).toBe("Jane"); + fitTo(60); + term.resizeListener(); + expect(term.prompt).toHaveBeenLastCalledWith(); + }); + }); + it("reads the cursor through xterm's public buffer API", () => { const { extend } = loadTerminalExt(); const term = createTerm({ buffer: { active: { cursorX: 25 } } });