diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index dfd23df2..a67fb9f2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,13 +85,16 @@ and initial prompt are not applied. In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores -the current conversation. Closing a full-viewport interaction rebuilds the chat -screen so its temporary rows do not leave a large blank area above the conversation. -Closing or shrinking a Composer completion menu also restores the exposed chat -rows. Short documents refresh in place; history is reconstructed only when the -smaller layout needs to bring scrolled rows back into view. -When running content shrinks entirely within the current screen, the renderer -keeps native scrollback and the Composer position stable. +the current conversation. Closing, replacing or shrinking a transient region +restores the exposed chat rows. This includes inline selectors such as `/theme`, +completion menus, multi-line drafts, image previews, queued messages, task and +Goal summaries, welcome notices and status rows. Short documents refresh in place; +history is reconstructed only when the smaller layout needs to bring scrolled +rows back into view. This rule follows the rendered layout, including asynchronous +updates, rather than requiring each close handler to request a special redraw. +When background running content shrinks entirely within the current screen and +the transient layout stays unchanged, the renderer keeps native scrollback and +the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 4db84b77..6273fc7d 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,9 +208,6 @@ export function createTuiApplicationEditor( autocompleteMaxVisible: 8, placeholder: composerText('placeholder'), }); - editor.onAutocompleteResize = (previousRows, rows) => { - if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender(); - }; editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime)); return editor; } @@ -428,7 +425,7 @@ export function createTuiApplicationSurface(options: { readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined; readonly shouldResumeDraftAfterLogin: () => boolean; readonly isActive: () => boolean; - readonly requestInteractionRender: (rebuild?: boolean) => void; + readonly requestInteractionRender: () => void; readonly mode: () => TuiMode; readonly switchMode: (mode: TuiMode) => boolean; readonly chatMode: TuiMode; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index ab9207c9..6d556331 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -820,10 +820,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity, composer, }); - function requestInteractionRender(rebuild = false): void { + function requestInteractionRender(): void { if (!started || stopped) return; - if (rebuild) tui.requestRender(true); - else tui.requestImmediateRender(); + tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f29800f9..f39b20b7 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,8 +21,8 @@ }, { "path": "components/editor.ts", - "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", - "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], + "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", + "changeIds": ["L005", "L009", "L022", "L030", "L032"], "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, @@ -163,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "currentSha256": "9d5c2b83b6b3f0b614f9746a398025eb2c9d324a83959138e77728a08679939c", "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", @@ -171,7 +171,7 @@ }, { "path": "tui.ts", - "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "currentSha256": "96591553bc244dbe431de5bff46f57a874d83cd5fe623c94b47b637f83afcec0", "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"], "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 及焦点输入过滤 独立面板声明键盘分页归属。", "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 分页交给焦点面板;默认仍由外层视口处理。" diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 31e2bbb8..50b72941 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -142,7 +142,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L038: Preserve native scrolling during visible content shrink - Product contract: settling visible activity rows must not clear native scrollback or pin a scrolled host viewport to the top. The Composer and status remain at the bottom, and historical content remains unique. -- Minimal difference: when terminal geometry and the text already in scrollback are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. +- Minimal difference: when terminal geometry, the text already in scrollback and the declared transient layout keys are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. L041 makes this an explicit background-content policy; unclassified layouts restore exposed rows. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. - Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. - Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate. - Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink. @@ -163,10 +163,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. - Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering. -## L041: Restore chat rows after inline completion shrink +## L041: Restore chat rows after transient layout shrink -- Product contract: closing or filtering an inline completion menu restores the conversation instead of leaving the released rows blank above it. Ordinary activity shrink retains L038's native scrolling behavior. -- Minimal difference: Editor reports completion row-count changes relative to its last rendered menu, including asynchronous results and grouped-list navigation. The product requests a layout render on shrink in regular mode. Main-screen layout renders skip L038 padding for one frame, allowing L034 reconstruction only when scrolled rows must return; short documents retain differential rendering. Disposal disables the callback before cancellation. -- Evidence: product VirtualTerminal tests exercise Escape, Tab, Backspace, asynchronous empty results and filtering, compare the complete visible frame, and check unique long history under xterm and an ED 2 clear-to-scrollback model. Short-document and fullscreen cases avoid unnecessary reconstruction; existing activity-shrink regressions remain intact. +- Product contract: shrinking a transient UI region restores the conversation instead of leaving released rows blank above it. Background activity shrink with unchanged transient layout retains L038's native scrolling behavior. +- Minimal difference: components may expose the layout key of their last rendered frame. MainScreen permits L038 padding only when every root explicitly supplies the same key and no overlay was present. ChatLayout includes every transient section's height and interaction state, while SurfaceHost includes the active feature. Unknown or changed layouts use L034 reconstruction only when scrolled rows must return. Keys are captured with native render state and cleared on reset. This replaces the earlier completion-specific resize callback and full-viewport close exception. +- Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. - Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. -- Removal condition: the selected Pi baseline distinguishes inline completion layout shrink from ordinary visible activity shrink. +- Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 38bae221..999096c7 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,7 +331,6 @@ export class Editor implements Component, Focusable { private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); private autocompleteList?: SelectList; - private renderedAutocompleteRows = 0; private autocompleteSuggestions?: AutocompleteSuggestions; private autocompleteState: 'regular' | 'force' | null = null; private autocompletePrefix: string = ''; @@ -382,8 +381,6 @@ export class Editor implements Component, Focusable { public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void; - /** Lets hosts restore space occupied by a shrinking inline completion menu. */ - public onAutocompleteResize?: (previousRows: number, rows: number) => void; public onAutocompleteSelect?: ( suggestions: AutocompleteSuggestions, item: AutocompleteSuggestions['items'][number], @@ -698,10 +695,8 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active - this.renderedAutocompleteRows = 0; if (this.autocompleteState && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(contentWidth); - this.renderedAutocompleteRows = autocompleteResult.length; for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth)); @@ -786,7 +781,6 @@ export class Editor implements Component, Focusable { if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) { this.autocompleteList.handleInput(data); - this.notifyAutocompleteResize(); return; } @@ -1220,7 +1214,6 @@ export class Editor implements Component, Focusable { } dispose(): void { - this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2639,7 +2632,6 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; - this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2667,21 +2659,6 @@ export class Editor implements Component, Focusable { this.autocompleteSuggestions = undefined; this.autocompletePrefix = ''; this.reportedAutocompleteKind = undefined; - this.notifyAutocompleteResize(); - } - - private autocompleteRows(): number { - if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0; - // SelectList owns group headings and pagination rows; reuse its layout rather - // than assuming one row per candidate. Its rows do not wrap with terminal width. - return this.autocompleteList.render(this.tui.terminal.columns).length; - } - - private notifyAutocompleteResize(): void { - const rows = this.autocompleteRows(); - if (rows !== this.renderedAutocompleteRows) { - this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows); - } } private cancelAutocomplete(): void { diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index bfe68eb7..8d4d1741 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -1,7 +1,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 { type Component, type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; import { stripTerminalSequences, visibleWidth } from "./utils.js"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; @@ -114,6 +114,8 @@ export interface TuiMainScreenRenderState { hardwareCursorRow: number; maxLinesRendered: number; previousViewportTop: number; + viewportLayouts: { component: Component; key: string | undefined }[]; + hadOverlays: boolean; } /** TUI implementation that renders into the terminal's main screen and scrollback. */ @@ -129,12 +131,8 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; private historyReplayPending = false; - private layoutRenderPending = false; - - override requestLayoutRender(): void { - this.layoutRenderPending = true; - super.requestLayoutRender(); - } + private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; + private hadOverlays = false; protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -173,6 +171,8 @@ export class TuiMainScreen extends TuiBase implements TUI { hardwareCursorRow: this.hardwareCursorRow, maxLinesRendered: this.maxLinesRendered, previousViewportTop: this.previousViewportTop, + viewportLayouts: this.viewportLayouts.map((layout) => ({ ...layout })), + hadOverlays: this.hadOverlays, }; } @@ -187,10 +187,13 @@ export class TuiMainScreen extends TuiBase implements TUI { this.hardwareCursorRow = state.hardwareCursorRow; this.maxLinesRendered = state.maxLinesRendered; this.previousViewportTop = state.previousViewportTop; + this.viewportLayouts = state.viewportLayouts.map((layout) => ({ ...layout })); + this.hadOverlays = state.hadOverlays; } protected override resetRenderState(): void { - this.layoutRenderPending = false; + this.viewportLayouts = []; + this.hadOverlays = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -297,11 +300,21 @@ export class TuiMainScreen extends TuiBase implements TUI { return targetScreenRow - currentScreenRow; }; - const layoutRender = this.layoutRenderPending; - this.layoutRenderPending = false; // 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, "")); + const viewportLayouts = this.children.map((component) => ({ + component, + key: component.getViewportLayoutKey?.(), + })); + const stableLayout = viewportLayouts.length > 0 && + viewportLayouts.length === this.viewportLayouts.length && + viewportLayouts.every(({ component, key }, index) => + key !== undefined && component === this.viewportLayouts[index]?.component && + key === this.viewportLayouts[index]?.key); + this.viewportLayouts = viewportLayouts; + const hadOverlays = this.hadOverlays; + this.hadOverlays = this.hasOverlayEntries; // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { @@ -313,7 +326,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // screen instead. The composer stays at the bottom, historical rows stay unique, // and later output consumes this temporary space before scrolling again. if ( - !layoutRender && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + stableLayout && !hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index 7e3749a1..babec471 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -27,6 +27,13 @@ export interface Component { */ render(width: number): string[]; + /** + * Opt into preserving native scrolling when only background content shrinks. + * Return a key for the last rendered transient layout (menus, editor, banners). + * A changed or missing key restores exposed document rows instead of padding. + */ + getViewportLayoutKey?(): string | undefined; + /** * Optional handler for keyboard input when component has focus */ @@ -312,8 +319,6 @@ export interface TUI extends Component { renderNow(force?: boolean): void; requestRender(force?: boolean): void; requestImmediateRender(): void; - /** Restore rows exposed by a shrinking interactive layout instead of padding them. */ - requestLayoutRender(): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; @@ -787,10 +792,6 @@ export abstract class TuiBase extends Container implements TUI { process.nextTick(() => this.scheduleRender()); } - requestLayoutRender(): void { - this.requestImmediateRender(); - } - requestImmediateRender(): void { this.cancelRenderTimer(); this.renderRequested = true; diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index 79a74870..a8c4df22 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -41,6 +41,11 @@ interface MouseAwareComponent extends Component { } export class TuiChatLayout implements Component { + private viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } readonly fullscreenLayoutRoot: Component; private readonly fullscreenBodyViewport: ScrollView; private pendingFullscreenFrame: @@ -233,8 +238,15 @@ export class TuiChatLayout implements Component { goal.length + followUp.length + activity.length + composer.length + status.length, surface === 'conversation' ? 2 : Math.max(1, notice.length + 1), ); + // Activity/transcript updates may preserve host scrolling. Every transient + // section participates here so new controls cannot silently leave blank rows. + const viewportLayout = [ + surface, interactionActive, interaction.length, composer.length, followUp.length, + goal.length, notice.length, tasks.length, status.length, + ]; if (surface === 'welcome') { if (interactionActive) { + this.viewportLayoutKey = JSON.stringify([...viewportLayout, 0]); return this.fitDocumentFrame( this.viewport() === 'fixed' ? [interaction, activity, composer, status] @@ -259,6 +271,7 @@ export class TuiChatLayout implements Component { frame.horizontalPadding, ) : renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); return this.fitDocumentFrame([welcome, ...tailEntries]); } @@ -278,6 +291,7 @@ export class TuiChatLayout implements Component { ]; const transcript = renderPart(this.parts.transcript); const welcome = renderPart(this.parts.welcome); + this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); const prelude = joinWelcomeAndTranscript(welcome, transcript); const bodyEntries = [prelude, transcript.length > 0 ? [''] : []]; return this.fitDocumentFrame([...bodyEntries, ...footerEntries]); diff --git a/packages/tui/src/tui/shell/interaction-surface.ts b/packages/tui/src/tui/shell/interaction-surface.ts index 334ce475..288340bb 100644 --- a/packages/tui/src/tui/shell/interaction-surface.ts +++ b/packages/tui/src/tui/shell/interaction-surface.ts @@ -8,7 +8,7 @@ export class TuiInteractionSurface { constructor( private readonly host: TuiInlinePanelHost, private readonly surfaces: TuiSurfaceHost, - private readonly requestRender: (rebuild?: boolean) => void, + private readonly requestRender: () => void, private readonly onActiveChanged?: (active: boolean) => void, private readonly followFullscreenBottom?: () => void, ) {} @@ -38,13 +38,11 @@ export class TuiInteractionSurface { close(panel?: Component): boolean { if (!this.host.isActive(panel)) return false; - const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport; if (this.layer && !this.layer.close()) return false; this.layer = undefined; if (!this.host.close(panel)) return false; this.onActiveChanged?.(false); - if (rebuild) this.requestRender(true); - else this.requestRender(); + this.requestRender(); return true; } diff --git a/packages/tui/src/tui/shell/surface-host.ts b/packages/tui/src/tui/shell/surface-host.ts index 7510050f..a13f18df 100644 --- a/packages/tui/src/tui/shell/surface-host.ts +++ b/packages/tui/src/tui/shell/surface-host.ts @@ -220,6 +220,11 @@ export class TuiSurfaceHost implements Component { else this.chatComponent.invalidate(); } + getViewportLayoutKey(): string | undefined { + const key = this.chatComponent.getViewportLayoutKey?.(); + return key === undefined ? undefined : JSON.stringify([this.activeFeature()?.screen.id, key]); + } + 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/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index 8cc6ac7a..f81e3eff 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,14 +80,6 @@ export class Editor implements Component, Focusable { return this.engine.focused; } - get onAutocompleteResize(): PiEditor['onAutocompleteResize'] { - return this.engine.onAutocompleteResize; - } - - set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) { - this.engine.onAutocompleteResize = callback; - } - set focused(value: boolean) { this.engine.focused = value; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 670d8cdb..fac9a117 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,7 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; - const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); + const writesBefore = terminal.writes.length; terminal.input?.("/"); await vi.waitFor(() => @@ -2984,7 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); - expect(requestLayoutRender).not.toHaveBeenCalled(); + expect(terminal.writes.slice(writesBefore).join("")).not.toContain("\x1b[3J"); await app.stop(); }); @@ -6127,6 +6127,66 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["/theme", "/settings", "history search"])("restores the complete chat viewport after closing %s", async (entry) => { + const terminal = new VirtualTerminal(80, 24); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (entry === "history search") { + for (let index = 0; index < 12; index++) app.editor.addToHistory(`Message ${index}`); + terminal.sendInput("\x12"); + } + else await app.submit(entry); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(true); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(app.interaction.isActive()).toBe(false); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + for (let index = 0; index < 12; index++) { + expect(terminal.getScrollBuffer().filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + + it.each(["multiline draft", "image preview"])("restores the chat viewport after dismissing %s", async (kind) => { + const terminal = new ClearToScrollbackTerminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace", + resolveAttachment: async () => ({ type: "image", filePath: "/tmp/preview-missing.png", fileName: "preview.png", mimeType: "image/png", sizeBytes: 32768 }), + }); + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + if (kind === "multiline draft") { + app.editor.setText(Array.from({ length: 8 }, (_, index) => `Draft ${index}`).join("\n")); + } else { + terminal.sendInput("\x1b[200~/tmp/preview-missing.png\x1b[201~"); + await vi.waitFor(() => expect(app.editor.getAttachmentPreview()).toBeDefined()); + } + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain(kind === "multiline draft" ? "Draft 7" : "preview.png"); + terminal.sendInput(kind === "multiline draft" ? "\x03" : "\x1b"); + app.tui.renderNow(); + await terminal.flush(); + if (kind === "multiline draft") expect(app.editor.getText()).toBe(""); + else expect(app.editor.getAttachmentPreview()).toBeUndefined(); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + } finally { + await app.stop(); + } + }); + it("does not push the conversation downward after closing /usage", async () => { const terminal = new VirtualTerminal(80, 50); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 2ccd563a..592553fe 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -342,7 +342,12 @@ describe('Tasks in the regular terminal viewport', () => { const terminal = new VirtualTerminal(80, 16); const tui = new TuiMainScreen(terminal); const chatLines = Array.from({ length: 40 }, (_, index) => `chat-line-${index}`); - tui.addChild({ render: () => [...chatLines, 'COMPOSER', 'STATUS'], invalidate() {} }); + tui.addChild({ + render: () => [...chatLines, 'COMPOSER', 'STATUS'], + // This fixture settles background content while the transient footer stays fixed. + getViewportLayoutKey: () => 'composer:1,status:1', + invalidate() {}, + }); const tasks = Array.from({ length: 30 }, (_, index) => ({ ...backgroundTasks()[0]!, taskId: `task-${index}`, 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 da018735..8f5e95ce 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -11,6 +11,7 @@ import { type TUI, visibleWidth, } from '../../src/tui/engine/public.js'; +import { TuiChatLayout, type TuiChatLayoutParts } from '../../src/tui/shell/chat-layout.js'; import { VirtualTerminal } from '../pi-084-upstream/virtual-terminal.js'; const passthrough = (value: string): string => value; @@ -47,6 +48,11 @@ class ClearToScrollbackTerminal extends RecordingVirtualTerminal { class MutableLines implements Component { lines: string[] = []; + viewportLayoutKey: string | undefined; + + getViewportLayoutKey(): string | undefined { + return this.viewportLayoutKey; + } render(): string[] { return [...this.lines]; @@ -55,6 +61,25 @@ class MutableLines implements Component { invalidate(): void {} } +function createMutableChatParts(surface: 'welcome' | 'conversation') { + const parts = { + surface: () => surface, + welcome: new MutableLines(), + notice: new MutableLines(), + transcript: new MutableLines(), + interaction: new MutableLines(), + activity: new MutableLines(), + goal: new MutableLines(), + followUp: new MutableLines(), + tasks: new MutableLines(), + composer: new MutableLines(), + status: new MutableLines(), + } satisfies TuiChatLayoutParts; + parts.composer.lines = [`composer${CURSOR_MARKER}`]; + parts.status.lines = ['status']; + return parts; +} + describe('MCode Pi Engine local deltas', () => { it('does not replay a submitted input frame after a synchronous render', async () => { const terminal = new RecordingVirtualTerminal(60, 12); @@ -168,6 +193,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, @@ -203,6 +229,194 @@ describe('MCode Pi Engine local deltas', () => { expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'idle']); }); + describe.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('%s transient layout restoration', (_name, Terminal) => { + const transientParts = ['welcome', 'interaction', 'composer', 'notice', 'followUp', 'goal', 'tasks', 'status'] as const; + + it.each(transientParts)('restores the document tail after repeated %s collapses', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = history; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + + for (let cycle = 0; cycle < 3; cycle++) { + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${cycle}-${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + parts[part].lines = [...baseline]; + tui.renderNow(); + await terminal.flush(); + + const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); + expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); + expect(terminal.getScrollBuffer()).toEqual(expected); + for (const line of history) { + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); + } + } + }); + + it.each(transientParts)('erases a short %s without clearing host history', async (part) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts(part === 'notice' || part === 'welcome' ? 'welcome' : 'conversation'); + (part === 'notice' || part === 'welcome' ? parts.welcome : parts.transcript).lines = ['Short answer']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const baseline = [...parts[part].lines]; + parts[part].lines = [ + ...(part === 'welcome' ? baseline : []), + ...Array.from({ length: 8 }, (_, index) => `${part} ${index}`), + ]; + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + parts[part].lines = baseline; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + }); + + it.each([ + ['hide', 2], + ['hide', 40], + ['setHidden', 2], + ['setHidden', 40], + ] as const)('restores %s overlays after activity settles with %i history rows', async (close, historyRows) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: historyRows }, (_, index) => `History ${index}`); + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + const overlay = new MutableLines(); + overlay.lines = ['Overlay contents']; + + for (let cycle = 0; cycle < 2; cycle++) { + // Background activity changes do not alter the chat's transient layout key. + // The previous overlay frame must still prevent padding after it disappears. + parts.activity.lines = Array.from({ length: 5 }, (_, index) => `Activity ${index}`); + const handle = tui.showOverlay(overlay, { width: 24 }); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('Overlay contents'); + terminal.takeWrites(); + if (close === 'hide') handle.hide(); + else handle.setHidden(true); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport().join('\n')).not.toContain('Overlay contents'); + if (historyRows < terminal.rows) expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + // Hidden overlays remain registered; removing one must keep the restored frame. + handle.hide(); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual(expected); + } + }); + + it.each([false, true])('reconstructs when any root lacks a layout key (mixed roots: %s)', async (mixedRoots) => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + if (mixedRoots) { + component.viewportLayoutKey = 'stable-footer'; + tui.addChild(new MutableLines()); + } + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('tracks layout changes even when they produce an identical frame', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + const expected = terminal.getScrollBuffer(); + component.lines.splice(-2, 0, ...Array.from({ length: 8 }, (_, index) => `Transient ${index}`)); + tui.renderNow(); + await terminal.flush(); + + // A selector can replace a same-height footer without changing any rows. + component.viewportLayoutKey = 'open'; + tui.renderNow(); + await terminal.flush(); + component.viewportLayoutKey = 'closed'; + component.lines = [...history, 'composer', 'status']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + }); + + it('preserves a scrolled host viewport when only chat activity settles', async () => { + const terminal = new Terminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + parts.transcript.lines = Array.from({ length: 40 }, (_, index) => `History ${index}`); + parts.activity.lines = ['Activity 1', 'Activity 2', 'Activity 3']; + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + parts.activity.lines = []; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollPosition()).toEqual(before); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().filter((line) => line.trim().startsWith('History '))) + .toHaveLength(40); + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-2).map((line) => line.trim())).toEqual(['composer', 'status']); + }); + }); + it('ignores same-size resize notifications while the host is scrolled up', async () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); @@ -232,6 +446,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -296,6 +511,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer line ${index}`); component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; tui.addChild(component); @@ -338,6 +554,7 @@ describe('MCode Pi Engine local deltas', () => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); + component.viewportLayoutKey = 'stable-footer'; const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); component.lines = [ ...answer, diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index fbfc2b54..4ed7414b 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -71,10 +71,10 @@ function createRecordingPresenter(): { describe('TuiSurfaceHost', () => { it.each([ - { mode: 'regular', fullscreenViewport: true, rebuild: true }, - { mode: 'regular', fullscreenViewport: false, rebuild: false }, - { mode: 'fullscreen', fullscreenViewport: true, rebuild: false }, - ] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => { + { mode: 'regular', fullscreenViewport: true }, + { mode: 'regular', fullscreenViewport: false }, + { mode: 'fullscreen', fullscreenViewport: true }, + ] as const)('leaves layout restoration to the renderer on interaction close ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport }) => { const inline = new TuiInlinePanelHost(); const host = createSurfaceHost({ chat: { component: inline, focus: component([]) }, @@ -91,8 +91,7 @@ describe('TuiSurfaceHost', () => { requestRender.mockClear(); expect(interaction.close(panel)).toBe(true); expect(requestRender).toHaveBeenCalledOnce(); - if (rebuild) expect(requestRender).toHaveBeenCalledWith(true); - else expect(requestRender).toHaveBeenCalledWith(); + expect(requestRender).toHaveBeenCalledWith(); }); it.each(['regular', 'fullscreen'] as const)(