ux(coding-agent): capped collapsed bash, ssh, and eval previews to a viewport tail window

- Replaced the fixed `CALL_PREVIEW_MAX_LINES` cap in `render-utils.ts` with a dynamic `previewWindowRows()` (terminal rows minus a chrome reserve); `capPreviewLines` now returns lines untouched when expanded and windows them otherwise.
- Applied the cap in every render state for bash and ssh command previews — streaming and final render identically instead of snapping fully open on completion — and included the preview window in bash's render cache key so terminal resizes invalidate it.
- Capped eval cell code previews via the same window; previously cell code was never capped at all, with `ctrl+o` remaining the only way to uncap.
- Added stream-vs-final parity coverage in `bash-sixel-render.test.ts`, `ssh-render.test.ts`, and the new `eval-code-preview.test.ts`, and a changelog entry.
This commit is contained in:
can1357
2026-06-12 02:28:06 +02:00
parent 7c3407ac65
commit 8fd5171768
8 changed files with 196 additions and 45 deletions
+1
View File
@@ -15,6 +15,7 @@
- Reorganized the `/settings` panel for findability: every tab now has titled sections backed by a per-tab layout contract (`TAB_GROUPS`); on wide terminals the panel renders a section sidebar with the active section's settings beside it (narrow terminals keep a flat list with inline headings), and PgUp/PgDn jump section-to-section. The Editing tab became Files (edit/read/LSP) and a new Shell tab hosts bash, eval, and Python settings. Misplaced settings were rehomed: bash toggles united under Shell, tool approval mode and policies together under Interaction → Approvals, marketplace auto-update next to startup update checks, and the todo auto-clear delay beside the other todo settings.
- Normalized `/settings` labels and descriptions: consistent Title Case labels (e.g. "Todo Auto-Clear Delay", "GitHub View Cache"), uniform unit placement, articles and verb-first phrasing in descriptions ("If false…"/"Whether to…" rewritten), and a stale browser-tool description (Ulixee Hero) corrected to the actual puppeteer/Chromium implementation.
- Image-generation (Antigravity, xAI, OpenRouter, Gemini) and xAI TTS request failures now throw pi-ai's typed `ProviderHttpError` carrying status and response headers instead of `Object.assign`-patched `Error`s.
- Collapsed bash, ssh, and eval previews now cap the command/code section to a viewport-sized tail window (terminal rows minus a chrome reserve) that renders identically while streaming and after completion, with `ctrl+o` as the only way to uncap. Previously bash/ssh capped the command only while streaming and snapped it fully open the moment the tool finished, and eval never capped cell code at all.
### Fixed
+10 -3
View File
@@ -31,7 +31,7 @@ import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-ski
import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
import { resolveToCwd } from "./path-utils";
import { capPreviewLines, formatToolWorkingDirectory, replaceTabs } from "./render-utils";
import { capPreviewLines, formatToolWorkingDirectory, previewWindowRows, replaceTabs } from "./render-utils";
import { ToolAbortError, ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
@@ -1241,6 +1241,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
let cachedRawOutput: string | undefined;
let cachedIsPartial: boolean | undefined;
let cachedLines: readonly string[] | undefined;
let cachedPreviewWindow: number | undefined;
return markFramedBlockComponent({
render: (width: number): readonly string[] => {
@@ -1255,6 +1256,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
const isPartial = options.isPartial === true;
const previewWindow = previewWindowRows();
if (
cachedLines !== undefined &&
@@ -1262,7 +1264,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
cachedPreviewLines === previewLines &&
cachedExpanded === expanded &&
cachedRawOutput === rawOutput &&
cachedIsPartial === isPartial
cachedIsPartial === isPartial &&
cachedPreviewWindow === previewWindow
) {
return cachedLines;
}
@@ -1348,7 +1351,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
state: isPartial ? "pending" : isError ? "error" : "success",
sections: [
{
lines: isPartial ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded }) : (cmdLines ?? []),
// Viewport-sized tail window in every state — streaming and final
// render identically; only ctrl+o uncaps.
lines: capPreviewLines(cmdLines ?? [], uiTheme, { expanded }),
},
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
],
@@ -1362,6 +1367,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
cachedExpanded = expanded;
cachedRawOutput = rawOutput;
cachedIsPartial = isPartial;
cachedPreviewWindow = previewWindow;
cachedLines = framed;
return framed;
},
@@ -1373,6 +1379,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
cachedExpanded = undefined;
cachedRawOutput = undefined;
cachedIsPartial = undefined;
cachedPreviewWindow = undefined;
},
});
},
+13 -8
View File
@@ -33,6 +33,7 @@ import {
formatDuration,
formatStatusIcon,
formatTitle,
previewWindowRows,
replaceTabs,
shortenPath,
truncateToWidth,
@@ -493,7 +494,7 @@ export const evalToolRenderer = {
return markFramedBlockComponent({
render: (width: number): readonly string[] => {
const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
const key = `${options.expanded ? 1 : 0}|${previewWindowRows()}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
if (cached && cached.key === key && cached.width === width) {
return cached.result;
}
@@ -510,9 +511,11 @@ export const evalToolRenderer = {
title: cell.title,
status: "pending",
width,
// Always render the full source: the code is fixed input, not the
// streaming part, so it is never compacted.
codeMaxLines: Number.POSITIVE_INFINITY,
// Viewport-sized tail window following the newest streamed code
// line; renderResult keeps the same cap so the cell never snaps
// open on completion. Only ctrl+o uncaps.
codeTail: true,
codeMaxLines: previewWindowRows(),
expanded: options.expanded,
},
uiTheme,
@@ -576,7 +579,7 @@ export const evalToolRenderer = {
render: (width: number): readonly string[] => {
const expanded = options.renderContext?.expanded ?? options.expanded;
const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
const key = `${expanded}|${previewLines}|${options.spinnerFrame}|${previewWindowRows()}`;
if (cached && cached.key === key && cached.width === width) {
return cached.result;
}
@@ -613,9 +616,11 @@ export const evalToolRenderer = {
duration: cell.durationMs,
output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
outputMaxLines: outputLines.length,
// Code is fixed input — always shown in full, never compacted.
// Only `output` honors the collapsed preview cap above.
codeMaxLines: Number.POSITIVE_INFINITY,
// Same viewport-sized tail window as the pending preview so the
// cell never snaps open on completion; only ctrl+o uncaps.
// `output` keeps its own preview cap from above.
codeTail: true,
codeMaxLines: previewWindowRows(),
expanded,
width,
},
+29 -31
View File
@@ -183,24 +183,32 @@ export function formatMoreItems(remaining: number, itemType: string): string {
}
/**
* Maximum rows a tool's streaming/pending *call* preview may render before it is
* capped. This is intentionally conservative: the preview still sits inside a
* transcript that already consumed some viewport rows, and tool blocks carry
* extra chrome (status/header/border/"more lines"), so a "reasonable" raw code
* or command preview like 10-12 lines can still overflow and strand its top
* while the block is volatile. Keeping the live call window short avoids that
* across terminals without turning the transcript into an interactive scroller.
* Collapsed command/code previews render a tail window sized from the live
* viewport: terminal rows minus a reserve for the rest of the block (frame,
* Output section, stats line) and the editor/status area below the
* transcript. This keeps a volatile streaming block from growing past the
* viewport and stranding its top, while letting tall terminals show more.
*/
export const CALL_PREVIEW_MAX_LINES = 6;
const PREVIEW_WINDOW_RESERVED_ROWS = 20;
/** Floor so tiny or unknown viewports still show a useful window. */
const PREVIEW_WINDOW_MIN_LINES = 6;
/** Assumed viewport when rows are unknown (non-TTY, tests). */
const PREVIEW_WINDOW_FALLBACK_ROWS = 30;
/** Tail-window height for collapsed command/code previews. */
export function previewWindowRows(): number {
const rows = process.stdout.rows || PREVIEW_WINDOW_FALLBACK_ROWS;
return Math.max(PREVIEW_WINDOW_MIN_LINES, rows - PREVIEW_WINDOW_RESERVED_ROWS);
}
/**
* Cap a pre-rendered pending/call preview to a bounded window. When truncated,
* show both the head and the live tail so the user can still see what the tool
* is currently writing while the volatile block stays short enough not to strand
* its top above the viewport. `Ctrl+O` widens the bounded window, but does not
* fully uncap live tool previews for the same reason.
* Cap a pre-rendered command preview to a viewport-sized tail window: the end
* of the command stays visible (it is the live edge while args stream) behind
* an "… N earlier lines" marker on top. The same window applies while
* streaming and after completion so the block never jumps; only `expanded`
* (ctrl+o) uncaps it.
*
* `prefix` (raw, e.g. a dim tree gutter) is prepended to the summary line so
* `prefix` (raw, e.g. a dim tree gutter) is prepended to the marker line so
* nested previews stay aligned.
*/
export function capPreviewLines(
@@ -208,24 +216,14 @@ export function capPreviewLines(
theme: Theme,
options: { max?: number; expanded?: boolean; prefix?: string } = {},
): string[] {
const max = options.max ?? (options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : CALL_PREVIEW_MAX_LINES);
if (options.expanded) return lines;
const max = options.max ?? previewWindowRows();
if (lines.length <= max) return lines;
if (max <= 1) {
const hint = formatExpandHint(theme, options.expanded, true);
const moreLine = `${formatMoreItems(lines.length, "line")}${hint ? ` ${hint}` : ""}`;
return [`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`];
}
const bodyBudget = max - 1; // reserve one summary row
const headCount = Math.max(1, Math.ceil(bodyBudget / 2));
const tailCount = Math.max(1, bodyBudget - headCount);
const hidden = Math.max(0, lines.length - headCount - tailCount);
const hint = formatExpandHint(theme, options.expanded, true);
const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
return [
...lines.slice(0, headCount),
`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`,
...lines.slice(lines.length - tailCount),
];
const visible = max <= 1 ? [] : lines.slice(lines.length - (max - 1));
const hidden = lines.length - visible.length;
const hint = formatExpandHint(theme, false, true);
const marker = `… ${hidden} earlier ${pluralize("line", hidden)}${hint ? ` ${hint}` : ""}`;
return [`${options.prefix ?? ""}${theme.fg("dim", marker)}`, ...visible];
}
export function formatMeta(meta: string[], theme: Theme): string {
+3 -3
View File
@@ -329,9 +329,9 @@ export const sshToolRenderer = {
state: "success",
sections: [
{
lines: options.isPartial
? capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded })
: cmdLines,
// Viewport-sized tail window in every state — streaming and final
// render identically; only ctrl+o uncaps.
lines: capPreviewLines(cmdLines, uiTheme, { expanded }),
},
{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
],
@@ -4,6 +4,7 @@ import * as path from "node:path";
import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { bashToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
import { sanitizeText } from "@oh-my-pi/pi-utils";
@@ -304,4 +305,39 @@ describe("bashToolRenderer", () => {
const postInvalidate = component.render(120);
expect(postInvalidate).not.toBe(sameAgainCached);
});
it("renders the collapsed command as a viewport tail window in every state — no stream→final expansion", async () => {
// The collapsed command is a tail window sized from the viewport: the end
// (the live edge while args stream) stays visible behind an "earlier
// lines" marker. The finalized collapsed block MUST render the identical
// window — snapping the full command open on completion makes the block
// jump. Only ctrl+o (expanded) uncaps.
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const total = previewWindowRows() + 5;
const command = Array.from({ length: total }, (_, i) => `echo step_${i}`).join("\n");
const render = (opts: { expanded: boolean; isPartial: boolean }) => {
const component = bashToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details: {}, isError: false },
opts,
uiTheme,
{ command },
);
return sanitizeText(component.render(120).join("\n"));
};
for (const rendered of [
render({ expanded: false, isPartial: true }),
render({ expanded: false, isPartial: false }),
]) {
expect(rendered).toContain(`echo step_${total - 1}`);
expect(rendered).toContain("earlier line");
expect(rendered).not.toContain("echo step_0");
}
const expandedFinal = render({ expanded: true, isPartial: false });
expect(expandedFinal).toContain("echo step_0");
expect(expandedFinal).not.toContain("earlier line");
});
});
@@ -0,0 +1,74 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { EvalToolDetails } from "@oh-my-pi/pi-coding-agent/eval/types";
import { getThemeByName, setThemeInstance, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { evalToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/eval";
import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
/**
* Defends the bounded code-window contract for eval cells: collapsed views cap
* the cell source to a viewport-sized TAIL window (the end stays visible, the
* head is elided behind an "earlier lines" marker) in BOTH the pending preview
* and the final result, so a long cell neither floods the transcript nor snaps
* open when the result lands. Only ctrl+o (expanded) uncaps.
*/
describe("eval renderer: viewport tail window for cell code", () => {
let theme: Theme;
const total = previewWindowRows() + 5;
const code = Array.from({ length: total }, (_, i) => `value_${i} = ${i}`).join("\n");
const firstLine = "value_0 = 0";
const lastLine = `value_${total - 1} = ${total - 1}`;
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
theme = (await getThemeByName("dark"))!;
expect(theme).toBeDefined();
setThemeInstance(theme);
});
afterAll(() => {
resetSettingsForTest();
});
function renderResult(expanded: boolean): string {
const details: EvalToolDetails = {
language: "python",
languages: ["python"],
cells: [{ index: 0, code, language: "python", output: "", status: "complete", statusEvents: [] }],
};
const component = evalToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details },
{ expanded, isPartial: false, spinnerFrame: 0 },
theme,
);
return Bun.stripANSI(component.render(120).join("\n"));
}
it("caps collapsed result code to the tail window with an earlier-lines marker", () => {
const rendered = renderResult(false);
expect(rendered).toContain(lastLine);
expect(rendered).toContain("earlier line");
expect(rendered).not.toContain(firstLine);
});
it("shows the full source when expanded", () => {
const rendered = renderResult(true);
expect(rendered).toContain(firstLine);
expect(rendered).toContain(lastLine);
expect(rendered).not.toContain("earlier line");
});
it("bounds the pending preview to the same live tail window", () => {
const component = evalToolRenderer.renderCall(
{ cells: [{ language: "py", code }] },
{ expanded: false, isPartial: true },
theme,
);
const rendered = Bun.stripANSI(component.render(120).join("\n"));
// Newest streamed line stays visible; earliest lines are elided above it.
expect(rendered).toContain(lastLine);
expect(rendered).toContain("earlier line");
expect(rendered).not.toContain(firstLine);
});
});
@@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils";
import { sshToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ssh";
import { sanitizeText } from "@oh-my-pi/pi-utils";
@@ -55,4 +56,33 @@ describe("sshToolRenderer", () => {
expect(body).toContain("$ set -e");
expect(body).toContain("do-something");
});
it("renders the collapsed command as a viewport tail window in every state — no stream→final expansion", async () => {
const uiTheme = (await getThemeByName("dark"))!;
expect(uiTheme).toBeDefined();
const total = previewWindowRows() + 5;
const command = Array.from({ length: total }, (_, i) => `step_${i}`).join("\n");
const render = (opts: { expanded: boolean; isPartial: boolean }) =>
sanitizeText(
sshToolRenderer
.renderResult({ content: [{ type: "text", text: "" }] }, opts, uiTheme, { host: "router", command })
.render(120)
.join("\n"),
);
// Identical tail window streaming and final: the end stays visible, the
// head is elided behind an "earlier lines" marker. Only ctrl+o uncaps.
for (const rendered of [
render({ expanded: false, isPartial: true }),
render({ expanded: false, isPartial: false }),
]) {
expect(rendered).toContain(`step_${total - 1}`);
expect(rendered).toContain("earlier line");
expect(rendered).not.toContain("step_0");
}
const expandedFinal = render({ expanded: true, isPartial: false });
expect(expandedFinal).toContain("$ step_0");
expect(expandedFinal).not.toContain("earlier line");
});
});