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
33 changes: 33 additions & 0 deletions src/tui-opentui/mention-popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly string[]>((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, "@")
Expand Down
31 changes: 31 additions & 0 deletions src/tui-opentui/prompt-features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,9 @@ export async function attachClipboardImage(shell: AppShell): Promise<boolean> {
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
Expand Down Expand Up @@ -3931,11 +3934,15 @@ export async function openAtMentionSuggestions(shell: AppShell): Promise<boolean
await source(token.dir),
token.fragment,
)
// Quitting mid-lookup tears down the renderer/TextBuffer this function
// writes into below; a resolved-but-stale lookup must not touch them.
if (shell.disposed) return false
// The source caps how many entries it returns per directory, so a large
// directory can cap out before the interior match appears. Asking it to do
// its own prefix filter puts that cap after the narrowing instead of before.
if (suggestions.length === 0 && token.fragment.length > 0) {
suggestions = await source(at.prefix)
if (shell.disposed) return false
}
if (mentionGenerations.get(shell) !== generation) return false

Expand Down
Loading