From a04b315eb24e080468a85f732b8ceda81bc7f8aa Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 25 Jul 2026 00:40:23 +0000 Subject: [PATCH] fix(tui): expand pinned provider errors inline on ctrl+o While a provider error is pinned in the banner above the editor the inline transcript block is suppressed, so the prior guard skipped re-rendering on Ctrl+O and the full body stayed unreachable until the next turn. - Track whether the message carries a truncatable error regardless of pinning, so setExpanded re-renders while pinned. - Render the inline error block in full when expanded even while pinned; keep it suppressed only while pinned and collapsed. - Disable the streaming fast path whenever the inline error block is drawn. Fixes #6555 --- .../src/modes/components/assistant-message.ts | 44 ++++++++++++------- .../test/provider-error-expand.test.ts | 22 ++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index 942fbe074..38d69704b 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -214,12 +214,14 @@ export class AssistantMessageComponent extends Container { */ #errorExpanded = false; /** - * True when the last {@link updateContent} rendered a length-capped error - * block (the `#appendErrorBlock` path). Gates {@link setExpanded} so toggling - * expansion only re-renders assistant turns that actually carry a truncatable - * error, not every message in the transcript. + * True when the current {@link updateContent} message carries a truncatable + * inline provider error (the `#appendErrorBlock` path) — set whether or not + * the inline block was actually drawn, so it stays true even while the error + * is suppressed under a pinned banner. Gates {@link setExpanded} so toggling + * expansion only re-renders assistant turns that carry such an error, not + * every message in the transcript. */ - #renderedTruncatableError = false; + #hasTruncatableError = false; /** * Monotonic content version reported to the transcript container via * {@link getTranscriptBlockVersion}. Bumped by {@link updateContent} — the @@ -435,13 +437,15 @@ export class AssistantMessageComponent extends Container { /** * Expand or collapse the inline turn-ending error block so Ctrl+O * (tool-output expansion) can reveal a long provider error's hidden tail. - * Only re-renders when the last render actually produced a truncatable error - * block, so toggling expansion across the transcript skips ordinary turns. + * Only re-renders when the current message carries a truncatable error, so + * toggling expansion across the transcript skips ordinary turns. Works even + * while the error is pinned in the banner: the inline block is drawn (in full) + * when expanded so the complete body is reachable without sending a message. */ setExpanded(expanded: boolean): void { if (this.#errorExpanded === expanded) return; this.#errorExpanded = expanded; - if (this.#renderedTruncatableError && this.#lastMessage) { + if (this.#hasTruncatableError && this.#lastMessage) { this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient }); } } @@ -536,7 +540,6 @@ export class AssistantMessageComponent extends Container { * the complete message is reachable. Mirrors {@link ErrorBannerComponent}. */ #appendErrorBlock(message: string): void { - this.#renderedTruncatableError = true; if (this.#errorExpanded) { const [first = "Unknown error", ...rest] = replaceTabs(message.replace(/\s+$/, "")).split("\n"); this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${first}`), 1, 0)); @@ -705,7 +708,10 @@ export class AssistantMessageComponent extends Container { if (this.#toolImagesByCallId.size > 0) return false; const errorPresentation = resolveAssistantErrorPresentation(message); if (errorPresentation.kind === "compact-recovered") return false; - if (errorPresentation.kind === "full" && !(message.stopReason === "error" && this.#errorPinned)) { + if ( + errorPresentation.kind === "full" && + !(message.stopReason === "error" && this.#errorPinned && !this.#errorExpanded) + ) { return false; } // Extension stability: if thinking renderers exist and any tracked thinking @@ -839,7 +845,7 @@ export class AssistantMessageComponent extends Container { // Clear content container this.#contentContainer.clear(); this.#thinkingDots = undefined; - this.#renderedTruncatableError = false; + this.#hasTruncatableError = false; // Determine if we should capture Markdown instances for next fast path const shouldCapture = this.#canFastPath(message); @@ -922,11 +928,19 @@ export class AssistantMessageComponent extends Container { this.#contentContainer.addChild(new Spacer(1)); this.#contentContainer.addChild(new Text(theme.fg("dim", errorPresentation.text), 1, 0)); } else if (!hasToolCalls && errorPresentation.kind === "full") { - if (!(message.stopReason === "error" && this.#errorPinned)) { + if (message.stopReason === "aborted") { this.#contentContainer.addChild(new Spacer(1)); - if (message.stopReason === "aborted") { - this.#contentContainer.addChild(new Text(theme.fg("error", errorPresentation.text), 1, 0)); - } else { + this.#contentContainer.addChild(new Text(theme.fg("error", errorPresentation.text), 1, 0)); + } else { + // Non-aborted provider error: a truncatable inline block. Mark it so + // setExpanded re-renders even while the same error is pinned above. + this.#hasTruncatableError = true; + // Suppress the inline block only while pinned AND collapsed — the + // banner already shows the capped error there. When expanded, draw + // the inline block in full so the complete body is reachable without + // sending a message; the pinned banner stays a short reminder. + if (!(message.stopReason === "error" && this.#errorPinned) || this.#errorExpanded) { + this.#contentContainer.addChild(new Spacer(1)); this.#appendErrorBlock(errorPresentation.text); } } diff --git a/packages/coding-agent/test/provider-error-expand.test.ts b/packages/coding-agent/test/provider-error-expand.test.ts index 2cd7e36c9..fced8ee92 100644 --- a/packages/coding-agent/test/provider-error-expand.test.ts +++ b/packages/coding-agent/test/provider-error-expand.test.ts @@ -54,4 +54,26 @@ describe("provider error expand", () => { const recollapsed = Bun.stripANSI(component.render(120).join("\n")); expect(recollapsed).not.toContain("provider error detail line 29"); }); + + it("reveals the full body inline when expanded while the error is pinned", () => { + const component = new AssistantMessageComponent(makeErr(longError(30))); + + // The banner above the editor mirrors the error, so the inline block is + // suppressed while pinned (EventController does this at message_end). + component.setErrorPinned(true); + const pinned = Bun.stripANSI(component.render(120).join("\n")); + expect(pinned).not.toContain("provider error detail line 0"); + + // Ctrl+O while pinned must still reach the full body inline. + component.setExpanded(true); + const expanded = Bun.stripANSI(component.render(120).join("\n")); + const shown = expanded.match(/provider error detail line \d+/g) ?? []; + expect(shown.length).toBe(30); + expect(expanded).toContain("provider error detail line 29"); + + // Collapsing again re-suppresses the pinned inline error. + component.setExpanded(false); + const recollapsed = Bun.stripANSI(component.render(120).join("\n")); + expect(recollapsed).not.toContain("provider error detail line 0"); + }); });