Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/tui/long-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,19 @@ export function trimRetainedLog<T>(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)";
}
71 changes: 68 additions & 3 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down
57 changes: 34 additions & 23 deletions src/tui/shell/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/tui/shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export function createAppShell(
lineCount: 0,
streamLog: [],
streamLogBase: 0,
unloadedHistory: false,
agentVoices: new Set<string>(),
baseTitle: title,
modelLabel: null,
Expand Down Expand Up @@ -465,6 +466,7 @@ export function createAppShell(
observe: null,
parentStreamLog: null,
parentStreamLogBase: null,
parentUnloadedHistory: null,
promptKillRing: emptyKillRing,
pendingAttachments: [],
sentHistory: createSentHistoryBrowse([]),
Expand Down
9 changes: 9 additions & 0 deletions src/tui/shell/internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/tui/shell/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
Loading