Skip to content

Commit 4feb6a3

Browse files
committed
fix(resume): paint truncated history without faking the log base
A truncated tail that still fits the retention cap never splices a painted row. Bumping the base to 1 just to show the marker made the first retained row unreachable at absolute 0 and claimed one row was evicted. The marker now tracks unloaded history on its own.
1 parent c3d0f99 commit 4feb6a3

6 files changed

Lines changed: 129 additions & 26 deletions

File tree

‎src/tui/long-log.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,19 @@ export function trimRetainedLog<T>(log: T[], base: number): number {
3636
* Notice painted above the oldest retained row once the cap has evicted
3737
* anything. Unlike the pre-CL-5551 collapse marker it replaces, scrolling
3838
* never reveals more — these rows are gone, not merely out of the window.
39+
*
40+
* `evicted` is the count of painted rows actually spliced from the log. Do
41+
* not invent a count of 1 to mean "older history exists on disk."
3942
*/
4043
export function evictedRowsNotice(evicted: number): string {
4144
return ` … ${evicted} earlier row${evicted === 1 ? "" : "s"} dropped (past the retention limit)`;
4245
}
46+
47+
/**
48+
* Notice when older history exists on disk but no painted row was spliced.
49+
* Resume can load a truncated tail that still fits the cap; the marker
50+
* must still say this is not the start of history, without a fake count.
51+
*/
52+
export function unloadedHistoryNotice(): string {
53+
return " … earlier rows not loaded (past the retention limit)";
54+
}

‎src/tui/product-host.test.ts‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
*/
55
import { EventEmitter } from "node:events";
66
import { describe, expect, test } from "bun:test";
7-
import type { KeyEvent } from "@opentui/core";
7+
import { TextRenderable, type KeyEvent } from "@opentui/core";
88
import type { ConversationTurn } from "@intx/types/runtime";
9+
import { defined } from "../../testkit/defined.js";
910
import { createHarness, type Harness } from "./harness.js";
1011
import { acceptOverlaySelection } from "./shell/overlay-host.js";
1112
import {
@@ -19,7 +20,10 @@ import { hydrateHistoryRows } from "./history-hydrate.js";
1920
import { MAX_RETAINED_STREAM_ROWS } from "./long-log.js";
2021
import { turnsToContentBlocks } from "./turns-to-blocks.js";
2122
import { enterSubagentObserve } from "./shell/observe.js";
22-
import { transcriptMarker } from "./shell/transcript.js";
23+
import { toggleRowExpandedAt } from "./shell/chrome.js";
24+
import { streamRowAt, transcriptMarker } from "./shell/transcript.js";
25+
import { isCollapsibleRow } from "./stream.js";
26+
import type { AppShell } from "./shell/internals.js";
2327

2428
function makeFakeSessionPort(): {
2529
readonly sends: string[];
@@ -139,6 +143,13 @@ function spawnResultTurn(id: string): ConversationTurn {
139143
} as unknown as ConversationTurn;
140144
}
141145

146+
function markerNotice(shell: AppShell): string {
147+
const marker = transcriptMarker(shell);
148+
expect(marker).toBeInstanceOf(TextRenderable);
149+
const content = (marker as TextRenderable).content;
150+
return typeof content === "string" ? content : String(content);
151+
}
152+
142153
describe("mountProductHost", () => {
143154
test("stream events emitted on the event emitter paint rows into the shell", async () => {
144155
const { host, emitter } = await mountHeadless();
@@ -402,8 +413,62 @@ describe("mountProductHost", () => {
402413
const blocks = turnsToContentBlocks(turns);
403414
emitter.emit("history.hydrate", { blocks, truncated: true });
404415
expect(host.shell.streamLog.length).toBe(MAX_RETAINED_STREAM_ROWS);
405-
expect(host.shell.streamLogBase).toBeGreaterThan(0);
416+
expect(host.shell.streamLogBase).toBe(0);
417+
expect(streamRowAt(host.shell, 0)).toEqual(
418+
defined(host.shell.streamLog[0]),
419+
);
406420
expect(transcriptMarker(host.shell)).toBeDefined();
421+
expect(markerNotice(host.shell)).not.toMatch(/\b1 earlier row\b/);
422+
} finally {
423+
host.dispose();
424+
}
425+
});
426+
427+
test("truncated hydrate keeps the first retained tool row clickable", async () => {
428+
const { host, emitter } = await mountHeadless();
429+
try {
430+
emitter.emit("history.hydrate", {
431+
blocks: [
432+
{
433+
type: "tool_call",
434+
name: "edit_file",
435+
arguments: JSON.stringify({
436+
path: "src/x.ts",
437+
old_string: "const a = 1",
438+
new_string: "const a = 2",
439+
}),
440+
callId: "e1",
441+
},
442+
],
443+
truncated: true,
444+
});
445+
const first = defined(streamRowAt(host.shell, 0));
446+
expect(first).toEqual(defined(host.shell.streamLog[0]));
447+
expect(isCollapsibleRow(first)).toBe(true);
448+
expect(host.shell.streamLogBase).toBe(0);
449+
expect(toggleRowExpandedAt(host.shell, 0)).toBe(true);
450+
expect(streamRowAt(host.shell, 0)?.expanded).toBe(true);
451+
expect(markerNotice(host.shell)).not.toMatch(/\b1 earlier row\b/);
452+
} finally {
453+
host.dispose();
454+
}
455+
});
456+
457+
test("truncated hydrate in observe mode does not fake parentStreamLogBase", async () => {
458+
const { host, emitter } = await mountHeadless();
459+
try {
460+
enterSubagentObserve(host.shell, {
461+
sessionId: "child-trunc",
462+
agentId: "explorer",
463+
description: "observe truncated hydrate",
464+
lines: [],
465+
});
466+
emitter.emit("history.hydrate", {
467+
blocks: [{ type: "user", content: "kept parent" }],
468+
truncated: true,
469+
});
470+
expect(host.shell.parentStreamLogBase).toBe(0);
471+
expect(host.shell.parentUnloadedHistory).toBe(true);
407472
} finally {
408473
host.dispose();
409474
}

‎src/tui/shell/chrome.ts‎

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ import {
6262
resolveMarkGrid,
6363
versionBadgeVisible,
6464
} from "../landing.js";
65-
import { evictedRowsNotice, trimRetainedLog } from "../long-log.js";
65+
import {
66+
evictedRowsNotice,
67+
trimRetainedLog,
68+
unloadedHistoryNotice,
69+
} from "../long-log.js";
6670
import { destroySubtree } from "../teardown.js";
6771
import {
6872
badgeCount,
@@ -1076,26 +1080,38 @@ export function appendStreamRow(shell: AppShell, row: StreamRow): void {
10761080
/**
10771081
* Paint the dropped-rows notice when older history exists on disk but the
10781082
* loaded window hydrated to at most the retention cap, so trim never ran.
1083+
*
1084+
* Does not bump `streamLogBase`. That field is the splice offset of
1085+
* `streamLog[0]`; faking it to 1 leaves the first retained row unreachable
1086+
* at absolute 0 and makes the notice claim one painted row was evicted.
10791087
*/
10801088
export function noteUnloadedHistory(shell: AppShell): void {
10811089
if (shell.observe !== null && shell.parentStreamLog !== null) {
1082-
if (
1083-
(shell.parentStreamLogBase ?? 0) === 0 &&
1084-
shell.parentStreamLog.length > 0
1085-
) {
1086-
shell.parentStreamLogBase = 1;
1090+
if (shell.parentStreamLog.length > 0) {
1091+
shell.parentUnloadedHistory = true;
10871092
}
10881093
return;
10891094
}
1090-
if (shell.streamLogBase > 0 || shell.streamLog.length === 0) return;
1091-
shell.streamLogBase = 1;
1095+
if (shell.streamLog.length === 0) return;
1096+
shell.unloadedHistory = true;
1097+
paintDroppedHistoryMarker(shell);
1098+
}
1099+
1100+
function droppedHistoryNotice(shell: AppShell): string {
1101+
return shell.streamLogBase > 0
1102+
? evictedRowsNotice(shell.streamLogBase)
1103+
: unloadedHistoryNotice();
1104+
}
1105+
1106+
function paintDroppedHistoryMarker(shell: AppShell): void {
1107+
const content = droppedHistoryNotice(shell);
10921108
const marker = transcriptMarker(shell);
10931109
if (marker instanceof TextRenderable) {
1094-
marker.content = evictedRowsNotice(shell.streamLogBase);
1110+
marker.content = content;
10951111
return;
10961112
}
10971113
const node = new TextRenderable(shell.renderer as CliRenderer, {
1098-
content: evictedRowsNotice(shell.streamLogBase),
1114+
content,
10991115
fg: UI.textDim,
11001116
});
11011117
evictionMarkers.add(node);
@@ -1183,17 +1199,7 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void {
11831199
shell.transcript.remove(evicted);
11841200
destroySubtree(evicted);
11851201
}
1186-
const marker = transcriptMarker(shell);
1187-
if (marker instanceof TextRenderable) {
1188-
marker.content = evictedRowsNotice(shell.streamLogBase);
1189-
} else {
1190-
const node = new TextRenderable(shell.renderer as CliRenderer, {
1191-
content: evictedRowsNotice(shell.streamLogBase),
1192-
fg: UI.textDim,
1193-
});
1194-
evictionMarkers.add(node);
1195-
shell.transcript.add(node, 1);
1196-
}
1202+
paintDroppedHistoryMarker(shell);
11971203
}
11981204

11991205
const index = shell.streamLog.length - 1;
@@ -1251,6 +1257,7 @@ export function clearTranscript(shell: AppShell): void {
12511257
shell.observe = null;
12521258
shell.parentStreamLog = null;
12531259
shell.parentStreamLogBase = null;
1260+
shell.parentUnloadedHistory = null;
12541261
let guard = 4;
12551262
while (guard-- > 0 && focusOwner(shell.focus) === "observe") {
12561263
shell.focus = popFocus(shell.focus);
@@ -1264,9 +1271,11 @@ export function clearTranscript(shell: AppShell): void {
12641271
}
12651272
shell.streamLog.length = 0;
12661273
shell.streamLogBase = 0;
1274+
shell.unloadedHistory = false;
12671275
shell.lineCount = 0;
12681276
shell.parentStreamLog = null;
12691277
shell.parentStreamLogBase = null;
1278+
shell.parentUnloadedHistory = null;
12701279
repaintTranscriptWindow(shell);
12711280
paintChrome(shell);
12721281
}
@@ -1350,9 +1359,11 @@ export function repaintTranscriptWindow(shell: AppShell): void {
13501359

13511360
// Rows evicted by the retention cap are gone for good, not just scrolled
13521361
// past — say so, or the boundary reads as the true start of history.
1353-
if (shell.streamLogBase > 0) {
1362+
// Truncated resume can also leave older history on disk without splicing
1363+
// any painted row; that path must not fake `streamLogBase`.
1364+
if (shell.streamLogBase > 0 || shell.unloadedHistory) {
13541365
const marker = new TextRenderable(shell.renderer as CliRenderer, {
1355-
content: evictedRowsNotice(shell.streamLogBase),
1366+
content: droppedHistoryNotice(shell),
13561367
fg: UI.textDim,
13571368
});
13581369
evictionMarkers.add(marker);

‎src/tui/shell/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ export function createAppShell(
438438
lineCount: 0,
439439
streamLog: [],
440440
streamLogBase: 0,
441+
unloadedHistory: false,
441442
agentVoices: new Set<string>(),
442443
baseTitle: title,
443444
modelLabel: null,
@@ -465,6 +466,7 @@ export function createAppShell(
465466
observe: null,
466467
parentStreamLog: null,
467468
parentStreamLogBase: null,
469+
parentUnloadedHistory: null,
468470
promptKillRing: emptyKillRing,
469471
pendingAttachments: [],
470472
sentHistory: createSentHistoryBrowse([]),

‎src/tui/shell/internals.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,13 @@ export interface AppShell {
421421
* array itself. Bumped by the number of rows dropped on each trim.
422422
*/
423423
streamLogBase: number;
424+
/**
425+
* Older history exists on disk but was not loaded into this window.
426+
* Independent of `streamLogBase`: a truncated resume that still fits
427+
* the retention cap splices nothing, so the first retained row stays at
428+
* absolute 0, but the dropped-rows marker still has to paint.
429+
*/
430+
unloadedHistory: boolean;
424431
/**
425432
* Distinct writers in the visible transcript. Rows carry a name and icon only
426433
* once this holds more than one, so identity appears where it disambiguates.
@@ -514,6 +521,8 @@ export interface AppShell {
514521
parentStreamLog: StreamRow[] | null;
515522
/** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */
516523
parentStreamLogBase: number | null;
524+
/** Saved `unloadedHistory` for the parent snapshot while observing. */
525+
parentUnloadedHistory: boolean | null;
517526
/**
518527
* Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it;
519528
* the text widget itself has no concept of a kill ring (see

‎src/tui/shell/observe.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function enterSubagentObserve(
2929
const seedLines = session.lines.slice();
3030
shell.parentStreamLog = shell.streamLog.slice();
3131
shell.parentStreamLogBase = shell.streamLogBase;
32+
shell.parentUnloadedHistory = shell.unloadedHistory;
3233
shell.observe = {
3334
sessionId: session.sessionId,
3435
agentId: session.agentId,
@@ -40,6 +41,7 @@ export function enterSubagentObserve(
4041
// of how far the parent's retention cap has already trimmed.
4142
shell.streamLog = seedLines;
4243
shell.streamLogBase = 0;
44+
shell.unloadedHistory = false;
4345
shell.lineCount = shell.streamLog.length;
4446
repaintTranscriptWindow(shell);
4547

@@ -72,8 +74,10 @@ export function leaveSubagentObserve(shell: AppShell): void {
7274
if (shell.parentStreamLog) {
7375
shell.streamLog = shell.parentStreamLog;
7476
shell.streamLogBase = shell.parentStreamLogBase ?? 0;
77+
shell.unloadedHistory = shell.parentUnloadedHistory ?? false;
7578
shell.parentStreamLog = null;
7679
shell.parentStreamLogBase = null;
80+
shell.parentUnloadedHistory = null;
7781
}
7882
shell.lineCount = shell.streamLog.length;
7983
repaintTranscriptWindow(shell);

0 commit comments

Comments
 (0)