diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5b484a4d8..92fedbd3b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index fc9805592..cafab1339 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -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(config: ShellRendererConfig) { 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(config: ShellRendererConfig) { 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(config: ShellRendererConfig) { cachedPreviewLines === previewLines && cachedExpanded === expanded && cachedRawOutput === rawOutput && - cachedIsPartial === isPartial + cachedIsPartial === isPartial && + cachedPreviewWindow === previewWindow ) { return cachedLines; } @@ -1348,7 +1351,9 @@ export function createShellRenderer(config: ShellRendererConfig) { 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(config: ShellRendererConfig) { cachedExpanded = expanded; cachedRawOutput = rawOutput; cachedIsPartial = isPartial; + cachedPreviewWindow = previewWindow; cachedLines = framed; return framed; }, @@ -1373,6 +1379,7 @@ export function createShellRenderer(config: ShellRendererConfig) { cachedExpanded = undefined; cachedRawOutput = undefined; cachedIsPartial = undefined; + cachedPreviewWindow = undefined; }, }); }, diff --git a/packages/coding-agent/src/tools/eval-render.ts b/packages/coding-agent/src/tools/eval-render.ts index df581955b..a891280aa 100644 --- a/packages/coding-agent/src/tools/eval-render.ts +++ b/packages/coding-agent/src/tools/eval-render.ts @@ -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, }, diff --git a/packages/coding-agent/src/tools/render-utils.ts b/packages/coding-agent/src/tools/render-utils.ts index 99d981e91..4c27b9b4e 100644 --- a/packages/coding-agent/src/tools/render-utils.ts +++ b/packages/coding-agent/src/tools/render-utils.ts @@ -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 { diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index ed3608c99..b04fb74dd 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -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 }, ], diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index 48f0ca9c4..45cb05568 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -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"); + }); }); diff --git a/packages/coding-agent/test/tools/eval-code-preview.test.ts b/packages/coding-agent/test/tools/eval-code-preview.test.ts new file mode 100644 index 000000000..bfed55f27 --- /dev/null +++ b/packages/coding-agent/test/tools/eval-code-preview.test.ts @@ -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); + }); +}); diff --git a/packages/coding-agent/test/tools/ssh-render.test.ts b/packages/coding-agent/test/tools/ssh-render.test.ts index f94e59217..8b72919ee 100644 --- a/packages/coding-agent/test/tools/ssh-render.test.ts +++ b/packages/coding-agent/test/tools/ssh-render.test.ts @@ -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"); + }); });