feat(tui): enabled stable-prefix scrollback commits for streaming assistant output

- Added assistant-message completion state and stable-line count reporting.
- Added transcript stable-row aggregation and snapshot replay to keep unstable rows out of scrollback.
- Added TUI stable scrollback boundary APIs and stable-prefix commit path.
- Added regression tests proving unstable rows stay out of scrollback until stabilized.
This commit is contained in:
can1357
2026-06-04 16:00:50 +02:00
parent d7eee460d2
commit cbdff129b6
9 changed files with 297 additions and 20 deletions
+7 -2
View File
@@ -1,13 +1,18 @@
# Changelog
## [Unreleased]
### Added
- Added deferred session-title generation so greetings no longer become the session title. A first user message that is only a greeting / acknowledgement / filler ("hi", "thanks", "ok", a bare number, emoji-only, etc.) is now detected deterministically and skips titling entirely — no title model is invoked. Title generation then retries on each subsequent user message while the session stays unnamed, so the title is deduced from the first message that actually describes work. A capable online title model may additionally answer `none` to decline a non-greeting taskless message (normalized to "no title").
### Changed
- Changed the Agent Control Center's new-agent description field to use the multiline TUI editor, with Enter inserting lines and Ctrl+Enter generating the spec.
- Refreshed the Ctrl+R history search overlay: the selected row now renders as a full-width `selectedBg` highlight bar, matched query tokens are highlighted in the accent color, each result shows a right-aligned relative timestamp, and the panel gained an icon'd accent title plus a two-tone keyhint footer. The selector also gained PageUp/PageDown (via the configurable `tui.select.pageUp`/`pageDown` keybindings) and Home/End navigation.
### Fixed
- Fixed transcript scrollback stability on terminals with eager erase risk so completed assistant messages remain stable while new streaming lines are rendering
- Fixed Ctrl+R history search results to remain globally sorted by prompt recency after merging FTS prefix matches with substring fallback matches.
- Fixed Exa web search with no stored or environment credential to use the public Exa MCP fallback again, preserving the auth storage → `EXA_API_KEY` → `mcp.exa.ai` resolution order ([#1860](https://github.com/can1357/oh-my-pi/issues/1860)).
- Fixed `provider.appendOnlyContext: "auto"` staying inactive for Xiaomi Token Plan/SGLang endpoints, preserving prefix-cache hits without forcing append-only mode globally ([#1851](https://github.com/can1357/oh-my-pi/issues/1851)).
@@ -9310,4 +9315,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -17,6 +17,7 @@ export class AssistantMessageComponent extends Container {
#usageInfo?: Usage;
#convertedKittyImages = new Map<string, ImageContent>();
#kittyConversionsInFlight = new Set<string>();
#complete: boolean;
constructor(
message?: AssistantMessage,
@@ -26,6 +27,7 @@ export class AssistantMessageComponent extends Container {
private readonly imageBudget?: ImageBudget,
) {
super();
this.#complete = message !== undefined;
// Container for text/thinking content
this.#contentContainer = new Container();
@@ -36,6 +38,15 @@ export class AssistantMessageComponent extends Container {
}
}
setComplete(): void {
this.#complete = true;
}
getStableLineCount(width: number): number {
if (!this.#complete || this.#kittyConversionsInFlight.size > 0) return 0;
return this.render(width).length;
}
override invalidate(): void {
super.invalidate();
if (this.#lastMessage) {
@@ -38,6 +38,9 @@ export class TranscriptContainer extends Container {
// Bumped to invalidate every block's snapshot at once; a snapshot is only
// honored when its stored generation still matches.
#generation = 0;
#lastRenderWidth = 0;
#lastChildLineCounts: number[] = [];
#lastChildStableCounts: number[] = [];
override invalidate(): void {
// A theme/global invalidation forces a full recompute on the rebuild that
@@ -63,29 +66,52 @@ export class TranscriptContainer extends Container {
override render(width: number): string[] {
width = Math.max(1, width);
if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
const lines: string[] = [];
const counts: number[] = [];
const stableCounts: number[] = [];
const liveIndex = this.children.length - 1;
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i]! as Component & SnapshotCarrier;
if (i !== liveIndex) {
let rendered: string[] | undefined;
if (TERMINAL.eagerEraseScrollbackRisk && i !== liveIndex) {
const snapshot = child[kSnapshot];
// Replay the block's last render from while it was live. A stale
// generation (post-thaw) or width mismatch (resize in flight, an
// explicit rebuild that reconciles history anyway) recomputes instead.
if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
lines.push(...snapshot.lines);
continue;
rendered = snapshot.lines;
}
}
const rendered = child.render(width);
// Cache every block's latest render. While a block is live this keeps its
// snapshot current; the frame it stops being live the cache already holds
// its final live render, so nothing recomputes underneath it.
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
rendered ??= child.render(width);
if (TERMINAL.eagerEraseScrollbackRisk) {
// Cache every block's latest render. While a block is live this keeps
// its snapshot current; the frame it stops being live the cache already
// holds its final live render, so nothing recomputes underneath it.
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
}
const stable = i === liveIndex ? (child.getStableLineCount?.(width) ?? 0) : rendered.length;
counts.push(rendered.length);
stableCounts.push(Math.max(0, Math.min(rendered.length, stable)));
lines.push(...rendered);
}
this.#lastRenderWidth = width;
this.#lastChildLineCounts = counts;
this.#lastChildStableCounts = stableCounts;
return lines;
}
getStableLineCount(width: number): number {
if (this.#lastRenderWidth !== Math.max(1, width) || this.#lastChildLineCounts.length !== this.children.length) {
return 0;
}
let stable = 0;
for (let i = 0; i < this.#lastChildLineCounts.length; i++) {
const length = this.#lastChildLineCounts[i] ?? 0;
const childStable = this.#lastChildStableCounts[i] ?? 0;
stable += childStable;
if (childStable < length) break;
}
return stable;
}
}
@@ -465,6 +465,7 @@ export class EventController {
}
this.#lastAssistantComponent = this.ctx.streamingComponent;
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
this.#lastAssistantComponent.setComplete();
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.statusLine.invalidate();
@@ -399,6 +399,9 @@ export class InteractiveMode implements InteractiveModeContext {
// unless the user opts in, and never emits raw escapes on other terminals.
setTerminalTextSizing(settings.get("tui.textSizing") && TERMINAL.textSizing);
this.chatContainer = new TranscriptContainer();
if (TERMINAL.eagerEraseScrollbackRisk) {
this.ui.setNativeScrollbackStableComponent(this.chatContainer);
}
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.todoContainer = new Container();
@@ -19,6 +19,18 @@ class MutableBlock implements Component {
}
}
class StableBlock extends MutableBlock {
#stableLineCount = 0;
setStableLineCount(count: number): void {
this.#stableLineCount = count;
}
getStableLineCount(): number {
return this.#stableLineCount;
}
}
const riskFlag = TERMINAL as unknown as { eagerEraseScrollbackRisk: boolean };
const original = riskFlag.eagerEraseScrollbackRisk;
@@ -53,6 +65,24 @@ describe("TranscriptContainer", () => {
expect(container.render(40)).toEqual(["a2", "b2"]);
});
it("reports frozen blocks plus the live block's immutable prefix", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const frozen = new MutableBlock(["a1", "a2"]);
const live = new StableBlock(["b1", "b2", "b3"]);
live.setStableLineCount(1);
container.addChild(frozen);
container.addChild(live);
expect(container.render(40)).toEqual(["a1", "a2", "b1", "b2", "b3"]);
expect(container.getStableLineCount(40)).toBe(3);
frozen.set(["a-mutated"]);
live.setStableLineCount(3);
expect(container.render(40)).toEqual(["a1", "a2", "b1", "b2", "b3"]);
expect(container.getStableLineCount(40)).toBe(5);
});
it("thaw() reconciles frozen blocks to their current state", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
+9 -1
View File
@@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
### Added
- Added optional `Component#getStableLineCount(width)` method so components can report how many leading rendered rows are immutable
- Added `TUI#setNativeScrollbackStableComponent(component)` to define the native-scrollback stability boundary for stability-aware rendering
### Fixed
- Kept streaming lines outside the declared stable prefix out of native scrollback so only newly stable output is committed without clearing existing terminal history
## [15.9.0] - 2026-06-04
@@ -1017,4 +1025,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
### Fixed
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
+129 -8
View File
@@ -99,6 +99,13 @@ export interface Component {
*/
render(width: number): string[];
/**
* Number of leading rendered rows that are immutable and may be appended to
* native scrollback. Components that omit this are treated as unstable by
* stability-aware containers.
*/
getStableLineCount?(width: number): number;
/**
* Optional handler for keyboard input when component has focus
*/
@@ -274,20 +281,25 @@ export interface OverlayHandle {
*/
export class Container implements Component {
children: Component[] = [];
#lastRenderWidth = 0;
#lastChildLineCounts: number[] = [];
addChild(component: Component): void {
this.children.push(component);
this.#lastChildLineCounts = [];
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.#lastChildLineCounts = [];
}
}
clear(): void {
this.children = [];
this.#lastChildLineCounts = [];
}
invalidate(): void {
@@ -299,11 +311,31 @@ export class Container implements Component {
render(width: number): string[] {
width = Math.max(1, width);
const lines: string[] = [];
const counts: number[] = [];
for (const child of this.children) {
lines.push(...child.render(width));
const rendered = child.render(width);
counts.push(rendered.length);
lines.push(...rendered);
}
this.#lastRenderWidth = width;
this.#lastChildLineCounts = counts;
return lines;
}
getRenderedChildOffset(component: Component, width: number): { offset: number; length: number } | null {
if (this.#lastRenderWidth !== Math.max(1, width) || this.#lastChildLineCounts.length !== this.children.length) {
return null;
}
let offset = 0;
for (let i = 0; i < this.children.length; i++) {
const length = this.#lastChildLineCounts[i] ?? 0;
if (this.children[i] === component) {
return { offset, length };
}
offset += length;
}
return null;
}
}
/**
@@ -318,8 +350,9 @@ export class Container implements Component {
* wrapped at the old size — clear viewport and scrollback so it rewraps at the
* new geometry. Also flushes deferred content-only rewrites.
* - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
* is set, emit those tail rows as scrollback growth first so streaming
* output reaches terminal history before the corrected viewport is drawn.
* is set, emit those tail rows as scrollback growth first.
* - `stablePrefixCommit`: append newly stable rows to native scrollback without
* clearing existing history, then repaint the live viewport.
* - `deferredShrink`: pure content shrink would re-expose rows already in
* native history. Keep row indices stable with blank tail padding, repaint
* only the viewport, and defer the real shorter replay to a checkpoint.
@@ -335,6 +368,7 @@ type RenderIntent =
| { kind: "historyRebuild" }
| { kind: "overlayRebuild" }
| { kind: "viewportRepaint"; appendFrom?: number }
| { kind: "stablePrefixCommit"; target: number }
| { kind: "deferredShrink"; paddedLength: number }
| { kind: "deferredMutation" }
| { kind: "shrink" }
@@ -406,6 +440,8 @@ export class TUI extends Container {
// between the viewport and scrollback, so the previous frame no longer
// describes the screen. Tracking only the dimension delta misses this.
#resizeEventPending = false;
#nativeScrollbackStableComponent: Component | undefined;
#stopped = false;
// Overlay stack for modal components rendered on top of base content
@@ -429,6 +465,14 @@ export class TUI extends Container {
}
}
/**
* Limit native scrollback growth to the immutable prefix reported by this
* child. Rows after that prefix are treated as live viewport-only content.
*/
setNativeScrollbackStableComponent(component: Component | undefined): void {
this.#nativeScrollbackStableComponent = component;
}
get fullRedraws(): number {
return this.#fullRedrawCount;
}
@@ -1368,6 +1412,11 @@ export class TUI extends Container {
this.#extractCursorPosition(baseLines, height);
baseLines = this.#fitLinesToWidth(this.#applyLineResets(baseLines), width);
}
const stableScrollbackBoundary =
this.#nativeScrollbackStableComponent !== undefined && visibleOverlayComponents.length === 0;
const stableLineCount = stableScrollbackBoundary
? this.#getNativeScrollbackStableLineCount(width, lines.length)
: lines.length;
// 2. Capture transition + pre-render state before any emitter runs.
const prevViewportTop = this.#viewportTopRow;
@@ -1393,6 +1442,8 @@ export class TUI extends Container {
heightChanged,
prevViewportTop,
height,
stableLineCount,
stableScrollbackBoundary,
visibleOverlayComponents.length > 0,
overlayVisibilityReduced,
allowUnknownViewportMutation,
@@ -1454,6 +1505,10 @@ export class TUI extends Container {
}
this.#emitViewportRepaint(lines, width, height, cursorPos);
return;
case "stablePrefixCommit":
this.#emitStablePrefixCommit(lines, width, height, intent.target);
this.#emitViewportRepaint(lines, width, height, cursorPos);
return;
case "deferredMutation":
return;
case "deferredShrink":
@@ -1497,6 +1552,8 @@ export class TUI extends Container {
heightChanged: boolean,
prevViewportTop: number,
height: number,
stableLineCount: number,
stableScrollbackBoundary: boolean,
hasVisibleOverlay: boolean,
overlayVisibilityReduced: boolean,
allowUnknownViewportMutation: boolean,
@@ -1549,6 +1606,9 @@ export class TUI extends Container {
// stale high-water rows in native scrollback and duplicates the new tail above
// the viewport.
const naturalViewportTop = Math.max(0, newLines.length - height);
const overflowRows = Math.max(0, newLines.length - height);
const stableScrollbackTarget = Math.min(stableLineCount, overflowRows);
const hasUnstableOverflow = stableScrollbackTarget < overflowRows;
if (
diff.firstChanged !== -1 &&
newLines.length < this.#previousLines.length &&
@@ -1694,6 +1754,20 @@ export class TUI extends Container {
}
if (diff.firstChanged === -1) {
if (
stableScrollbackBoundary &&
stableScrollbackTarget > this.#scrollbackHighWater &&
!isMultiplexerSession()
) {
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
this.#markNativeScrollbackDirty();
return this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)
? { kind: "deferredMutation" }
: { kind: "viewportRepaint" };
}
return { kind: "stablePrefixCommit", target: stableScrollbackTarget };
}
// A geometry change reflows the terminal's own buffer, moving rows between
// the viewport and native scrollback. When content overflows and the
// viewport position is unobservable (POSIX/ED3-risk/Windows), an in-place
@@ -1742,6 +1816,19 @@ export class TUI extends Container {
const contentGrew = newLines.length > this.#previousLines.length;
const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
if (stableScrollbackBoundary && stableScrollbackTarget > this.#scrollbackHighWater && !isMultiplexerSession()) {
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
this.#markNativeScrollbackDirty();
return this.#nativeViewportIsKnownScrolled(nativeViewportAtBottom)
? { kind: "deferredMutation" }
: { kind: "viewportRepaint" };
}
return { kind: "stablePrefixCommit", target: stableScrollbackTarget };
}
if (stableScrollbackBoundary && hasUnstableOverflow && contentGrew && !isMultiplexerSession()) {
return { kind: "viewportRepaint" };
}
if (pureAppend && contentGrew && this.#previousLines.length > height && !isMultiplexerSession()) {
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) {
@@ -1877,9 +1964,8 @@ export class TUI extends Container {
// Offscreen edit: repainting only the viewport leaves native history stale
// while the user is bottom-anchored. Rebuild whenever replay is safe. If
// replay is not safe, keep the viewport stable, mark history dirty, and only
// scroll a clean appended tail so newly streamed rows remain reachable until
// the next checkpoint rebuild.
// replay is not safe, mark history dirty and repaint only the live viewport;
// stability-aware callers have already appended any newly immutable prefix.
if (diff.firstChanged < prevViewportTop) {
const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
const cleanTailAppend =
@@ -1891,7 +1977,10 @@ export class TUI extends Container {
return { kind: "historyRebuild" };
}
this.#markNativeScrollbackDirty();
return { kind: "viewportRepaint", appendFrom: cleanTailAppend ? this.#previousLines.length : undefined };
return {
kind: "viewportRepaint",
appendFrom: cleanTailAppend ? this.#previousLines.length : undefined,
};
}
if (forceViewportRepaint) {
@@ -1912,6 +2001,15 @@ export class TUI extends Container {
};
}
#getNativeScrollbackStableLineCount(width: number, totalLines: number): number {
const component = this.#nativeScrollbackStableComponent;
if (!component) return totalLines;
const offset = this.getRenderedChildOffset(component, width);
if (!offset) return 0;
const childStable = component.getStableLineCount?.(width) ?? 0;
return Math.max(0, Math.min(totalLines, offset.offset + Math.min(offset.length, childStable)));
}
/**
* Two-pointer diff over `#previousLines` and `newLines`. `firstChanged` is
* `-1` when the two are identical; otherwise it is the first differing
@@ -1921,6 +2019,7 @@ export class TUI extends Container {
#diffLines(newLines: string[]): { firstChanged: number; lastChanged: number; appendedLines: boolean } {
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.#previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
@@ -2243,6 +2342,26 @@ export class TUI extends Container {
}
}
/**
* Append newly immutable rows into native scrollback without clearing saved
* lines. The replay starts at the previously committed prefix and writes just
* enough rows for the terminal's normal linefeed scrolling to push `target`
* rows into history; the caller immediately repaints the true live viewport.
*/
#emitStablePrefixCommit(lines: string[], width: number, height: number, target: number): void {
const start = this.#scrollbackHighWater;
if (target <= start) return;
const replayEnd = Math.min(lines.length, target + height);
let buffer = `${this.#paintBeginSequence}\x1b[2J\x1b[H`;
for (let i = start; i < replayEnd; i++) {
if (i > start) buffer += "\r\n";
buffer += this.#fitLineToWidth(lines[i], width);
}
buffer += this.#paintEndSequence;
this.terminal.write(buffer);
this.#scrollbackHighWater = target;
}
/**
* Trailing-shrink: prior content shared a prefix with the new content; the
* extra rows below the new tail need to be cleared without scrolling. Falls
@@ -2445,7 +2564,9 @@ export class TUI extends Container {
? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
: intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
? `${intent.kind}(appendFrom=${intent.appendFrom})`
: intent.kind;
: intent.kind === "stablePrefixCommit"
? `${intent.kind}(target=${intent.target})`
: intent.kind;
const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
fs.appendFileSync(getDebugLogPath(), msg);
}
@@ -27,6 +27,18 @@ class MutableLinesComponent implements Component {
}
}
class StablePrefixLinesComponent extends MutableLinesComponent {
#stableLineCount = 0;
setStableLineCount(count: number): void {
this.#stableLineCount = count;
}
getStableLineCount(): number {
return this.#stableLineCount;
}
}
class WrappingLinesComponent implements Component {
#lines: string[];
@@ -2314,6 +2326,66 @@ describe("TUI terminal-state regressions", () => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
}
});
it("keeps streaming rows out of native scrollback until the prefix is stable", async () => {
await withTerminalRisk(true, async () => {
const term = new UnknownViewportTerminal(32, 5, 200);
const writes = captureWrites(term);
const tui = new TUI(term);
const transcript = new StablePrefixLinesComponent(["thinking 0"]);
const footer = new MutableLinesComponent(["status", "prompt>"]);
tui.setNativeScrollbackStableComponent(transcript);
tui.addChild(transcript);
tui.addChild(footer);
try {
tui.start();
tui.setEagerNativeScrollbackRebuild(true);
await settle(term);
for (let i = 0; i < 8; i++) {
transcript.setLines([`thinking ${i + 1}`, ...rows("token-", i + 1)]);
tui.requestRender();
await settle(term);
}
const beforePosition = term.getBufferPosition();
expect(
term
.getScrollBuffer()
.slice(0, beforePosition.baseY)
.map(line => line.trimEnd()),
).toEqual([]);
expect(visible(term).map(line => line.trim())).toEqual([
"token-5",
"token-6",
"token-7",
"status",
"prompt>",
]);
transcript.setStableLineCount(9);
tui.requestRender();
await settle(term);
const position = term.getBufferPosition();
const committed = term
.getScrollBuffer()
.slice(0, position.baseY)
.map(line => line.trimEnd());
expect(committed).toEqual(["thinking 8", "token-0", "token-1", "token-2", "token-3", "token-4"]);
expect(visible(term).map(line => line.trim())).toEqual([
"token-5",
"token-6",
"token-7",
"status",
"prompt>",
]);
expect(writes.join("")).not.toContain("\x1b[3J");
} finally {
tui.stop();
}
});
});
it("keeps a scrolled-up reader anchored while streaming inserts arrive on POSIX (unknown viewport)", async () => {
// POSIX terminals cannot report scrollback position, so isNativeViewportAtBottom()