diff --git a/src/tui/long-log.ts b/src/tui/long-log.ts index 69b47f62d..ab7ae7f16 100644 --- a/src/tui/long-log.ts +++ b/src/tui/long-log.ts @@ -36,7 +36,19 @@ export function trimRetainedLog(log: T[], base: number): number { * Notice painted above the oldest retained row once the cap has evicted * anything. Unlike the pre-CL-5551 collapse marker it replaces, scrolling * never reveals more — these rows are gone, not merely out of the window. + * + * `evicted` is the count of painted rows actually spliced from the log. Do + * not invent a count of 1 to mean "older history exists on disk." */ export function evictedRowsNotice(evicted: number): string { return ` … ${evicted} earlier row${evicted === 1 ? "" : "s"} dropped (past the retention limit)`; } + +/** + * Notice when older history exists on disk but no painted row was spliced. + * Resume can load a truncated tail that still fits the cap; the marker + * must still say this is not the start of history, without a fake count. + */ +export function unloadedHistoryNotice(): string { + return " … earlier rows not loaded (past the retention limit)"; +} diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index aef7dd4df..0747d6ee6 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -4,8 +4,9 @@ */ import { EventEmitter } from "node:events"; import { describe, expect, test } from "bun:test"; -import type { KeyEvent } from "@opentui/core"; +import { TextRenderable, type KeyEvent } from "@opentui/core"; import type { ConversationTurn } from "@intx/types/runtime"; +import { defined } from "../../testkit/defined.js"; import { createHarness, type Harness } from "./harness.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; import { @@ -19,7 +20,10 @@ import { hydrateHistoryRows } from "./history-hydrate.js"; import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js"; import { turnsToContentBlocks } from "./turns-to-blocks.js"; import { enterSubagentObserve } from "./shell/observe.js"; -import { transcriptMarker } from "./shell/transcript.js"; +import { toggleRowExpandedAt } from "./shell/chrome.js"; +import { streamRowAt, transcriptMarker } from "./shell/transcript.js"; +import { isCollapsibleRow } from "./stream.js"; +import type { AppShell } from "./shell/internals.js"; function makeFakeSessionPort(): { readonly sends: string[]; @@ -139,6 +143,13 @@ function spawnResultTurn(id: string): ConversationTurn { } as unknown as ConversationTurn; } +function markerNotice(shell: AppShell): string { + const marker = transcriptMarker(shell); + expect(marker).toBeInstanceOf(TextRenderable); + const content = (marker as TextRenderable).content; + return typeof content === "string" ? content : String(content); +} + describe("mountProductHost", () => { test("stream events emitted on the event emitter paint rows into the shell", async () => { const { host, emitter } = await mountHeadless(); @@ -402,8 +413,62 @@ describe("mountProductHost", () => { const blocks = turnsToContentBlocks(turns); emitter.emit("history.hydrate", { blocks, truncated: true }); expect(host.shell.streamLog.length).toBe(MAX_RETAINED_STREAM_ROWS); - expect(host.shell.streamLogBase).toBeGreaterThan(0); + expect(host.shell.streamLogBase).toBe(0); + expect(streamRowAt(host.shell, 0)).toEqual( + defined(host.shell.streamLog[0]), + ); expect(transcriptMarker(host.shell)).toBeDefined(); + expect(markerNotice(host.shell)).not.toMatch(/\b1 earlier row\b/); + } finally { + host.dispose(); + } + }); + + test("truncated hydrate keeps the first retained tool row clickable", async () => { + const { host, emitter } = await mountHeadless(); + try { + emitter.emit("history.hydrate", { + blocks: [ + { + type: "tool_call", + name: "edit_file", + arguments: JSON.stringify({ + path: "src/x.ts", + old_string: "const a = 1", + new_string: "const a = 2", + }), + callId: "e1", + }, + ], + truncated: true, + }); + const first = defined(streamRowAt(host.shell, 0)); + expect(first).toEqual(defined(host.shell.streamLog[0])); + expect(isCollapsibleRow(first)).toBe(true); + expect(host.shell.streamLogBase).toBe(0); + expect(toggleRowExpandedAt(host.shell, 0)).toBe(true); + expect(streamRowAt(host.shell, 0)?.expanded).toBe(true); + expect(markerNotice(host.shell)).not.toMatch(/\b1 earlier row\b/); + } finally { + host.dispose(); + } + }); + + test("truncated hydrate in observe mode does not fake parentStreamLogBase", async () => { + const { host, emitter } = await mountHeadless(); + try { + enterSubagentObserve(host.shell, { + sessionId: "child-trunc", + agentId: "explorer", + description: "observe truncated hydrate", + lines: [], + }); + emitter.emit("history.hydrate", { + blocks: [{ type: "user", content: "kept parent" }], + truncated: true, + }); + expect(host.shell.parentStreamLogBase).toBe(0); + expect(host.shell.parentUnloadedHistory).toBe(true); } finally { host.dispose(); } diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index bbb3ee30e..8868fdc68 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -62,7 +62,11 @@ import { resolveMarkGrid, versionBadgeVisible, } from "../landing.js"; -import { evictedRowsNotice, trimRetainedLog } from "../long-log.js"; +import { + evictedRowsNotice, + trimRetainedLog, + unloadedHistoryNotice, +} from "../long-log.js"; import { destroySubtree } from "../teardown.js"; import { badgeCount, @@ -1076,26 +1080,38 @@ export function appendStreamRow(shell: AppShell, row: StreamRow): void { /** * Paint the dropped-rows notice when older history exists on disk but the * loaded window hydrated to at most the retention cap, so trim never ran. + * + * Does not bump `streamLogBase`. That field is the splice offset of + * `streamLog[0]`; faking it to 1 leaves the first retained row unreachable + * at absolute 0 and makes the notice claim one painted row was evicted. */ export function noteUnloadedHistory(shell: AppShell): void { if (shell.observe !== null && shell.parentStreamLog !== null) { - if ( - (shell.parentStreamLogBase ?? 0) === 0 && - shell.parentStreamLog.length > 0 - ) { - shell.parentStreamLogBase = 1; + if (shell.parentStreamLog.length > 0) { + shell.parentUnloadedHistory = true; } return; } - if (shell.streamLogBase > 0 || shell.streamLog.length === 0) return; - shell.streamLogBase = 1; + if (shell.streamLog.length === 0) return; + shell.unloadedHistory = true; + paintDroppedHistoryMarker(shell); +} + +function droppedHistoryNotice(shell: AppShell): string { + return shell.streamLogBase > 0 + ? evictedRowsNotice(shell.streamLogBase) + : unloadedHistoryNotice(); +} + +function paintDroppedHistoryMarker(shell: AppShell): void { + const content = droppedHistoryNotice(shell); const marker = transcriptMarker(shell); if (marker instanceof TextRenderable) { - marker.content = evictedRowsNotice(shell.streamLogBase); + marker.content = content; return; } const node = new TextRenderable(shell.renderer as CliRenderer, { - content: evictedRowsNotice(shell.streamLogBase), + content, fg: UI.textDim, }); evictionMarkers.add(node); @@ -1183,17 +1199,7 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { shell.transcript.remove(evicted); destroySubtree(evicted); } - const marker = transcriptMarker(shell); - if (marker instanceof TextRenderable) { - marker.content = evictedRowsNotice(shell.streamLogBase); - } else { - const node = new TextRenderable(shell.renderer as CliRenderer, { - content: evictedRowsNotice(shell.streamLogBase), - fg: UI.textDim, - }); - evictionMarkers.add(node); - shell.transcript.add(node, 1); - } + paintDroppedHistoryMarker(shell); } const index = shell.streamLog.length - 1; @@ -1251,6 +1257,7 @@ export function clearTranscript(shell: AppShell): void { shell.observe = null; shell.parentStreamLog = null; shell.parentStreamLogBase = null; + shell.parentUnloadedHistory = null; let guard = 4; while (guard-- > 0 && focusOwner(shell.focus) === "observe") { shell.focus = popFocus(shell.focus); @@ -1264,9 +1271,11 @@ export function clearTranscript(shell: AppShell): void { } shell.streamLog.length = 0; shell.streamLogBase = 0; + shell.unloadedHistory = false; shell.lineCount = 0; shell.parentStreamLog = null; shell.parentStreamLogBase = null; + shell.parentUnloadedHistory = null; repaintTranscriptWindow(shell); paintChrome(shell); } @@ -1350,9 +1359,11 @@ export function repaintTranscriptWindow(shell: AppShell): void { // Rows evicted by the retention cap are gone for good, not just scrolled // past — say so, or the boundary reads as the true start of history. - if (shell.streamLogBase > 0) { + // Truncated resume can also leave older history on disk without splicing + // any painted row; that path must not fake `streamLogBase`. + if (shell.streamLogBase > 0 || shell.unloadedHistory) { const marker = new TextRenderable(shell.renderer as CliRenderer, { - content: evictedRowsNotice(shell.streamLogBase), + content: droppedHistoryNotice(shell), fg: UI.textDim, }); evictionMarkers.add(marker); diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 7379385c6..fe0c4824f 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -438,6 +438,7 @@ export function createAppShell( lineCount: 0, streamLog: [], streamLogBase: 0, + unloadedHistory: false, agentVoices: new Set(), baseTitle: title, modelLabel: null, @@ -465,6 +466,7 @@ export function createAppShell( observe: null, parentStreamLog: null, parentStreamLogBase: null, + parentUnloadedHistory: null, promptKillRing: emptyKillRing, pendingAttachments: [], sentHistory: createSentHistoryBrowse([]), diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 39ab494ba..5b60b6200 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -421,6 +421,13 @@ export interface AppShell { * array itself. Bumped by the number of rows dropped on each trim. */ streamLogBase: number; + /** + * Older history exists on disk but was not loaded into this window. + * Independent of `streamLogBase`: a truncated resume that still fits + * the retention cap splices nothing, so the first retained row stays at + * absolute 0, but the dropped-rows marker still has to paint. + */ + unloadedHistory: boolean; /** * Distinct writers in the visible transcript. Rows carry a name and icon only * once this holds more than one, so identity appears where it disambiguates. @@ -514,6 +521,8 @@ export interface AppShell { parentStreamLog: StreamRow[] | null; /** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */ parentStreamLogBase: number | null; + /** Saved `unloadedHistory` for the parent snapshot while observing. */ + parentUnloadedHistory: boolean | null; /** * Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it; * the text widget itself has no concept of a kill ring (see diff --git a/src/tui/shell/observe.ts b/src/tui/shell/observe.ts index ca51b6f2a..009650b4b 100644 --- a/src/tui/shell/observe.ts +++ b/src/tui/shell/observe.ts @@ -29,6 +29,7 @@ export function enterSubagentObserve( const seedLines = session.lines.slice(); shell.parentStreamLog = shell.streamLog.slice(); shell.parentStreamLogBase = shell.streamLogBase; + shell.parentUnloadedHistory = shell.unloadedHistory; shell.observe = { sessionId: session.sessionId, agentId: session.agentId, @@ -40,6 +41,7 @@ export function enterSubagentObserve( // of how far the parent's retention cap has already trimmed. shell.streamLog = seedLines; shell.streamLogBase = 0; + shell.unloadedHistory = false; shell.lineCount = shell.streamLog.length; repaintTranscriptWindow(shell); @@ -72,8 +74,10 @@ export function leaveSubagentObserve(shell: AppShell): void { if (shell.parentStreamLog) { shell.streamLog = shell.parentStreamLog; shell.streamLogBase = shell.parentStreamLogBase ?? 0; + shell.unloadedHistory = shell.parentUnloadedHistory ?? false; shell.parentStreamLog = null; shell.parentStreamLogBase = null; + shell.parentUnloadedHistory = null; } shell.lineCount = shell.streamLog.length; repaintTranscriptWindow(shell);