From 55d68c874ec7dada29cb8cd47a6a34413ee42206 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 14:34:13 +0800 Subject: [PATCH] fix(tui): restore chat viewport after autocomplete shrinks --- docs/tui-capabilities.md | 3 + packages/tui/src/tui/app-composition.ts | 3 + .../tui/src/tui/engine/LOCAL_CHANGES.json | 12 +-- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ .../tui/src/tui/engine/components/editor.ts | 23 ++++++ .../tui/src/tui/engine/tui-main-screen.ts | 11 ++- packages/tui/src/tui/engine/tui.ts | 6 ++ packages/tui/src/tui/widgets/editor/editor.ts | 8 ++ packages/tui/test/unit/tui-app.test.ts | 79 +++++++++++++++++++ 9 files changed, 146 insertions(+), 7 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 61348fe7..dfd23df2 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -87,6 +87,9 @@ 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. Freed rows temporarily remain blank at the top of the active screen and subsequent diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 2a823ca6..4db84b77 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -208,6 +208,9 @@ 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; } diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 5c9eedcd..f29800f9 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": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], + "currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"], "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,16 +163,16 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"], + "currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226", + "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 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", "behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." }, { "path": "tui.ts", - "currentSha256": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"], + "currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a", + "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 e9c7d159..31e2bbb8 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -162,3 +162,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided. - 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 + +- 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. +- 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. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..38bae221 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -331,6 +331,7 @@ 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 = ''; @@ -381,6 +382,8 @@ 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], @@ -695,8 +698,10 @@ 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)); @@ -781,6 +786,7 @@ 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; } @@ -1214,6 +1220,7 @@ export class Editor implements Component, Focusable { } dispose(): void { + this.onAutocompleteResize = undefined; this.cancelAutocomplete(); this.autocompleteProvider = undefined; this.onPaste = undefined; @@ -2632,6 +2639,7 @@ export class Editor implements Component, Focusable { } this.autocompleteState = state; + this.notifyAutocompleteResize(); const kind = suggestions.prefix.startsWith('/') ? '/' : suggestions.prefix.startsWith('@') @@ -2659,6 +2667,21 @@ 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 44901c93..bfe68eb7 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -129,6 +129,12 @@ 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(); + } protected override onTerminalResize(): void { // Some hosts repeat resize notifications while scrolling or reconnecting. @@ -184,6 +190,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } protected override resetRenderState(): void { + this.layoutRenderPending = false; this.cancelResize(); this.historyReplayPending = false; this.previousLines = []; @@ -290,6 +297,8 @@ 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, "")); @@ -304,7 +313,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 ( - !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + !layoutRender && !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 89cd0bb3..7e3749a1 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -312,6 +312,8 @@ 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; @@ -785,6 +787,10 @@ 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/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..8cc6ac7a 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -80,6 +80,14 @@ 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 9a922ceb..505f1d3e 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -2968,6 +2968,7 @@ describe("createTuiApp", () => { app.start(); await app.ready; + const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender"); terminal.input?.("/"); await vi.waitFor(() => @@ -2983,6 +2984,7 @@ describe("createTuiApp", () => { terminal.input?.("\u001B"); expect(app.editor.getText()).toBe("/"); expect(app.editor.focused).toBe(true); + expect(requestLayoutRender).not.toHaveBeenCalled(); await app.stop(); }); @@ -3026,6 +3028,83 @@ describe("createTuiApp", () => { await app.stop(); }); + describe.each([ + ["xterm", VirtualTerminal], + ["clear-to-scrollback terminal", ClearToScrollbackTerminal], + ] as const)("autocomplete viewport restoration on %s", (_terminalName, Terminal) => { + it.each([ + ["Escape", "\x1b", "/"], + ["Tab", "\t", "/help "], + ["Backspace", "\x7f", ""], + ["no matches", "zzzzzz", "/zzzzzz"], + ["filter to one command", "help", "/help"], + ] as const)("restores the conversation viewport when Slash Command autocomplete shrinks via %s", async (_name, input, draft) => { + const terminal = new Terminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + version: "0.2.0", + workspaceDir: "/workspace", + tuiMode: "regular", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + app.tui.renderNow(); + await terminal.flush(); + + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + const menuRows = app.editor.render(78).length; + terminal.sendInput(input); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(app.editor.getText()).toBe(draft); + expect(app.editor.render(78).length).toBeLessThan(menuRows); + const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); + expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + }); + const history = terminal.getScrollBuffer(); + for (let index = 0; index < 12; index++) { + expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + } finally { + await app.stop(); + } + }); + }); + + it("dismisses autocomplete in a short document without clearing native history", async () => { + const terminal = new VirtualTerminal(80, 100); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" }); + const write = vi.spyOn(terminal, "write"); + app.start(); + try { + await app.ready; + terminal.sendInput("/"); + await vi.waitFor(async () => { + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Show available commands"); + }); + write.mockClear(); + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).not.toContain("Show available commands"); + expect(write.mock.calls.map(([data]) => data).join("")).not.toContain("\x1b[3J"); + } finally { + await app.stop(); + } + }); + it("resumes fullscreen tail following when submitting from scrolled history", async () => { const terminal = new FakeTerminal(); terminal.rows = 6;