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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user