diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 3fb374d81..37e175019 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,8 +163,8 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "146fd4222ae5a48d1f439de2f280b111789151a30c1bdd2735e32fff07329b9d", - "changeIds": ["L005", "L017", "L027", "L033", "L034"], + "currentSha256": "6970c4bac059263fc1ec420fa07362b7bcc1838fd381dbaa5a61180c15bb4787", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L036"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", "behaviorImpact": "Large regular-mode renders preserve output order without constructing a single V8-limited terminal string. 150ms 后及退出前重放完整投影历史。 内部导航标记不再写入终端,避免 iTerm2 左侧箭头和命令条噪声。 Session 结束后的状态更新不再发送 ESC 3J 清除原生滚动历史,当前视口仍显示最新内容。" diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index e2f84903b..96fd95ef6 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -115,3 +115,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Adaptation: the original contribution widened hits over two live content columns. The reserved gutter prevents those presses from swallowing content clicks or text selection. `chat-layout.ts` opts into the three-column gutter. - Evidence: `test/unit/tui-scrollbar-interaction.test.ts` drives SGR press, motion, release and wheel events through VirtualTerminal, covering track jumps, thumb grabs, narrow terminals, content routing, selection, overlays and the actual fullscreen ChatLayout. - Removal condition: the selected Pi baseline provides equivalent reserved-gutter track and drag interaction and MCode migrates to it. + +## L036: Rebuild structural collapses across native scrollback + +- Origin: follow-up to [PR #243](https://github.com/MiniMax-AI/minimax-code/pull/243). +- Product contract: collapsing transient content must retain the folded summary and every Composer row, even when they move above the previous viewport origin. +- Minimal difference: during a shrinking viewport-only redraw, compare the text of the scrolled prefix after stripping terminal sequences. If that prefix changed, rebuild the full projection. Tail-only shrink and style-only prefix changes continue to preserve native scrollback; pending resize replay retains its temporary tail view. +- Evidence: `test/unit/tui-engine-local-deltas.test.ts` covers stale transient rows, all Composer rows after a selector closes, and simultaneous shrink plus style-only changes without duplicate answer rows. +- Removal condition: the selected Pi baseline supplies equivalent structural-collapse handling. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 77534d1e7..4055c43b0 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { deleteKittyImage, isImageLine } from "./terminal-image.js"; import { type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; -import { visibleWidth } from "./utils.js"; +import { stripTerminalSequences, visibleWidth } from "./utils.js"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; const MAX_RENDER_WRITE_CHARS = 1024 * 1024; @@ -308,9 +308,13 @@ export class TuiMainScreen extends TuiBase implements TUI { // previous viewport origin so rows already scrolled out are not painted twice, // and growth still writes every row before it scrolls out. Resize previews are // temporary: they show the new tail until the pending full history replay. - if (viewportOnly && !this.historyReplayPending && newLines.length <= prevViewportTop) { - // Nothing remains addressable on screen; rebuild with one consistent origin. - viewportOnly = false; + if (viewportOnly && !this.historyReplayPending) { + // A shrink can move new content (including the Composer) into immutable + // scrollback. Rebuild when that prefix changes, but ignore style-only edits. + const changedScrolledPrefix = newLines.length < this.previousLines.length && + newLines.slice(0, prevViewportTop).some((line, index) => + stripTerminalSequences(line) !== stripTerminalSequences(this.previousLines[index] ?? "")); + if (newLines.length <= prevViewportTop || changedScrolledPrefix) viewportOnly = false; } const start = viewportOnly ? this.historyReplayPending 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 6edebc70c..dd84a3715 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -56,7 +56,7 @@ describe('MCode Pi Engine local deltas', () => { } }); - it('preserves native scrollback when transient rows shrink above the viewport', async () => { + it('rebuilds when a transient collapse changes rows already in scrollback', async () => { const terminal = new RecordingVirtualTerminal(40, 5); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -81,13 +81,55 @@ describe('MCode Pi Engine local deltas', () => { const writes = terminal.takeWrites(); expect(writes).toContain('\x1b[2J\x1b[H'); - expect(writes).not.toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toContain('activity-1'); - expect(terminal.getViewport()).toEqual(['composer', '', '', '', '']); + expect(writes).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).not.toContain('activity-1'); + expect(terminal.getViewport()).toEqual(['header', 'status', 'composer', '', '']); expect(terminal.getScrollBuffer().filter((line) => line === 'header')).toHaveLength(1); expect(tui.fullRedraws).toBe(2); }); + it('keeps every composer row visible after a collapse crosses the viewport origin', async () => { + const terminal = new RecordingVirtualTerminal(40, 10); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const chat = Array.from({ length: 15 }, (_, index) => `Chat ${index}`); + const composer = ['Editor 0', 'Editor 1', `Editor 2${CURSOR_MARKER}`]; + component.lines = [...chat, ...composer]; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + + component.lines = [ + ...chat, + ...Array.from({ length: 8 }, (_, index) => `Selector ${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + component.lines = [...chat, ...composer]; + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + component.lines = [...chat.slice(0, 12), ...composer]; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getViewport()).toEqual([ + ...chat.slice(5, 12), + 'Editor 0', + 'Editor 1', + 'Editor 2', + ]); + expect(terminal.getScrollBuffer()).toEqual([ + ...chat.slice(0, 12), + 'Editor 0', + 'Editor 1', + 'Editor 2', + ]); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 9 }); + }); + it('does not replay scrolled answer rows after a narrow terminal frame shrinks', async () => { const terminal = new RecordingVirtualTerminal(67, 44); const tui = new TuiMainScreen(terminal); @@ -99,11 +141,13 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); // Settle the activity row, then repaint a historical line (for example Markdown styling). + terminal.takeWrites(); component.lines = [...answer, `composer${CURSOR_MARKER}`, 'status']; + component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; tui.renderNow(); await terminal.flush(); - terminal.takeWrites(); - component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + component.lines[0] = '\x1b[3mAnswer line 0\x1b[0m'; tui.renderNow(); await terminal.flush();