diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 285a1a066..b3a70ecca 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,5 +1,23 @@ # changes +## bounded compaction progress row (2026-07-24) + +### What changed + +- `components/status-indicator.ts`: the combined compaction spinner, status, and streamed preview is truncated to the + actual terminal width. +- `../../../test/interactive-mode-compaction.test.ts`: pins a hostile multiline, 600-column progress update to one + rendered row no wider than the requested terminal width. + +### Why + +- Long streamed summaries could wrap the otherwise single-row lifecycle indicator, push the composer upward, and make + previous output appear erased as the terminal viewport remapped. + +### Expected merge conflict zones + +- LOW: `components/status-indicator.ts` compaction progress rendering. + ## accepted-only compaction queue transfer (2026-07-24) ### What changed diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts index 9b9b18bdb..aab936f43 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -1,4 +1,11 @@ -import { type Component, Loader, type LoaderIndicatorOptions, type TUI } from "@earendil-works/pi-tui"; +import { + type Component, + Loader, + type LoaderIndicatorOptions, + type TUI, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; import { theme } from "../theme/theme.ts"; import { CountdownTimer } from "./countdown-timer.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -105,9 +112,12 @@ export class CompactionStatusIndicator extends StatusIndicator { } override render(width: number): string[] { - const status = super.render(width).join(" "); - if (!this.progressText) return [status]; - return [`${status} ${theme.fg("muted", this.progressText)}`]; + if (!this.progressText) return super.render(width); + const progressWidth = Math.min(visibleWidth(this.progressText), Math.max(1, Math.floor(width / 2))); + const statusWidth = Math.max(1, width - progressWidth - 1); + const status = truncateToWidth(super.render(statusWidth).join(" "), statusWidth, ""); + const progress = truncateToWidth(theme.fg("muted", this.progressText), progressWidth); + return [truncateToWidth(`${status} ${progress}`, width)]; } } diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 5885de7c2..0ac15dec5 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Container, sanitizeTerminalLabel } from "@earendil-works/pi-tui"; +import { Container, sanitizeTerminalLabel, visibleWidth } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; import { SessionManager } from "../src/core/session-manager.ts"; import { CompactionSummaryMessageComponent } from "../src/modes/interactive/components/compaction-summary-message.ts"; @@ -97,7 +97,7 @@ describe("InteractiveMode compaction events", () => { fakeThis.autoCompactionLoader?.stop(); }); - test("renders streamed compaction progress below the active loader", async () => { + test("bounds streamed compaction progress to the active status row", async () => { const statusContainer = new Container(); const fakeThis = { isInitialized: true, @@ -133,12 +133,15 @@ describe("InteractiveMode compaction events", () => { await handleEvent.call(fakeThis, { type: "compaction_progress", reason: "extension", - delta: "live summary chunk", + delta: `live\n${"summary chunk ".repeat(40)}`, }); - const rendered = stripAnsi(statusContainer.children.flatMap((child) => child.render(120)).join("\n")); - expect(rendered).toContain("Compacting context"); - expect(rendered).toContain("live summary chunk"); + const renderedLines = statusContainer.children.flatMap((child) => child.render(40)); + const rendered = stripAnsi(renderedLines.join("\n")); + expect(renderedLines).toHaveLength(1); + expect(visibleWidth(renderedLines[0] ?? "")).toBeLessThanOrEqual(40); + expect(rendered).toContain("Compacting"); + expect(rendered).toContain("live summary"); fakeThis.autoCompactionLoader?.stop(); });