From 66bddedec2ee321f7ab08b7d95fc7362f091ab03 Mon Sep 17 00:00:00 2001 From: nimbly1 <258366730+nimbly1@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:11:39 -0500 Subject: [PATCH] fix(cli): do not repaint the prompt after a keypress quits the session onKey() ended with an unconditional refresh(). When the key was a quit (Ctrl-D at an empty prompt, or the second Ctrl-C), onControl() had already run quit() -> finish() -> Screen.finish(), which clears the live composer and footer and restores the cursor. The refresh then painted the input box and the shortcut hint again, leaving them on screen after the process exited with code 0. Skip the refresh once the session is quitting, as the other refresh call sites in chat.ts already do. Add tests for Ctrl-D and for the second Ctrl-C that check nothing drawing the prompt is written after the quit begins. Ctrl-D in a non-empty draft (delete forward) and Ctrl-C while a task runs are unchanged. Fixes #32 Co-Authored-By: Claude Sonnet 5.5 --- packages/cli/src/chat.ts | 3 ++- packages/cli/test/chat.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/chat.ts b/packages/cli/src/chat.ts index acb08f3..809f315 100644 --- a/packages/cli/src/chat.ts +++ b/packages/cli/src/chat.ts @@ -478,7 +478,8 @@ export class Chat { break; } if (key.type !== "ctrl" || key.key !== "c") this.ctrlCArmedAt = 0; - this.refresh(); + // A quit from the keypress (Ctrl-D, a second Ctrl-C) has already cleaned the terminal up + if (!this.quitting) this.refresh(); } private onControl(letter: string): void { diff --git a/packages/cli/test/chat.test.ts b/packages/cli/test/chat.test.ts index 42c5414..8ba181b 100644 --- a/packages/cli/test/chat.test.ts +++ b/packages/cli/test/chat.test.ts @@ -833,6 +833,37 @@ describe("interactive session", () => { expect(t.seen).toHaveLength(1); }); + /** Whatever is written once a quit began is cleanup; it must not draw the box or the hint again. */ + const expectNoRepaintSince = (s: ReturnType, before: number): void => { + const after = s.screen().slice(before); + expect(after).not.toContain("type a task"); + expect(after).not.toContain("╭"); + expect(after).not.toContain("auto-approve"); + }; + + it("does not repaint the prompt after Ctrl-D quits at an empty prompt", async () => { + const s = session(new GateTransport([])); + open.push(s); + // The live prompt is on screen before the keypress + expect(s.screen()).toContain("type a task"); + const before = s.screen().length; + s.type("\x04"); + expect(await s.finished).toBe(0); + expectNoRepaintSince(s, before); + }); + + it("does not repaint the prompt after a second Ctrl-C quits at an empty prompt", async () => { + const s = session(new GateTransport([])); + open.push(s); + // The first Ctrl-C only arms the quit and repaints the prompt with a hint + s.type("\x03"); + await vi.waitFor(() => expect(s.screen()).toContain("ctrl-c again to quit")); + const before = s.screen().length; + s.type("\x03"); + expect(await s.finished).toBe(0); + expectNoRepaintSince(s, before); + }); + it("asks before a command runs, and a yes runs it", async () => { const t = new GateTransport([toolCallBody("bash", { command: "echo allowed-output" }), ...reply("done")]); const s = session(t, { settings: { model: "m", endpoint: "https://x", channel: "toolcall", maxTurns: 20, cwd: mkdtempSync(join(tmpdir(), "motif-chat-")), theme: "motif", compactAt: 0.75, permissions: "ask" } });