Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions js/terminal-ext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions tests/terminal-ext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } });
Expand Down
Loading