From e51bd44eedf6eb438a1168453d015f8b269fd16a Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 1 Oct 2026 11:16:40 +0800 Subject: [PATCH] fix(tui): keep regular-mode history stable during live runs Regular mode wrote rows into native scrollback as soon as they scrolled off screen. When such a row later changed (a parallel tool finishing, a streamed table widening, a list turning loose, a turn growing past the projection fold, the welcome status badge flipping, or a stopped prompt gaining its cancelled marker), the renderer cleared scrollback and replayed the whole session from the welcome logo. Only final rows now reach native history: - The transcript reports how many leading rows later updates cannot change, including the committed blocks of a streaming Markdown reply. - The chat layout keeps rows that may still change within one screen above the footer, showing their latest rows under a one-line notice when they do not fit. - The regular-mode projection only appends; established turns are not re-folded or dropped. - The welcome banner above a conversation is static; account notices that need action appear above the Composer. - Stopping a run no longer marks its delivered prompt as cancelled; the marker stays reserved for prompts returned to the Composer. To keep long sessions responsive, the transcript drops final rows far above the screen and the engine rebases its retained state (takeDiscardedRows) instead of rewriting history. --- docs/tui-capabilities.md | 14 + packages/tui/src/tui/app.ts | 5 +- .../projection/turn-live-projection.ts | 9 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 14 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 16 + .../tui/src/tui/engine/components/markdown.ts | 9 + .../tui/src/tui/engine/tui-main-screen.ts | 20 ++ packages/tui/src/tui/engine/tui.ts | 9 + packages/tui/src/tui/shell/chat-layout.ts | 113 +++++++ packages/tui/src/tui/shell/frame.ts | 2 +- packages/tui/src/tui/shell/surface-host.ts | 4 + .../tui/src/tui/shell/welcome/component.ts | 87 +++-- .../src/tui/transcript/projection-window.ts | 62 +++- packages/tui/src/tui/transcript/view.ts | 133 +++++++- packages/tui/test/unit/tui-app.test.ts | 298 ++++++++++++++++++ .../test/unit/tui-engine-local-deltas.test.ts | 102 ++++++ .../tui/test/unit/tui-transcript-view.test.ts | 44 +++ 17 files changed, 887 insertions(+), 54 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 353d8b112..e536348be 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -222,6 +222,20 @@ and image layout changes also retain the existing reconstruction behavior. A reconstruction clears earlier shell scrollback and can reset the host's scroll position; ordinary updates keep native scrolling and selection behavior. +During a run, regular mode lets a row scroll into native history only when later +updates cannot change it. These rows include finished steps, earlier blocks of a +streaming reply, and the welcome banner. The welcome banner omits live account +and runtime status in a conversation; account notices that need action appear +above the Composer. A running tool, a block that is still streaming (such as a +growing table or list), and the last tool step stay on screen until they are +final. If they do not fit, the screen shows their latest rows under a +`↑ N more lines above · still updating` line, and the full rows enter history in +order once final. Long turns extend their visible steps instead of re-folding or +dropping earlier steps, so ordinary progress never needs reconstruction. To keep +long sessions responsive, MCode stops retaining final rows that are far above +the screen; they remain in the terminal's own scrollback. A later reconstruction, +such as after a resize, redraws only the retained recent rows. + Rewind and Fork history-loading hints disappear as soon as their lists are ready. Returning from a cancelled operation must not leave a stale loading message in the Composer. Rewind displays its completed result after a successful operation. diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index d481f8362..7928402a6 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -82,6 +82,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { const transcriptView = new TranscriptView(transcriptVisibility, { displayModes: new TranscriptPresentationController(), workspaceDir: options.workspaceDir, + appendOnly: () => renderer.mode === 'regular', }); const editor = createEditor(tui, workspaceRoots, options.runtime); let draftLifecycle: TuiDraftLifecycle | undefined; @@ -400,8 +401,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (!userCell) return; // runtime-owned or retry-continuation turns // A second Esc in the settle window can reach the runtime-owned branch // with the same turnId; track restored turns so a repeat concatenates - // nothing. (Cell status cannot key this: abort-time markTurn also - // cancels still-pending user cells before the first restore.) + // nothing. (Track by turn rather than by cell status, which the runtime + // echo and the restore path both update.) if (restoredAbortTurnIds.has(turnId)) return; const hasIrreversibleActivity = cells.some((cell) => { if (cell.id === `user:${turnId}`) return false; diff --git a/packages/tui/src/tui/controller/projection/turn-live-projection.ts b/packages/tui/src/tui/controller/projection/turn-live-projection.ts index 5150b781f..c65370a73 100644 --- a/packages/tui/src/tui/controller/projection/turn-live-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-live-projection.ts @@ -111,7 +111,14 @@ export class TuiLiveTurnProjection { const turnCells = this.transcript.snapshot().filter((cell) => cell.turnId === turnId); for (const cell of turnCells) { if (cell.status !== 'pending' && cell.status !== 'running') continue; - this.transcript.upsert({ id: cell.id, status, updatedAtMs: timestamp }); + // A submitted prompt was delivered even when its run stops. Its cancelled + // marker means "returned to the Composer" and is set only by that restore + // path; marking it here would rewrite a prompt already in native history. + const cellStatus = + status === 'cancelled' && cell.kind === 'user' && cell.userPresentation !== 'pending-steer' + ? 'succeeded' + : status; + this.transcript.upsert({ id: cell.id, status: cellStatus, updatedAtMs: timestamp }); } const hasAssistant = turnCells.some( (cell) => cell.kind === 'assistant' || cell.kind === 'assistant-preamble', diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f29c7ebb2..8f3529b46 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -42,8 +42,8 @@ }, { "path": "components/markdown.ts", - "currentSha256": "59eaf8bc4ef1c3a900892248fc3ab9a1483d18d84e63a888711c289f2bdf6e8d", - "changeIds": ["L005", "L007", "L013", "L029", "L026"], + "currentSha256": "1d32aa3553e99178db5a96130f9a18c1add235d5d70100a8cd2d47c4189ddd1b", + "changeIds": ["L005", "L007", "L013", "L029", "L026", "L045"], "reason": "Keep strict TypeScript fixes and product Markdown hooks, plus Pi's post-baseline wrapped-table style restoration. 链接标签使用独立样式上下文,防止外层正文或标题颜色覆盖链接颜色。 稳定 Markdown 块缓存", "behaviorImpact": "MCode keeps its compact code presentation without reimplementing Pi Markdown or leaking wrapped link styles into table borders and adjacent cells. 链接保留嵌套强调与行内代码,并在结束后恢复外层样式。 保留两个尾部块并对引用回退全文。" }, @@ -163,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f57eab79552763ae29bc39e2ced4c7905eb44fa163f538ba3ff6000136e0f493", + "currentSha256": "f38671720867d2baa633a0e8ca826bd36474edefc281efe6b6e6a2f81e7d3ddb", "changeIds": [ "L005", "L017", @@ -174,7 +174,8 @@ "L038", "L039", "L041", - "L044" + "L044", + "L046" ], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure.", @@ -182,7 +183,7 @@ }, { "path": "tui.ts", - "currentSha256": "6bfb2095855d00532bfc96a4be57e0c974e0d8d06137bbba1e865986483e7005", + "currentSha256": "a661a3e326e1f10c9567b99c23fec5269849856a54417439597801f75ec077f8", "changeIds": [ "L005", "L015", @@ -192,7 +193,8 @@ "L031", "L040", "L041", - "L044" + "L044", + "L046" ], "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure.", "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。 Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output." diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 3a0ad7062..7a5e66727 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -122,6 +122,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Minimal difference: when a shorter document would move the viewport origin backwards, or changed visible text is already in scrollback, clear and replay the complete current projection, except for addressable text-only shrink covered by L038. Compare changed historical rows without terminal sequences so style-only updates preserve scrollback. Other updates retain differential rendering and genuine resize retains the existing delayed history replay. - Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. L038 keeps freed visible rows temporarily blank instead of reconstructing unchanged history. - Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Queue lifecycle tests replay bracketed CJK paste, Alt+Enter, auto-drain, and history refresh through Ghostty; equal-height and growing historical edits are also covered by xterm. Virtual terminals do not establish native Windows Terminal or iTerm2 touchpad acceptance. +- Product boundary: the regular chat layout keeps rows that may still change out of native history (see L045), so this reconstruction remains for resize and for content that changes after being reported final. - Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior. ## L036: Unframed multiline paste chunks @@ -193,3 +194,18 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: terminal-output regressions cover held/partial writes, input dispatch, control ordering, transient and permanent failures, frame coalescing, final transcript preservation, keyboard input draining and editor handoff/shutdown races; application tests cover shutdown/suspend drains. A local macOS PTY with 10,000 synthetic history rows and a paused consumer no longer blocks the input loop while reconstructing history. - Boundary: history reconstruction still transmits the complete ordered document. A stalled connection can delay visible output; it no longer synchronously stalls the JavaScript event loop. Windows and non-TTY output retain their existing writer. Native remote SSH and native Windows acceptance remain separate. - Removal condition: the upstream terminal writer provides ordered asynchronous POSIX TTY writes and its render scheduler observes output backpressure. + +## L045: Report committed Markdown lines + +- Product contract: in Regular mode a streaming reply enters native scrollback only through rows that appended text cannot change, so a table, list or other block that is still growing never rewrites history. +- Minimal difference: `components/markdown.ts` exposes `getStableLineCount()`, the number of leading lines from the latest render that come from the blocks already committed by the L026 streaming cache. Rendering output is unchanged. +- Evidence: `tui-app.test.ts` regular-mode native history cases cover widening tables, lists that become loose, long prose that streams into history during a turn, and exact history after the run. +- Removal condition: the selected Pi baseline exposes an equivalent committed-prefix contract for streaming Markdown. + +## L046: Discard final rows already in native history + +- Product contract: a long Regular-mode session keeps frame cost bounded without rewriting native scrollback. +- Minimal difference: `Component.takeDiscardedRows()` lets the root report leading rows it dropped since the last frame. When those rows are all above the previous viewport, `tui-main-screen.ts` drops them from its retained lines and rebases its viewport and cursor rows before diffing. Other cases keep the existing differential and reconstruction paths. +- Product boundary: the MCode Transcript only drops whole units of final rows far above the screen, so the remaining output is an exact suffix of the previous output. A later reconstruction, such as a resize, replays only the retained rows. +- Evidence: `tui-engine-local-deltas.test.ts` retained-document cases assert no scrollback erase and exact native history after trimming (failing without the rebase), and exact history without stale rows when a root reports too few or too many discarded rows. +- Removal condition: the selected Pi baseline supports discarding a committed document prefix. diff --git a/packages/tui/src/tui/engine/components/markdown.ts b/packages/tui/src/tui/engine/components/markdown.ts index 351694b31..b1d004a60 100644 --- a/packages/tui/src/tui/engine/components/markdown.ts +++ b/packages/tui/src/tui/engine/components/markdown.ts @@ -273,6 +273,15 @@ export class Markdown implements Component { this.cachedLines = undefined; } + /** + * Number of leading lines from the latest render() that appending text cannot + * change. They come from blocks the streaming cache has already committed. + */ + getStableLineCount(): number { + if (!this.cachedLines || this.cachedLines.length === 0) return 0; + return Math.min(this.cachedLines.length, this.paddingY + this.stableLines.length); + } + invalidate(): void { this.stableSource = ""; this.stableLines = []; diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 1c590003a..3d752ae62 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -307,6 +307,26 @@ export class TuiMainScreen extends TuiBase implements TUI { // Render all components to get new lines. Strip OSC 133 zone sentinels before the // differential compare so they never enter previousLines or any terminal write. let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + // A root may drop leading rows that already sit in native scrollback. Rebase the + // retained state so later diffs address the shorter document without a replay. + const discardedRows = this.children.length === 1 + ? Math.max(0, Math.floor(this.children[0]?.takeDiscardedRows?.() ?? 0)) + : 0; + // A miscount needs no separate check: the history comparison below sees the + // misaligned rows and reconstructs the session instead of splicing them. + if ( + discardedRows > 0 && discardedRows <= prevViewportTop && + !widthChanged && !heightChanged && !this.historyReplayPending + ) { + this.previousLines = this.previousLines.slice(discardedRows); + this.previousViewportTop -= discardedRows; + prevViewportTop -= discardedRows; + viewportTop -= discardedRows; + this.cursorRow = Math.max(0, this.cursorRow - discardedRows); + this.hardwareCursorRow -= discardedRows; + hardwareCursorRow -= discardedRows; + this.maxLinesRendered = Math.max(0, this.maxLinesRendered - discardedRows); + } const viewportLayouts = this.children.map((component) => ({ component, key: component.getViewportLayoutKey?.(), diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index d3894f93c..a7bf70fb3 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -34,6 +34,15 @@ export interface Component { */ getViewportLayoutKey?(): string | undefined; + /** + * Regular mode only: report and reset how many leading rows the component dropped + * since the renderer last asked. Each dropped row must equal the leading row of the + * previous output and already be in native scrollback. The renderer rebases its state + * instead of rewriting history, which bounds the retained document. If the remaining + * history does not line up, its ordinary history comparison reconstructs the session. + */ + takeDiscardedRows?(): number; + /** * Optional handler for keyboard input when component has focus */ diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index a8c4df225..4cebe1c86 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -7,6 +7,7 @@ import { } from '../engine/public.js'; import type { Component } from '../rendering/component.js'; import { stripAnsi } from '../rendering/text.js'; +import { tuiChalk as chalk, tuiColors as colors } from '../theme/runtime.js'; import { resolveTuiLayoutPolicy } from './layout-policy.js'; const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; @@ -36,12 +37,35 @@ interface ActiveAwareComponent extends Component { isActive(): boolean; } +/** A transcript whose leading rows can be marked final for native scrollback. */ +interface StableRowsAwareComponent extends Component { + getStableRowCount(): number; + /** Whether leading final rows were dropped; the welcome above them is then gone too. */ + isTrimmed?(): boolean; + takeDiscardedRows?(): number; +} + +/** A welcome that renders a static banner and a separate live notice above a conversation. */ +interface PreludeAwareComponent extends Component { + renderPrelude(width: number): string[]; + renderPreludeNotice(width: number): string[]; +} + interface MouseAwareComponent extends Component { handleMouse(event: TuiMouseEvent): boolean; } export class TuiChatLayout implements Component { private viewportLayoutKey: string | undefined; + /** Rows above the transcript in the latest regular conversation frame. */ + private documentPreludeRows = 0; + private discardedRows = 0; + + takeDiscardedRows(): number { + const rows = this.discardedRows; + this.discardedRows = 0; + return rows; + } getViewportLayoutKey(): string | undefined { return this.viewportLayoutKey; @@ -275,6 +299,17 @@ export class TuiChatLayout implements Component { return this.fitDocumentFrame([welcome, ...tailEntries]); } + if (this.viewport() === 'document') { + return this.renderDocumentConversation(frame, viewportLayout, [ + interaction, + goal, + followUp, + tasks, + activity, + composer, + status, + ]); + } const footerEntries = interactionActive && this.viewport() === 'fixed' ? [interaction, activity, composer, status] @@ -297,6 +332,64 @@ export class TuiChatLayout implements Component { return this.fitDocumentFrame([...bodyEntries, ...footerEntries]); } + /** + * Regular mode writes rows that scroll off the screen into native terminal + * scrollback, which cannot be edited afterward. Only final rows may get there: + * the static welcome banner and transcript rows that later updates cannot + * change. Rows that may still change stay within the screen above the footer; + * when they do not fit, the screen shows their latest rows and a one-line + * notice, and the hidden rows enter history once they are final. + */ + private renderDocumentConversation( + frame: ChatFrameLayout, + viewportLayout: readonly unknown[], + footerSections: readonly (readonly string[])[], + ): string[] { + const insetPart = (lines: readonly string[]) => insetLines(lines, frame.horizontalPadding); + const welcomePart = this.parts.welcome; + const welcome = isPreludeAwareComponent(welcomePart) + ? insetPart(welcomePart.renderPrelude(frame.contentWidth)) + : insetPart(welcomePart.render(frame.contentWidth)); + const accountNotice = isPreludeAwareComponent(welcomePart) + ? insetPart(welcomePart.renderPreludeNotice(frame.contentWidth)) + : []; + const transcriptPart = this.parts.transcript; + const transcript = insetPart(transcriptPart.render(frame.contentWidth)); + const stableAware = isStableRowsAwareComponent(transcriptPart); + const transcriptFinalRows = stableAware + ? Math.max(0, Math.min(transcript.length, transcriptPart.getStableRowCount())) + : transcript.length; + // Once the transcript drops final rows, the banner above them is long in history. + const trimmed = stableAware && transcriptPart.isTrimmed?.() === true; + const transcriptDiscarded = stableAware ? (transcriptPart.takeDiscardedRows?.() ?? 0) : 0; + if (transcriptDiscarded > 0) { + this.discardedRows += transcriptDiscarded + (trimmed ? this.documentPreludeRows : 0); + } + const shownWelcome = trimmed ? [] : welcome; + this.viewportLayoutKey = JSON.stringify([ + ...viewportLayout, + shownWelcome.length, + accountNotice.length, + ]); + const prelude = joinWelcomeAndTranscript(shownWelcome, transcript); + this.documentPreludeRows = prelude.length - transcript.length; + const body = [...prelude, ...(transcript.length > 0 ? [''] : [])]; + const finalRows = prelude.length - transcript.length + transcriptFinalRows; + const [interaction = [], ...afterInteraction] = footerSections; + const footer = [...interaction, ...accountNotice, ...afterInteraction.flat()]; + const liveBudget = Math.max(0, (this.terminal.rows || 24) - footer.length); + let live = body.slice(finalRows); + if (live.length > liveBudget) { + const shown = Math.max(0, liveBudget - 1); + const hidden = live.length - shown; + live = [ + ...(liveBudget > 0 ? insetPart([renderHeldRowsNotice(hidden)]) : []), + ...live.slice(live.length - shown), + ]; + } + return [...body.slice(0, finalRows), ...live, ...footer]; + } + private renderFullscreenBody(width: number): string[] { const frame = this.resolveFullscreenFrame(width); this.pendingFullscreenFrame = undefined; @@ -538,6 +631,26 @@ function joinWelcomeAndTranscript( return [...compactWelcome, ...(isVisuallyBlank(transcript[0] ?? '') ? [] : ['']), ...transcript]; } +function renderHeldRowsNotice(hiddenRows: number): string { + const noun = hiddenRows === 1 ? 'line' : 'lines'; + return chalk.hex(colors.muted)(`↑ ${hiddenRows} more ${noun} above · still updating`); +} + +function isStableRowsAwareComponent( + component: Component, +): component is StableRowsAwareComponent { + return 'getStableRowCount' in component && typeof component.getStableRowCount === 'function'; +} + +function isPreludeAwareComponent(component: Component): component is PreludeAwareComponent { + return ( + 'renderPrelude' in component && + typeof component.renderPrelude === 'function' && + 'renderPreludeNotice' in component && + typeof component.renderPreludeNotice === 'function' + ); +} + function isVisuallyBlank(line: string): boolean { return stripAnsi(line).trim().length === 0; } diff --git a/packages/tui/src/tui/shell/frame.ts b/packages/tui/src/tui/shell/frame.ts index f8225c5c7..c9bbb2639 100644 --- a/packages/tui/src/tui/shell/frame.ts +++ b/packages/tui/src/tui/shell/frame.ts @@ -35,7 +35,7 @@ export function renderSplitFrameBottom(width: number, leftWidth: number): string export function renderFrameHeader(left: string, right: string, width: number): string { const border = (value: string) => chalk.hex(colors.line)(value); const start = `${border('╭─')} ${left} `; - const end = ` ${right} ${border('─╮')}`; + const end = right ? ` ${right} ${border('─╮')}` : border('─╮'); if (visibleWidth(start) + visibleWidth(end) <= width) { return `${start}${border( '─'.repeat(Math.max(0, width - visibleWidth(start) - visibleWidth(end))), diff --git a/packages/tui/src/tui/shell/surface-host.ts b/packages/tui/src/tui/shell/surface-host.ts index a13f18df8..5177e1919 100644 --- a/packages/tui/src/tui/shell/surface-host.ts +++ b/packages/tui/src/tui/shell/surface-host.ts @@ -225,6 +225,10 @@ export class TuiSurfaceHost implements Component { return key === undefined ? undefined : JSON.stringify([this.activeFeature()?.screen.id, key]); } + takeDiscardedRows(): number { + return this.chatComponent.takeDiscardedRows?.() ?? 0; + } + render(width: number): string[] { const active = this.activeFeature(); if (!active || this.currentMode() === 'regular') return this.chatComponent.render(width); diff --git a/packages/tui/src/tui/shell/welcome/component.ts b/packages/tui/src/tui/shell/welcome/component.ts index 1cd813a87..17e049ba7 100644 --- a/packages/tui/src/tui/shell/welcome/component.ts +++ b/packages/tui/src/tui/shell/welcome/component.ts @@ -40,24 +40,52 @@ export class TuiWelcome implements Component { } renderViewport(width: number, height: number): string[] { + return this.renderFrame(width, height, false); + } + + /** + * Static banner above a conversation. In regular mode it becomes native + * scrollback, so it omits live account and runtime status; the conversation + * footer shows those through renderPreludeNotice(). + */ + renderPrelude(width: number): string[] { + return this.renderFrame(width, Number.POSITIVE_INFINITY, true); + } + + /** Live account notice for the conversation footer; empty when nothing needs action. */ + renderPreludeNotice(width: number): string[] { + const safeWidth = normalizeWidth(width); + const message = resolveWelcomeAccountNotice(this.state); + if (!message || safeWidth === 0) return []; + return [fitLine(` ${renderWelcomeAccountNotice(message)}`, safeWidth)]; + } + + private renderFrame(width: number, height: number, staticBanner: boolean): string[] { const safeWidth = normalizeWidth(width); if (safeWidth === 0) return []; if (safeWidth < 4) return [fitLine('M', safeWidth)]; const policy = resolveTuiLayoutPolicy(safeWidth); + const options = { keybindings: this.keybindings, content: this.content, staticBanner }; let frame = policy.welcome === 'compact' - ? renderCompactWelcome(this.state, safeWidth, this.keybindings, this.content) + ? renderCompactWelcome(this.state, safeWidth, options) : policy.welcome === 'stacked' - ? renderStackedWelcome(this.state, safeWidth, this.keybindings, this.content) - : renderWideWelcome(this.state, safeWidth, this.keybindings, this.content); + ? renderStackedWelcome(this.state, safeWidth, options) + : renderWideWelcome(this.state, safeWidth, options); if (frame.length + 2 > height && policy.welcome !== 'compact') { - frame = renderCompactWelcome(this.state, safeWidth, this.keybindings, this.content); + frame = renderCompactWelcome(this.state, safeWidth, options); } return fitWelcomeHeight(['', ...frame, ''], height); } } +interface WelcomeRenderOptions { + readonly keybindings: TuiKeybindingRegistry | undefined; + readonly content?: TuiWelcomeContent; + readonly staticBanner: boolean; +} + function fitWelcomeHeight(lines: string[], height: number): string[] { if (!Number.isFinite(height)) return lines; const maxRows = Math.max(1, Math.floor(height)); @@ -73,8 +101,7 @@ function fitWelcomeHeight(lines: string[], height: number): string[] { function renderWideWelcome( state: TuiShellState, width: number, - keybindings: TuiKeybindingRegistry | undefined, - content?: TuiWelcomeContent, + { keybindings, content, staticBanner }: WelcomeRenderOptions, ): string[] { const tips = resolveWelcomeItems(content?.tips, MINIMAX_CODE_WELCOME_DESIGN.wide.tips, 3); const news = resolveWelcomeItems( @@ -85,8 +112,8 @@ function renderWideWelcome( return [ ...renderTuiWelcomeHero(width), '', - renderWelcomeHeader(state, width), - ...renderWelcomeAccountNoticeRows(state, width), + renderWelcomeHeader(state, width, staticBanner), + ...(staticBanner ? [] : renderWelcomeAccountNoticeRows(state, width)), renderFrameRow(renderSectionTitle(MINIMAX_CODE_WELCOME_DESIGN.sectionTitles.tips), width), ...tips.map((text) => renderFrameRow(renderWelcomeBullet(resolveWelcomeCopy(text, keybindings)), width), @@ -103,8 +130,7 @@ function renderWideWelcome( function renderStackedWelcome( state: TuiShellState, width: number, - keybindings: TuiKeybindingRegistry | undefined, - content?: TuiWelcomeContent, + { keybindings, content, staticBanner }: WelcomeRenderOptions, ): string[] { const tips = resolveWelcomeItems(content?.tips, MINIMAX_CODE_WELCOME_DESIGN.stacked.tips, 2); const news = resolveWelcomeItems( @@ -115,8 +141,8 @@ function renderStackedWelcome( return [ ...renderTuiWelcomeHero(width), '', - renderWelcomeHeader(state, width), - ...renderWelcomeAccountNoticeRows(state, width), + renderWelcomeHeader(state, width, staticBanner), + ...(staticBanner ? [] : renderWelcomeAccountNoticeRows(state, width)), renderFrameDivider(width), renderFrameRow(renderSectionTitle(MINIMAX_CODE_WELCOME_DESIGN.sectionTitles.tips), width), ...tips.map((text) => @@ -135,8 +161,7 @@ function renderStackedWelcome( function renderCompactWelcome( state: TuiShellState, width: number, - keybindings: TuiKeybindingRegistry | undefined, - content?: TuiWelcomeContent, + { keybindings, content, staticBanner }: WelcomeRenderOptions, ): string[] { const tips = resolveWelcomeItems(content?.tips, MINIMAX_CODE_WELCOME_DESIGN.compact.tips, 2); const news = resolveWelcomeItems( @@ -149,10 +174,10 @@ function renderCompactWelcome( '', renderFrameHeader( `${chalk.bold.hex(colors.brand)('MCode')} ${chalk.hex(colors.muted)(`v${state.version}`)}`, - renderActivity(state), + staticBanner ? '' : renderActivity(state), width, ), - ...renderWelcomeAccountNoticeRows(state, width), + ...(staticBanner ? [] : renderWelcomeAccountNoticeRows(state, width)), renderFrameRow(renderSectionTitle('Tips'), width), ...tips.map((text) => renderFrameRow(chalk.hex(colors.muted)(resolveWelcomeCopy(text, keybindings)), width), @@ -163,10 +188,10 @@ function renderCompactWelcome( ]; } -function renderWelcomeHeader(state: TuiShellState, width: number): string { +function renderWelcomeHeader(state: TuiShellState, width: number, staticBanner: boolean): string { return renderFrameHeader( chalk.hex(colors.muted)(`v${state.version}`), - renderActivity(state), + staticBanner ? '' : renderActivity(state), width, ); } @@ -194,18 +219,24 @@ function resolveWelcomeCopy(value: string, keybindings: TuiKeybindingRegistry | ); } +function resolveWelcomeAccountNotice(state: TuiShellState): string | undefined { + return state.accountStatus === 'Sign in with /login' + ? state.accountStatus + : state.accountStatus === 'Connected with warnings' + ? 'Check /provider or /status for details.' + : state.accountStatus === 'Account unavailable' + ? 'Check /status for details.' + : undefined; +} + +function renderWelcomeAccountNotice(message: string): string { + return `${chalk.bold.hex(colors.warning)('○')} ${chalk.bold.hex(colors.warning)(message)}`; +} + function renderWelcomeAccountNoticeRows(state: TuiShellState, width: number): string[] { - const message = - state.accountStatus === 'Sign in with /login' - ? state.accountStatus - : state.accountStatus === 'Connected with warnings' - ? 'Check /provider or /status for details.' - : state.accountStatus === 'Account unavailable' - ? 'Check /status for details.' - : undefined; + const message = resolveWelcomeAccountNotice(state); if (!message) return []; - const notice = `${chalk.bold.hex(colors.warning)('○')} ${chalk.bold.hex(colors.warning)(message)}`; - return [renderFrameRow(notice, width)]; + return [renderFrameRow(renderWelcomeAccountNotice(message), width)]; } function renderActivity(state: TuiShellState): string { diff --git a/packages/tui/src/tui/transcript/projection-window.ts b/packages/tui/src/tui/transcript/projection-window.ts index da4d64b61..b2e9f102e 100644 --- a/packages/tui/src/tui/transcript/projection-window.ts +++ b/packages/tui/src/tui/transcript/projection-window.ts @@ -33,6 +33,12 @@ export class TranscriptProjectionWindow { private anchorCellId: string | undefined; private hiddenTurns = 0; private windowRolls = 0; + /** + * Append-only projections fix each turn's fold when the turn is first seen. + * Native terminal history cannot be rewritten, so a growing turn must only + * extend its visible tail instead of moving the fold boundary. + */ + private readonly appendOnlyFolds = new Map(); constructor( private readonly maxInitialTurns = 30, @@ -41,7 +47,17 @@ export class TranscriptProjectionWindow { private readonly maxProjectedCells = 1_000, ) {} - project(input: readonly TranscriptCell[] | TranscriptProjectionSource): TranscriptProjection { + /** + * Project the source into the cells to render. With `appendOnly`, an established + * window never rolls, drops earlier turns, or re-folds a turn; later cells only + * extend the projection. Regular terminal mode needs this because rows already + * written to native scrollback cannot be changed in place. + */ + project( + input: readonly TranscriptCell[] | TranscriptProjectionSource, + options: { readonly appendOnly?: boolean } = {}, + ): TranscriptProjection { + const appendOnly = options.appendOnly === true; const source = Array.isArray(input) ? new ArrayTranscriptProjectionSource(input) : (input as TranscriptProjectionSource); @@ -57,6 +73,10 @@ export class TranscriptProjectionWindow { } if (this.anchorCellId) { const anchor = source.locateCell(this.anchorCellId); + if (anchor && appendOnly) { + this.hiddenTurns = anchor.turnIndex; + return this.projectTurns(source, anchor.turnIndex, true); + } if (anchor) { const projectedTurns = source.turnCount - anchor.turnIndex; const projectedTurnLimit = Math.max( @@ -68,39 +88,53 @@ export class TranscriptProjectionWindow { return this.projectTurns(source, anchor.turnIndex); } this.windowRolls += 1; - return this.anchorTrailingWindow(source); + return this.anchorTrailingWindow(source, appendOnly); } this.anchorCellId = undefined; this.hiddenTurns = 0; } - return this.anchorTrailingWindow(source); + return this.anchorTrailingWindow(source, appendOnly); } reset(): void { this.anchorCellId = undefined; this.hiddenTurns = 0; this.windowRolls = 0; + this.appendOnlyFolds.clear(); } - private anchorTrailingWindow(source: TranscriptProjectionSource): TranscriptProjection { + private anchorTrailingWindow( + source: TranscriptProjectionSource, + appendOnly: boolean, + ): TranscriptProjection { + // A new window starts a new fold baseline. + this.appendOnlyFolds.clear(); const retainedTurns = normalizeLimit(this.maxInitialTurns); const firstTurn = Math.max(0, source.turnCount - retainedTurns); const range = source.turnRange(firstTurn); this.anchorCellId = range ? source.cellAt(range.start)?.id : undefined; this.hiddenTurns = firstTurn; - return this.projectTurns(source, firstTurn); + return this.projectTurns(source, firstTurn, appendOnly, true); } private projectTurns( source: TranscriptProjectionSource, firstTurn: number, + appendOnly = false, + establishing = false, ): TranscriptProjection { const limit = Math.max(2, normalizeLimit(this.maxCellsPerTurn)); const cellBudget = Math.max(limit + 1, normalizeLimit(this.maxProjectedCells)); - let effectiveFirstTurn = source.turnCount; + // An established append-only window keeps every turn it already shows. + const applyBudget = !appendOnly || establishing; + let effectiveFirstTurn = applyBudget ? source.turnCount : firstTurn; let projectedCellCount = 0; - for (let turnIndex = source.turnCount - 1; turnIndex >= firstTurn; turnIndex -= 1) { + for ( + let turnIndex = source.turnCount - 1; + applyBudget && turnIndex >= firstTurn; + turnIndex -= 1 + ) { const range = source.turnRange(turnIndex); if (!range) continue; const turnLength = Math.max(0, range.end - range.start); @@ -125,7 +159,14 @@ export class TranscriptProjectionWindow { const first = source.cellAt(range.start); visitedCells += 1; if (!first) continue; - if (turnLength <= limit) { + const turn = first.turnId?.trim() || `cell:${first.id}`; + let hidden = turnLength <= limit ? 0 : turnLength - limit; + if (appendOnly) { + const fixed = this.appendOnlyFolds.get(turn); + if (fixed === undefined) this.appendOnlyFolds.set(turn, hidden); + else hidden = Math.min(fixed, Math.max(0, turnLength - 1)); + } + if (hidden === 0) { projected.push(first); for (let index = range.start + 1; index < range.end; index += 1) { const cell = source.cellAt(index); @@ -135,15 +176,12 @@ export class TranscriptProjectionWindow { continue; } - const tailCount = limit - 1; - const hidden = turnLength - limit; const tail: TranscriptCell[] = []; - for (let index = range.end - tailCount; index < range.end; index += 1) { + for (let index = range.start + 1 + hidden; index < range.end; index += 1) { const cell = source.cellAt(index); visitedCells += 1; if (cell) tail.push(cell); } - const turn = first.turnId?.trim() || `cell:${first.id}`; projected.push( first, { diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 7d2e509c9..b43a78823 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -47,6 +47,11 @@ const OSC133_ZONE_FINAL = '\x1b]133;C\x07'; const TOOL_ROW_CONNECTOR_WIDTH = 2; /** Below this the command is too clipped to identify, so keep the row uniform instead. */ const MIN_SHELL_COMMAND_WIDTH = 12; +/** + * Append-only rendering keeps at most this many final rows before trimming rows + * that already sit in native scrollback, so frame cost stays bounded in long sessions. + */ +const APPEND_ONLY_RETAINED_ROWS = { high: 3_000, low: 1_500 } as const; export class TranscriptView implements Component { private readonly unitCache = new Map< @@ -67,6 +72,8 @@ export class TranscriptView implements Component { source?: TranscriptProjectionSource; sourceRevision?: number; presentationRevision: number; + appendOnly: boolean; + stableRows: number; } | undefined; private performanceSnapshot = { @@ -88,6 +95,12 @@ export class TranscriptView implements Component { private readonly displayModes: TranscriptDisplayModeResolver; private readonly workspaceDir: string; + private readonly appendOnly: () => boolean; + private readonly retainedRows: { readonly high: number; readonly low: number }; + private stableRows = 0; + /** First cell kept after trimming final rows in append-only mode. */ + private trimmedBeforeCellId: string | undefined; + private discardedRows = 0; constructor( private readonly cells: @@ -100,8 +113,17 @@ export class TranscriptView implements Component { maxProjectedCells?: number; displayModes?: TranscriptDisplayModeResolver; workspaceDir?: string; + /** + * Regular terminal mode writes rendered rows into native scrollback, which + * cannot be edited. Its projection must only grow at the end. + */ + appendOnly?: () => boolean; + /** Final-row bounds for trimming append-only output; defaults suit a terminal session. */ + retainedRows?: { readonly high: number; readonly low: number }; } = {}, ) { + this.appendOnly = options.appendOnly ?? (() => false); + this.retainedRows = options.retainedRows ?? APPEND_ONLY_RETAINED_ROWS; this.displayModes = options.displayModes ?? new TranscriptPresentationController(); this.workspaceDir = options.workspaceDir ?? process.cwd(); this.projectionWindow = new TranscriptProjectionWindow( @@ -119,6 +141,8 @@ export class TranscriptView implements Component { } dispose(): void { + this.trimmedBeforeCellId = undefined; + this.discardedRows = 0; this.unitCache.clear(); this.markdownComponents.clear(); this.markdownStreaming.clear(); @@ -135,34 +159,49 @@ export class TranscriptView implements Component { const sourceRevision = source?.revision; if (this.frameCache && this.frameCache.source !== source) this.invalidate(); const presentationRevision = this.displayModes.revision; + const appendOnly = this.appendOnly(); if ( source && sourceRevision !== undefined && this.frameCache?.source === source && this.frameCache.sourceRevision === sourceRevision && this.frameCache.presentationRevision === presentationRevision && + this.frameCache.appendOnly === appendOnly && this.frameCache.width === normalizedWidth ) { + this.stableRows = this.frameCache.stableRows; return this.frameCache.lines; } - const projection = this.projectionWindow.project(sourceCells); - const units = createRenderUnits(projection.cells); + const projection = this.projectionWindow.project(sourceCells, { appendOnly }); + let projectedCells = projection.cells; + if (!appendOnly) this.trimmedBeforeCellId = undefined; + if (this.trimmedBeforeCellId) { + const firstKept = projectedCells.findIndex((cell) => cell.id === this.trimmedBeforeCellId); + // The source was replaced (new or switched Session); start from its projection. + if (firstKept < 0) this.trimmedBeforeCellId = undefined; + else projectedCells = projectedCells.slice(firstKept); + } + const trimmed = this.trimmedBeforeCellId !== undefined; + const units = createRenderUnits(projectedCells); const unitSignatures = units.map((unit) => renderUnitSignature(unit, source)); const frameSignature = `${presentationRevision}\u001C${ projection.hiddenTurns + }\u001C${appendOnly ? 'append' : 'window'}\u001C${ + this.trimmedBeforeCellId ?? '' }\u001C${unitSignatures.join('\u001F')}`; if ( this.frameCache?.width === normalizedWidth && this.frameCache.signature === frameSignature ) { + this.stableRows = this.frameCache.stableRows; return this.frameCache.lines; } this.resetFrameCacheMetrics(); const activeUnitKeys = new Set(); - const lines: string[] = - projection.hiddenTurns > 0 + let lines: string[] = + projection.hiddenTurns > 0 && !trimmed ? [ chalk.hex(colors.muted)( `↑ ${projection.hiddenTurns} earlier turns omitted from live projection`, @@ -170,18 +209,56 @@ export class TranscriptView implements Component { ' ', ] : []; + // Rows before the first unit that can still change are final. Everything at + // or after it stays live: its text may change, so it must not enter history. + let stableRows = lines.length; + let frontierOpen = true; + let blockStart = lines.length; + const unitRowStarts: number[] = []; units.forEach((unit, index) => { const next = units[index + 1]; const key = renderUnitKey(unit); activeUnitKeys.add(key); const rendered = this.renderUnit(unit, next, normalizedWidth, unitSignatures[index] ?? ''); const connectedToNext = isConnectedRenderUnit(unit, next); + const rowsStart = lines.length; + unitRowStarts.push(rowsStart); lines.push(...rendered); + if (frontierOpen) { + const finalRows = this.finalRowCount(unit, next, rendered.length); + if (finalRows >= rendered.length) { + stableRows = lines.length; + } else { + // A separator belongs to the block after it; keep it live with a live block. + stableRows = finalRows > 0 ? rowsStart + finalRows : blockStart; + frontierOpen = false; + } + } + blockStart = lines.length; if (next && rendered.length > 0 && !connectedToNext && !isPendingSteerUnit(next)) { lines.push(' '); } }); + if (appendOnly && stableRows > this.retainedRows.high) { + // Drop whole leading units that are final and far above the screen. Retained + // units render independently of earlier ones, so the result is an exact suffix. + const target = Math.min(stableRows, lines.length - this.retainedRows.low); + let cutUnit = -1; + for (let index = 1; index < units.length; index += 1) { + if ((unitRowStarts[index] ?? Infinity) > target) break; + cutUnit = index; + } + const cut = cutUnit > 0 ? units[cutUnit] : undefined; + const cutRow = unitRowStarts[cutUnit] ?? 0; + if (cut && cutRow > 0) { + this.trimmedBeforeCellId = cut.kind === 'cell' ? cut.cell.id : cut.cells[0]?.id; + this.discardedRows += cutRow; + lines = lines.slice(cutRow); + stableRows -= cutRow; + } + } this.pruneUnitCaches(activeUnitKeys); + this.stableRows = stableRows; this.frameCache = { signature: frameSignature, width: normalizedWidth, @@ -189,6 +266,8 @@ export class TranscriptView implements Component { source, sourceRevision, presentationRevision, + appendOnly, + stableRows, }; this.performanceSnapshot = { sourceUnits: sourceCells.length, @@ -206,6 +285,52 @@ export class TranscriptView implements Component { return lines; } + /** + * Leading rows of the latest render that later updates cannot change. Rows + * after this boundary belong to a step that is still running or streaming, or + * to the last execution step whose connector depends on what follows. + */ + getStableRowCount(): number { + return this.stableRows; + } + + /** Whether append-only output no longer starts at the projection's first cell. */ + isTrimmed(): boolean { + return this.trimmedBeforeCellId !== undefined; + } + + /** Leading rows dropped from append-only output since the previous call. */ + takeDiscardedRows(): number { + const rows = this.discardedRows; + this.discardedRows = 0; + return rows; + } + + private finalRowCount( + unit: TranscriptRenderUnit, + next: TranscriptRenderUnit | undefined, + renderedRows: number, + ): number { + if (isPendingSteerUnit(unit)) return 0; + const cells = unit.kind === 'cell' ? [unit.cell] : unit.cells; + // A submitted prompt stays pending for the whole turn, but its rows do not + // depend on that status. + if (cells.some((cell) => cell.kind !== 'user' && isMutableCell(cell))) { + if ( + unit.kind === 'cell' && + (unit.cell.kind === 'assistant' || unit.cell.kind === 'assistant-preamble') + ) { + // Committed Markdown blocks never re-render while text is appended. + const markdown = this.markdownComponents.get(renderUnitKey(unit)); + return Math.min(renderedRows, markdown?.getStableLineCount() ?? 0); + } + return 0; + } + // The last execution step's connector and rail change when the next step arrives. + if (!next && cells.some(isExecutionCell)) return 0; + return renderedRows; + } + getPerformanceSnapshot(): { sourceUnits: number; projectedCells: number; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index e454fa526..ed998b2bf 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -7,6 +7,7 @@ import { type TerminalCapabilities, } from "../../src/tui/platform/terminal-capabilities.js"; import { + stripTerminalSequences, TuiAltScreen, VStack, type Terminal, @@ -15233,3 +15234,300 @@ describe("interactive model argument contract", () => { ).toBeUndefined(); }); }); + +describe("regular-mode native history during a live run", () => { + class HistoryTerminal extends VirtualTerminal { + output = ""; + override write(data: string): void { + this.output += data; + super.write(data); + } + } + + const SCROLLBACK_ERASE = "\x1b[3J"; + const toolCall = ( + id: string, + status: string, + output?: string, + ): NonNullable["toolCalls"]>[number] => ({ + id, + name: "bash", + status, + input: { command: `run ${id}` }, + ...(output === undefined ? {} : { output }), + }); + const delta = ( + messageId: string, + fields: Omit, "type">, + ): TuiStreamEvent => ({ type: "delta", messageId, role: "assistant", ...fields }); + const paragraphs = (prefix: string, count: number) => + Array.from({ length: count }, (_, index) => `${prefix} ${index}`).join("\n\n"); + const nonBlank = (lines: readonly string[]) => + lines.map((line) => stripTerminalSequences(line).trimEnd()).filter((line) => line.trim().length > 0); + + async function startScriptedRun(columns: number, rows: number) { + const terminal = new HistoryTerminal(columns, rows); + const runtime = createRuntime(); + const queue: TuiStreamEvent[] = []; + let wake: (() => void) | undefined; + let finished = false; + vi.mocked(runtime.sendMessage).mockImplementation(async function* () { + for (;;) { + while (queue.length > 0) yield queue.shift()!; + if (finished) return; + await new Promise((resolve) => { + wake = resolve; + }); + } + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + tuiMode: "regular", + }); + app.start(); + await app.ready; + app.tui.renderNow(); + await terminal.flush(); + const settle = async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + app.tui.renderNow(); + await terminal.flush(); + }; + return { + app, + terminal, + runtime, + settle, + async submit(prompt: string) { + const calls = vi.mocked(runtime.sendMessage).mock.calls.length; + finished = false; + app.editor.setText(prompt); + terminal.sendInput("\r"); + await vi.waitFor(() => + expect(runtime.sendMessage).toHaveBeenCalledTimes(calls + 1), + ); + await settle(); + }, + async push(...events: TuiStreamEvent[]) { + queue.push(...events); + wake?.(); + await settle(); + }, + async finish(...events: TuiStreamEvent[]) { + queue.push(...events, { type: "done" }); + finished = true; + wake?.(); + await settle(); + await settle(); + }, + /** Native history plus screen must equal the logical document exactly once. */ + expectHistoryMatchesDocument() { + expect(nonBlank(terminal.getScrollBuffer())).toEqual( + nonBlank(app.tui.render(terminal.columns)), + ); + }, + }; + } + + it("keeps history when a parallel tool finishes after later tools pushed it out", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("check everything"); + run.terminal.output = ""; + const ids = Array.from({ length: 20 }, (_, index) => `call-${index}`); + await run.push( + delta("m1", { + content: "Running checks in parallel.", + toolCalls: ids.map((id) => toolCall(id, "running")), + }), + ); + await run.push( + delta("m1", { + toolCalls: ids.map((id, index) => + index === 0 ? toolCall(id, "running") : toolCall(id, "completed", `ok ${id}\nline 2\nline 3`), + ), + }), + ); + // The running first tool keeps later rows out of history; the screen shows the latest rows. + const viewport = nonBlank(run.terminal.getViewport()); + expect(viewport.some((line) => /↑ \d+ more lines above · still updating/u.test(line))).toBe(true); + expect(nonBlank(run.terminal.getScrollBuffer()).filter((line) => line.includes("still updating"))).toHaveLength(1); + await run.push( + delta("m1", { + toolCalls: ids.map((id) => toolCall(id, "completed", `ok ${id}\nline 2\nline 3`)), + }), + ); + await run.finish(delta("m2", { content: "All checks passed." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("keeps history when a streamed table widens after rows scrolled out", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("make a table"); + run.terminal.output = ""; + const rows = Array.from({ length: 30 }, (_, index) => `| r${index} | x |`).join("\n"); + await run.push(delta("m1", { content: `Table:\n\n| a | b |\n|---|---|\n${rows}` })); + await run.push(delta("m1", { content: `\n| wide | ${"W".repeat(50)} |` })); + await run.finish(delta("m1", { content: "\n\nDone." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("keeps history when a list becomes loose after items scrolled out", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("list things"); + run.terminal.output = ""; + const items = Array.from({ length: 30 }, (_, index) => `- item ${index}`).join("\n"); + await run.push(delta("m1", { content: `Items:\n\n${items}` })); + await run.push(delta("m1", { content: "\n\n- spaced item" })); + await run.finish(delta("m1", { content: "\n\nDone." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("keeps history when a turn grows past the per-turn projection fold", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("long task"); + run.terminal.output = ""; + for (let batch = 0; batch < 14; batch += 1) { + const ids = Array.from({ length: 10 }, (_, index) => `call-${batch}-${index}`); + await run.push( + delta(`m${batch}`, { toolCalls: ids.map((id) => toolCall(id, "completed", "ok")) }), + ); + } + await run.finish(delta("final", { content: "Finished." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("keeps history when a failed turn and a recovered turn change the status", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("first"); + await run.finish(delta("m1", { content: paragraphs("ANSWER", 40) })); + run.terminal.output = ""; + await run.submit("second"); + await run.finish({ type: "error", message: "provider unavailable" }); + await run.submit("third"); + await run.finish(delta("m3", { content: "Recovered." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("commits finished steps of a long turn to history while the turn runs", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("work through steps"); + run.terminal.output = ""; + await run.push(delta("m0", { content: "I will work through the steps." })); + for (let step = 0; step < 12; step += 1) { + await run.push( + delta(`m${step + 1}`, { + content: `Step ${step} notes.`, + toolCalls: [toolCall(`call-${step}`, "running")], + }), + ); + await run.push( + delta(`m${step + 1}`, { + toolCalls: [toolCall(`call-${step}`, "completed", `done ${step}`)], + }), + ); + } + const history = nonBlank(run.terminal.getScrollBuffer()); + const visible = new Set(nonBlank(run.terminal.getViewport())); + const early = history.filter( + (line) => line.includes("Step 0 notes.") && !visible.has(line), + ); + expect(early).toHaveLength(1); + expect(history.some((line) => line.includes("still updating"))).toBe(false); + await run.finish(delta("final", { content: "All steps done." })); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); + + it("keeps history when a long run is stopped", async () => { + const terminal = new HistoryTerminal(80, 16); + const runtime = createRuntime(); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + vi.mocked(runtime.sendMessage).mockImplementation(async function* ( + _request: SendMessageReq, + signal?: AbortSignal, + ) { + yield delta("m1", { content: paragraphs("PARTIAL", 30) }); + await new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + yield { type: "done" }; + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + tuiMode: "regular", + }); + app.start(); + try { + await app.ready; + app.editor.setText("long task"); + terminal.sendInput("\r"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer().join("\n")).toContain("PARTIAL 29"); + }); + terminal.output = ""; + terminal.sendInput("\x1b"); + await vi.waitFor(() => expect(app.controller.snapshot().status).not.toBe("running")); + await new Promise((resolve) => setTimeout(resolve, 30)); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.output).not.toContain(SCROLLBACK_ERASE); + expect(nonBlank(terminal.getScrollBuffer())).toEqual(nonBlank(app.tui.render(80))); + } finally { + await app.stop(); + } + }); + + it("streams long prose into history without holding it back", async () => { + const run = await startScriptedRun(80, 16); + try { + await run.submit("write a lot"); + run.terminal.output = ""; + await run.push(delta("m1", { content: paragraphs("PROSE", 20) })); + const midRun = nonBlank(run.terminal.getScrollBuffer()); + expect(midRun.some((line) => line.includes("PROSE 0"))).toBe(true); + await run.push(delta("m1", { content: `\n\n${paragraphs("MORE", 20)}` })); + await run.finish(); + expect(run.terminal.output).not.toContain(SCROLLBACK_ERASE); + run.expectHistoryMatchesDocument(); + } finally { + await run.app.stop(); + } + }); +}); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 6d17679e9..b12a4eeaf 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from 'node:util'; import { describe, expect, it } from 'vitest'; import { @@ -939,3 +940,104 @@ describe('MCode Pi Engine local deltas', () => { expect(submitted).toBe(pasted); }); }); + +describe('regular-mode retained document', () => { + it.each([ + ['too few', 3], + ['too many', 7], + ])('reconstructs instead of rebasing when %s rows are reported', async (_label, reported) => { + const terminal = new RecordingVirtualTerminal(40, 8); + const tui = new TuiMainScreen(terminal); + let lines = Array.from({ length: 30 }, (_, index) => `row ${index}`); + let pending = 0; + tui.addChild({ + render: () => [...lines], + invalidate: () => undefined, + takeDiscardedRows: () => { + const rows = pending; + pending = 0; + return rows; + }, + }); + tui.renderNow(); + await terminal.flush(); + // Drop five rows but report a different count. + lines = [...lines.slice(5), 'row 30']; + pending = reported; + terminal.takeWrites(); + tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer().map((line) => line.trimEnd()).filter(Boolean); + expect(history.slice(-lines.length)).toEqual(lines); + expect(new Set(history).size).toBe(history.length); + }); + + it('drops final rows already in native history without rewriting it', async () => { + const terminal = new RecordingVirtualTerminal(60, 12); + const tui = new TuiMainScreen(terminal); + const cells: ReturnType[] = []; + const transcript = new TranscriptView(() => cells, { + appendOnly: () => true, + retainedRows: { high: 60, low: 30 }, + }); + const welcome = new MutableLines(); + welcome.lines = ['BANNER']; + const empty = new MutableLines(); + const composer = new MutableLines(); + composer.lines = [`composer${CURSOR_MARKER}`]; + const status = new MutableLines(); + status.lines = ['status']; + const layout = new TuiChatLayout(terminal, { + surface: () => 'conversation', + welcome, + transcript, + interaction: empty, + activity: empty, + followUp: empty, + composer, + status, + }); + tui.addChild(layout); + const addTurn = (turn: number) => { + cells.push( + createTranscriptCell({ + id: `user-${turn}`, + turnId: `turn-${turn}`, + kind: 'user', + status: 'succeeded', + content: `question ${turn}`, + createdAtMs: turn * 2, + }), + createTranscriptCell({ + id: `answer-${turn}`, + turnId: `turn-${turn}`, + kind: 'assistant', + status: 'succeeded', + content: `answer ${turn} first\n\nanswer ${turn} second`, + createdAtMs: turn * 2 + 1, + }), + ); + }; + for (let turn = 0; turn < 60; turn += 1) { + addTurn(turn); + tui.renderNow(); + await terminal.flush(); + } + + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(transcript.isTrimmed()).toBe(true); + expect(layout.render(60).length).toBeLessThan(80); + const plain = (lines: readonly string[]) => + lines.map((line) => line.trim()).filter((line) => line.length > 0); + const history = plain(terminal.getScrollBuffer()); + const fullTranscript = new TranscriptView(() => cells, { + maxInitialTurns: 1_000, + maxProjectedTurns: 1_000, + }) + .render(58) + .map((line) => stripVTControlCharacters(line)); + expect(history).toEqual( + plain(['BANNER', ...fullTranscript, 'composer', 'status']), + ); + }); +}); diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index 0562f6491..6f9f15f9c 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -2171,6 +2171,50 @@ describe('TranscriptView', () => { }); }); + it('only appends to an established projection in append-only mode', () => { + const tool = (index: number, status: 'running' | 'succeeded' = 'succeeded') => + createTranscriptCell({ + id: `tool-${index}`, + turnId: 'turn-1', + kind: 'tool', + status, + title: 'bash', + content: `step ${index}`, + createdAtMs: index + 2, + }); + const cells = [ + createTranscriptCell({ + id: 'user-1', + turnId: 'turn-1', + kind: 'user', + status: 'pending', + content: 'Run every step', + createdAtMs: 1, + }), + ...Array.from({ length: 50 }, (_, index) => tool(index)), + ]; + let appendOnly = true; + const view = new TranscriptView(() => cells, { maxCellsPerTurn: 40, appendOnly: () => appendOnly }); + const plain = () => view.render(80).map((line) => stripVTControlCharacters(line)); + + const first = plain(); + expect(first.join('\n')).toContain('11 earlier steps folded'); + // The last finished tool step stays live until the next step fixes its connector. + expect(view.getStableRowCount()).toBeLessThan(first.length); + + cells.push(...Array.from({ length: 30 }, (_, index) => tool(50 + index))); + cells.push(tool(80, 'running')); + const grown = plain(); + const stable = view.getStableRowCount(); + expect(grown.join('\n')).toContain('11 earlier steps folded'); + expect(grown.slice(0, first.length - 1)).toEqual(first.slice(0, first.length - 1)); + expect(grown.slice(stable).join('\n')).toContain('step 80'); + expect(grown.slice(0, stable).join('\n')).toContain('step 79'); + + appendOnly = false; + expect(plain().join('\n')).toContain('42 earlier steps folded'); + }); + it('renders Todo progress as a compact read-only checklist', () => { const view = new TranscriptView(() => [ createTranscriptCell({