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