diff --git a/src/tui-opentui/mention-popup.test.ts b/src/tui-opentui/mention-popup.test.ts index 3769c0199..b903e9dcc 100644 --- a/src/tui-opentui/mention-popup.test.ts +++ b/src/tui-opentui/mention-popup.test.ts @@ -123,6 +123,39 @@ describe("@ popup narrows as you type", () => { }) }) + test("quitting mid-lookup does not write into the disposed shell", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + let resolveLookup: (entries: readonly string[]) => void = () => {} + setMentionSuggestionSource( + shell, + () => + new Promise((resolve) => { + resolveLookup = resolve + }), + ) + + shell.prompt.value = "read @" + shell.prompt.cursorOffset = shell.prompt.value.length + const pending = openAtMentionSuggestions(shell) + + // The operator quits before the filesystem lookup answers. + shell.dispose() + resolveLookup(["AGENTS.md", "README.md"]) + + await expect(pending).resolves.toBe(false) + expect(shell.overlayKind).toBeNull() + expect(isMentionPopupOpen(shell)).toBe(false) + }, + { width: 80, height: 24 }, + ) + }) + test("no match closes the popup and leaves the typed text", async () => { await withShell(async (shell) => { await openAt(shell, "@") diff --git a/src/tui-opentui/prompt-features.test.ts b/src/tui-opentui/prompt-features.test.ts index 79adc8953..71908da56 100644 --- a/src/tui-opentui/prompt-features.test.ts +++ b/src/tui-opentui/prompt-features.test.ts @@ -71,6 +71,37 @@ describe("image attachments", () => { }) }) + test("quitting mid-read does not attach into the disposed shell", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + }) + let resolveRead: (r: { ok: true; attachment: PendingImageAttachment }) => void = + () => {} + setPromptImageSource( + shell, + () => + new Promise((resolve) => { + resolveRead = resolve + }), + ) + + const pending = attachClipboardImage(shell) + + // The operator quits before the clipboard read answers. + shell.dispose() + resolveRead({ ok: true, attachment: CLIP }) + + expect(await pending).toBe(false) + expect(shell.pendingAttachments).toEqual([]) + }, + { width: 80, height: 24 }, + ) + }) + // Raw control bytes, not a synthetic KeyEvent: a binding that never matches // what the terminal actually writes looks correct in the catalog and fails // silently in use. diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e45110ad5..8be092865 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -863,6 +863,9 @@ export async function attachClipboardImage(shell: AppShell): Promise { const source = shellPromptImageSource.get(shell) ?? readClipboardImage setStatusFlash(shell, "reading clipboard image…") const result = await source() + // Quitting while the clipboard read is pending tears down the shell's + // renderables; a stale continuation must not mutate them on resume. + if (shell.disposed) return false if (!result.ok) { setStatusFlash(shell, `image attach failed: ${result.reason}`) return false @@ -3931,11 +3934,15 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise 0) { suggestions = await source(at.prefix) + if (shell.disposed) return false } if (mentionGenerations.get(shell) !== generation) return false