diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84d052e2f..67d8a6c3a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Added +- Added a persistent error banner pinned above the editor when an assistant turn ends on a provider error (e.g. Anthropic's "Output blocked by content filtering policy"). The transcript `Error: …` line scrolls away as the conversation grows, so terminal turns that ended on a stream error could pass unnoticed; the banner stays in the fixed region above the input and is cleared when the next turn starts. + - Added bold, underlined, clickable `[Image #N]` placeholders in the draft editor and sent user-message bubbles, backed by extension-bearing blob-store sidecar files so terminal `file://` links open in image viewers. - Added the active model identifier (`provider/id`) to the system prompt's `` block so the agent knows which model it is running as. Gated by the new `includeModelInPrompt` setting (default on); the base prompt is rebuilt on a mid-session model switch so the surfaced identifier stays current. - Added `OLLAMA_HOST` support for implicit local Ollama discovery when `OLLAMA_BASE_URL` is unset, so OMP picks up the same host setting used by Ollama. diff --git a/packages/coding-agent/src/modes/components/error-banner.ts b/packages/coding-agent/src/modes/components/error-banner.ts new file mode 100644 index 000000000..16e80554a --- /dev/null +++ b/packages/coding-agent/src/modes/components/error-banner.ts @@ -0,0 +1,33 @@ +import { Container, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { getPreviewLines, TRUNCATE_LENGTHS } from "../../tools/render-utils"; +import { theme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +/** Max lines of the error message shown in the pinned banner. */ +const MAX_BANNER_LINES = 3; + +/** + * A persistent error banner pinned above the editor. Unlike the transcript + * "Error: …" line (which scrolls away as the conversation grows), this stays in + * the fixed region directly above the input so a turn that ended on a provider + * error — e.g. Anthropic's "Output blocked by content filtering policy" — cannot + * be missed. It is cleared when the next turn starts. + */ +export class ErrorBannerComponent extends Container { + constructor(message: string) { + super(); + const lines = getPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE); + if (lines.length === 0) { + lines.push("Unknown error"); + } + + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder(str => theme.fg("error", str))); + this.addChild(new Text(theme.bold(theme.fg("error", `${theme.status.error} ${lines[0]}`)), 1, 0)); + for (const line of lines.slice(1)) { + this.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0)); + } + this.addChild(new Text(theme.fg("dim", "Dismissed when you send your next message."), 1, 0)); + this.addChild(new DynamicBorder(str => theme.fg("error", str))); + } +} diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 6f3b3a8d5..2bc4734a1 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -204,6 +204,7 @@ export class EventController { this.#readToolCallAssistantComponents.clear(); this.#assistantMessageStreaming = false; this.#lastAssistantComponent = undefined; + this.ctx.clearPinnedError(); if (this.ctx.retryEscapeHandler) { this.ctx.editor.onEscape = this.ctx.retryEscapeHandler; this.ctx.retryEscapeHandler = undefined; @@ -490,6 +491,16 @@ export class EventController { this.#lastAssistantComponent.markTranscriptBlockFinalized(); this.ctx.streamingComponent = undefined; this.ctx.streamingMessage = undefined; + // Pin a turn-ending provider error (e.g. Anthropic content-filter block) + // above the editor so it survives transcript scroll. Cleared at the next + // turn's agent_start. + if ( + event.message.stopReason === "error" && + event.message.errorMessage && + !isSilentAbort(event.message.errorMessage) + ) { + this.ctx.showPinnedError(event.message.errorMessage); + } this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2e20e036a..c1659f1ac 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -96,6 +96,7 @@ import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { CustomEditor } from "./components/custom-editor"; import { DynamicBorder } from "./components/dynamic-border"; +import { ErrorBannerComponent } from "./components/error-banner"; import type { EvalExecutionComponent } from "./components/eval-execution"; import type { HookEditorComponent } from "./components/hook-editor"; import type { HookInputComponent } from "./components/hook-input"; @@ -264,6 +265,7 @@ export class InteractiveMode implements InteractiveModeContext { todoContainer: Container; btwContainer: Container; omfgContainer: Container; + errorBannerContainer: Container; editor: CustomEditor; editorContainer: Container; hookWidgetContainerAbove: Container; @@ -405,6 +407,7 @@ export class InteractiveMode implements InteractiveModeContext { this.todoContainer = new Container(); this.btwContainer = new Container(); this.omfgContainer = new Container(); + this.errorBannerContainer = new Container(); this.editor = new CustomEditor(getEditorTheme()); this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor()); this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible")); @@ -566,6 +569,7 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.addChild(this.todoContainer); this.ui.addChild(this.btwContainer); this.ui.addChild(this.omfgContainer); + this.ui.addChild(this.errorBannerContainer); this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border) this.ui.addChild(this.hookWidgetContainerAbove); this.ui.addChild(this.editorContainer); @@ -2503,6 +2507,19 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.showError(message); } + showPinnedError(message: string): void { + if (this.isBackgrounded) return; + this.errorBannerContainer.clear(); + this.errorBannerContainer.addChild(new ErrorBannerComponent(message)); + this.ui.requestRender(); + } + + clearPinnedError(): void { + if (this.errorBannerContainer.children.length === 0) return; + this.errorBannerContainer.clear(); + this.ui.requestRender(); + } + showWarning(message: string): void { this.#uiHelpers.showWarning(message); } @@ -2723,6 +2740,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#btwController.dispose(); this.#omfgController.dispose(); this.#extensionUiController.clearExtensionTerminalInputListeners(); + this.clearPinnedError(); this.#planReviewContainer = undefined; } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index a3279bb46..9116494ff 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -76,6 +76,7 @@ export interface InteractiveModeContext { todoContainer: Container; btwContainer: Container; omfgContainer: Container; + errorBannerContainer: Container; editor: CustomEditor; editorContainer: Container; hookWidgetContainerAbove: Container; @@ -159,6 +160,8 @@ export interface InteractiveModeContext { // UI helpers showStatus(message: string, options?: { dim?: boolean }): void; showError(message: string): void; + showPinnedError(message: string): void; + clearPinnedError(): void; showWarning(message: string): void; showNewVersionNotification(newVersion: string): void; clearEditor(): void; diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts new file mode 100644 index 000000000..4fa39bcee --- /dev/null +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -0,0 +1,137 @@ +/** + * EventController error-banner wiring. + * + * A turn that ends on a provider error (e.g. Anthropic's "Output blocked by + * content filtering policy") must pin a persistent banner above the editor via + * `ctx.showPinnedError`, and the banner must be cleared at the next turn's + * `agent_start` via `ctx.clearPinnedError`. Aborts and normal stops must NOT + * pin a banner. + */ +import { beforeAll, describe, expect, it, vi } from "bun:test"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { ErrorBannerComponent } from "@oh-my-pi/pi-coding-agent/modes/components/error-banner"; +import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; + +function makeAssistantMessage(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "draft" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + ...overrides, + }; +} + +beforeAll(async () => { + await initTheme(false); +}); + +function createFixture(streamingMessage?: AssistantMessage) { + const streamingComponent = { + updateContent: vi.fn(), + setUsageInfo: vi.fn(), + setComplete: vi.fn(), + markTranscriptBlockFinalized: vi.fn(), + }; + const showPinnedError = vi.fn(); + const clearPinnedError = vi.fn(); + + const ctx = { + isInitialized: true, + init: vi.fn(async () => {}), + ui: { requestRender: vi.fn(), setEagerNativeScrollbackRebuild: vi.fn() }, + statusLine: { invalidate: vi.fn() }, + updateEditorTopBorder: vi.fn(), + ensureLoadingAnimation: vi.fn(), + editor: {}, + streamingComponent: streamingMessage ? streamingComponent : undefined, + streamingMessage, + pendingTools: new Map(), + showPinnedError, + clearPinnedError, + session: { isTtsrAbortPending: false, retryAttempt: 0 }, + } as unknown as InteractiveModeContext; + + const controller = new EventController(ctx); + return { controller, ctx, showPinnedError, clearPinnedError }; +} + +describe("EventController error banner", () => { + it("pins the provider error above the editor when an assistant turn ends on stopReason error", async () => { + const errorMessage = "Output blocked by content filtering policy"; + const message = makeAssistantMessage({ stopReason: "error", errorMessage }); + const { controller, showPinnedError } = createFixture(message); + + await controller.handleEvent({ type: "message_end", message } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect(showPinnedError).toHaveBeenCalledTimes(1); + expect(showPinnedError).toHaveBeenCalledWith(errorMessage); + }); + + it("does not pin a banner for a normal assistant stop", async () => { + const message = makeAssistantMessage({ stopReason: "stop" }); + const { controller, showPinnedError } = createFixture(message); + + await controller.handleEvent({ type: "message_end", message } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect(showPinnedError).not.toHaveBeenCalled(); + }); + + it("does not pin a banner for an aborted assistant turn", async () => { + const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: "Operation aborted" }); + const { controller, showPinnedError } = createFixture(message); + + await controller.handleEvent({ type: "message_end", message } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + expect(showPinnedError).not.toHaveBeenCalled(); + }); + + it("clears the pinned banner when the next turn starts", async () => { + const { controller, clearPinnedError } = createFixture(); + + await controller.handleEvent({ type: "agent_start" } as Extract); + + expect(clearPinnedError).toHaveBeenCalledTimes(1); + }); +}); + +describe("ErrorBannerComponent", () => { + it("renders the provider error message", () => { + const banner = new ErrorBannerComponent("Output blocked by content filtering policy"); + const rendered = Bun.stripANSI(banner.render(120).join("\n")); + expect(rendered).toContain("Output blocked by content filtering policy"); + expect(rendered).toContain("Dismissed when you send your next message."); + }); + + it("caps an oversized multi-line error to a few lines", () => { + const huge = Array.from({ length: 50 }, (_, i) => `error detail line ${i}`).join("\n"); + const banner = new ErrorBannerComponent(huge); + const lines = Bun.stripANSI(banner.render(120).join("\n")).split("\n"); + const detailLines = lines.filter(line => line.includes("error detail line")); + expect(detailLines.length).toBeLessThanOrEqual(3); + expect(detailLines.length).toBeGreaterThan(0); + }); +});