feat(coding-agent): pinned turn-ending provider errors above editor

- Added a persistent error banner so stream errors survive transcript scroll.
- Cleared the banner when the next turn starts.
- Skipped pinning for aborts and normal stops.
This commit is contained in:
can1357
2026-06-06 00:23:07 +02:00
parent 340ac31122
commit ca143a4e08
6 changed files with 204 additions and 0 deletions
+2
View File
@@ -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 `<workstation>` 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.
@@ -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)));
}
}
@@ -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();
}
@@ -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;
}
+3
View File
@@ -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;
@@ -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> = {}): 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<AgentSessionEvent, { type: "agent_start" }>);
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);
});
});