Skip to content
Draft
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
5 changes: 4 additions & 1 deletion packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed actionable URLs in turn-ending provider errors being truncated out of view. The pinned error banner and the inline transcript error block now wrap long error messages instead of clipping each line at a fixed width, so a Cloud Code Assist account-verification link (`Account verification required … Visit https://…`) is shown in full rather than cut to `Visit https://acco…`.

## [16.1.3] - 2026-06-19

### Changed
Expand Down Expand Up @@ -340,7 +344,6 @@
### Removed

- Removed the built-in `render_mermaid` tool and its `renderMermaid.enabled` setting, so it can no longer be invoked directly

## [16.0.2] - 2026-06-16

### Added
Expand Down
17 changes: 9 additions & 8 deletions packages/coding-agent/src/modes/components/assistant-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ import { Container, Image, type ImageBudget, ImageProtocol, Markdown, Spacer, TE
import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
import { getWrappedPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";

/**
* Max lines of a turn-ending provider error rendered inline in the transcript.
* Bounds pathological error bodies — e.g. a proxy 502 whose body is a full HTML
* page — so they can't flood the scrollback. Blank lines are dropped and each
* line is width-truncated by {@link getPreviewLines}. Full text is still kept in
* the persisted session.
* page — so they can't flood the scrollback. Blank lines are dropped and over-
* long lines are wrapped (not truncated) by {@link getWrappedPreviewLines} so an
* embedded URL survives intact. Full text is still kept in the persisted session.
*/
const MAX_TRANSCRIPT_ERROR_LINES = 8;

Expand Down Expand Up @@ -287,12 +287,13 @@ export class AssistantMessageComponent extends Container {

/**
* Render a turn-ending provider error inline. Drops blank lines, clamps the
* line count to {@link MAX_TRANSCRIPT_ERROR_LINES}, and width-truncates each
* line so a pathological body — e.g. the HTML page a proxy returns on a 502 —
* can't flood the transcript. Mirrors {@link ErrorBannerComponent}.
* line count to {@link MAX_TRANSCRIPT_ERROR_LINES}, and wraps over-long lines
* so a pathological body — e.g. the HTML page a proxy returns on a 502 —
* can't flood the transcript while an actionable URL stays intact. Mirrors
* {@link ErrorBannerComponent}.
*/
#appendErrorBlock(message: string): void {
const lines = getPreviewLines(message, MAX_TRANSCRIPT_ERROR_LINES, TRUNCATE_LENGTHS.LINE);
const lines = getWrappedPreviewLines(message, MAX_TRANSCRIPT_ERROR_LINES, TRUNCATE_LENGTHS.LINE);
if (lines.length === 0) lines.push("Unknown error");
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${lines[0]}`), 1, 0));
Expand Down
14 changes: 10 additions & 4 deletions packages/coding-agent/src/modes/components/error-banner.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getPreviewLines, TRUNCATE_LENGTHS } from "../../tools/render-utils";
import { getWrappedPreviewLines, 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;
/**
* Max lines of the error message shown in the pinned banner. Lines wrap rather
* than truncate (see {@link getWrappedPreviewLines}) so an actionable URL — e.g.
* a Cloud Code Assist account-verification link — is never severed mid-token;
* the cap is generous enough to fit a long URL while still bounding a runaway
* error body.
*/
const MAX_BANNER_LINES = 6;

/**
* A persistent error banner pinned above the editor. Unlike the transcript
Expand All @@ -16,7 +22,7 @@ const MAX_BANNER_LINES = 3;
export class ErrorBannerComponent extends Container {
constructor(message: string) {
super();
const lines = getPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE);
const lines = getWrappedPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE);
if (lines.length === 0) {
lines.push("Unknown error");
}
Expand Down
52 changes: 51 additions & 1 deletion packages/coding-agent/src/tools/render-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as path from "node:path";
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
import type { Ellipsis } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { getKeybindings, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
import { getKeybindings, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import { pluralize } from "@oh-my-pi/pi-utils";
import { formatKeyHints, type KeyId } from "../config/keybindings";
import { settings } from "../config/settings";
Expand Down Expand Up @@ -99,6 +99,56 @@ export function getPreviewLines(text: string, maxLines: number, maxLineLen: numb
return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
}

/**
* Like {@link getPreviewLines}, but WRAPS over-long logical lines across
* multiple output lines instead of hard-truncating each at `wrapWidth`. Used by
* error renderers where embedded content — e.g. an account-verification URL —
* must stay intact even when the whole message is one long line that would
* otherwise be severed mid-token. Sanitizes tabs to spaces. Output is still
* capped at `maxLines`; the last retained line gets a trailing overflow marker
* only when wrapped content genuinely overflows the budget AND the marker fits
* without severing that line — a long token (e.g. an account-verification URL)
* is never clipped just to make room for the marker. Full text is still kept in
* the persisted session.
*/
export function getWrappedPreviewLines(text: string, maxLines: number, wrapWidth: number): string[] {
const sanitized = replaceTabs(text);
const out: string[] = [];
let overflowed = false;
// A single logical line never yields more than `maxLines` retained pieces, so
// cap its length before wrapping. Without this, a pathological one-line body —
// e.g. a proxy 502 whose body is a full HTML page — would be wrapped in full
// (one substring per ~wrapWidth chars) only to discard all but the first
// `maxLines` pieces. The +1 keeps just enough input to still detect overflow.
const maxLineChars = (maxLines + 1) * wrapWidth;
for (const rawLine of sanitized.split("\n")) {
const trimmed = rawLine.trim();
if (!trimmed) continue;
const line = trimmed.length > maxLineChars ? trimmed.slice(0, maxLineChars) : trimmed;
for (const piece of wrapTextWithAnsi(line, wrapWidth)) {
if (out.length >= maxLines) {
overflowed = true;
break;
}
out.push(piece);
}
if (overflowed) break;
}
if (overflowed && out.length > 0) {
// Best-effort overflow hint. Append it only when it fits without clipping
// the retained line — severing a visible token (e.g. an intact URL) to make
// room for the marker would defeat the point of wrapping. When the final
// line is already full, leave it intact; the persisted session keeps the
// full text either way.
const marker = " …";
const last = out[out.length - 1];
if (visibleWidth(last) + visibleWidth(marker) <= wrapWidth) {
out[out.length - 1] = `${last}${marker}`;
}
}
return out;
}

// =============================================================================
// URL Utilities
// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe("ErrorBannerComponent", () => {
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).toBeLessThanOrEqual(6);
expect(detailLines.length).toBeGreaterThan(0);
});
});
Expand Down
56 changes: 55 additions & 1 deletion packages/coding-agent/test/tools/render-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
formatExpandHint,
formatParseErrors,
formatScreenshot,
getPreviewLines,
getWrappedPreviewLines,
shortenPath,
truncateDiffByHunk,
} from "@oh-my-pi/pi-coding-agent/tools/render-utils";
Expand Down Expand Up @@ -94,7 +96,7 @@ describe("formatScreenshot", () => {
savedByteLength: 2048,
dest: filePath,
resized,
}),
}).map(line => line.replaceAll("\\", "/")),
).toEqual([
"Screenshot captured",
"Saved: image/png (2.00 KB) to ~/screenshots/capture.png",
Expand Down Expand Up @@ -335,3 +337,55 @@ describe("formatExpandHint / expandKeyHint", () => {
expect(formatExpandHint(plainTheme, false, false)).toBe("");
});
});

describe("getWrappedPreviewLines", () => {
// Mirrors the Cloud Code Assist account-verification error: a single long
// line whose actionable URL sits well past the per-line width.
const validationUrl =
"https://accounts.google.com/signin/continue?sarp=1&scc=1&plt=AKgnsbt0123456789abcdefghijklmnopqrstuvwxyzTOKEN";
const message = `Cloud Code Assist API error (403): Account verification required for user@example.com. Visit ${validationUrl} to continue, then retry your request.`;

it("keeps an embedded URL intact instead of truncating it mid-token", () => {
const wrapped = getWrappedPreviewLines(message, 6, 110);
expect(wrapped.join("")).toContain(validationUrl);
// Contrast: the truncating helper severs the URL on the single clipped line.
expect(getPreviewLines(message, 6, 110).join("")).not.toContain(validationUrl);
});

it("caps output at maxLines and marks the overflow when the final line has room", () => {
const long = ["alpha", "bravo", "charlie", "delta", "echo"].join("\n");
const wrapped = getWrappedPreviewLines(long, 3, 40);
expect(wrapped).toHaveLength(3);
// The marker is appended (not clipped in) because "charlie" leaves room.
expect(wrapped[wrapped.length - 1]).toBe("charlie …");
});

it("keeps a full-width URL on the final retained line intact instead of clipping it for the marker", () => {
// The URL lands on the last retained line and fills it (109 cells); the
// overflow marker (" …", 2 cells) cannot fit without severing the URL, so
// it is dropped and the URL survives — the whole point of the helper.
const msg = `padding header line\n${validationUrl}\ntrailing content that overflows the budget`;
const wrapped = getWrappedPreviewLines(msg, 2, 110);
expect(wrapped).toHaveLength(2);
expect(wrapped[wrapped.length - 1]).toBe(validationUrl);
expect(wrapped[wrapped.length - 1].endsWith("…")).toBe(false);
});

it("returns content verbatim when it fits within the budget", () => {
expect(getWrappedPreviewLines("short error", 6, 110)).toEqual(["short error"]);
});

it("sanitizes tabs to spaces", () => {
expect(getWrappedPreviewLines("error\twith\ttabs", 6, 110)).toEqual(["error with tabs"]);
});

it("bounds work on a pathological single-line body while preserving early content", () => {
// A proxy 502 can return a multi-megabyte single-line HTML body. The helper
// must not wrap the whole thing just to keep `maxLines`; the leading URL
// must still survive and the output stays capped at maxLines.
const giant = `Visit ${validationUrl} to continue. ${"x".repeat(2_000_000)}`;
const wrapped = getWrappedPreviewLines(giant, 6, 110);
expect(wrapped).toHaveLength(6);
expect(wrapped.join("")).toContain(validationUrl);
});
});