Skip to content

Commit ffbfff6

Browse files
authored
fix(tui): restore chat viewport after autocomplete shrinks (#331)
1 parent b2f146a commit ffbfff6

9 files changed

Lines changed: 146 additions & 7 deletions

File tree

‎docs/tui-capabilities.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ In regular mode, independent feature panels occupy the complete visible terminal
8787
area, including short Rewind previews and scope pickers. Closing a panel restores
8888
the current conversation. Closing a full-viewport interaction rebuilds the chat
8989
screen so its temporary rows do not leave a large blank area above the conversation.
90+
Closing or shrinking a Composer completion menu also restores the exposed chat
91+
rows. Short documents refresh in place; history is reconstructed only when the
92+
smaller layout needs to bring scrolled rows back into view.
9093
When running content shrinks entirely within the current screen, the renderer
9194
keeps native scrollback and the Composer position stable.
9295
Freed rows temporarily remain blank at the top of the active screen and subsequent

‎packages/tui/src/tui/app-composition.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ export function createTuiApplicationEditor(
208208
autocompleteMaxVisible: 8,
209209
placeholder: composerText('placeholder'),
210210
});
211+
editor.onAutocompleteResize = (previousRows, rows) => {
212+
if (tui.mode === 'regular' && rows < previousRows) tui.requestLayoutRender();
213+
};
211214
editor.setAutocompleteProvider(createTuiInitialAutocomplete(workspaceRoots.list(), runtime));
212215
return editor;
213216
}

‎packages/tui/src/tui/engine/LOCAL_CHANGES.json‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
},
2222
{
2323
"path": "components/editor.ts",
24-
"currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5",
25-
"changeIds": ["L005", "L009", "L022", "L030", "L032"],
24+
"currentSha256": "0c257eb6225d4847a78152b14bd12fe299223541b193a5f4bc0c5667680c7b52",
25+
"changeIds": ["L005", "L009", "L022", "L030", "L032", "L041"],
2626
"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 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。",
2727
"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 保留草稿;无参数命令保持原行为。"
2828
},
@@ -163,16 +163,16 @@
163163
},
164164
{
165165
"path": "tui-main-screen.ts",
166-
"currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7",
167-
"changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"],
166+
"currentSha256": "f0127d16377ed9f159df5e2e78aeae11f9b05bbb857b0ea31ff944dea6869226",
167+
"changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"],
168168
"upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91",
169169
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。",
170170
"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."
171171
},
172172
{
173173
"path": "tui.ts",
174-
"currentSha256": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3",
175-
"changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"],
174+
"currentSha256": "27c80c6c58f0eb35a4f3e199123aca47c95296abd4dd46b922df770effa9d42a",
175+
"changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"],
176176
"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 及焦点输入过滤 独立面板声明键盘分页归属。",
177177
"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 分页交给焦点面板;默认仍由外层视口处理。"
178178
},

‎packages/tui/src/tui/engine/LOCAL_CHANGES.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
162162
- User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided.
163163
- 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.
164164
- Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering.
165+
166+
## L041: Restore chat rows after inline completion shrink
167+
168+
- 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.
169+
- 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.
170+
- 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.
171+
- Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance.
172+
- Removal condition: the selected Pi baseline distinguishes inline completion layout shrink from ordinary visible activity shrink.

‎packages/tui/src/tui/engine/components/editor.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export class Editor implements Component, Focusable {
331331
private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
332332
private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
333333
private autocompleteList?: SelectList;
334+
private renderedAutocompleteRows = 0;
334335
private autocompleteSuggestions?: AutocompleteSuggestions;
335336
private autocompleteState: 'regular' | 'force' | null = null;
336337
private autocompletePrefix: string = '';
@@ -381,6 +382,8 @@ export class Editor implements Component, Focusable {
381382
public onChange?: (text: string) => void;
382383
public onPaste?: (text: string) => boolean;
383384
public onAutocompleteView?: (suggestions: AutocompleteSuggestions) => void;
385+
/** Lets hosts restore space occupied by a shrinking inline completion menu. */
386+
public onAutocompleteResize?: (previousRows: number, rows: number) => void;
384387
public onAutocompleteSelect?: (
385388
suggestions: AutocompleteSuggestions,
386389
item: AutocompleteSuggestions['items'][number],
@@ -695,8 +698,10 @@ export class Editor implements Component, Focusable {
695698
}
696699

697700
// Add autocomplete list if active
701+
this.renderedAutocompleteRows = 0;
698702
if (this.autocompleteState && this.autocompleteList) {
699703
const autocompleteResult = this.autocompleteList.render(contentWidth);
704+
this.renderedAutocompleteRows = autocompleteResult.length;
700705
for (const line of autocompleteResult) {
701706
const lineWidth = visibleWidth(line);
702707
const linePadding = ' '.repeat(Math.max(0, contentWidth - lineWidth));
@@ -781,6 +786,7 @@ export class Editor implements Component, Focusable {
781786

782787
if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) {
783788
this.autocompleteList.handleInput(data);
789+
this.notifyAutocompleteResize();
784790
return;
785791
}
786792

@@ -1214,6 +1220,7 @@ export class Editor implements Component, Focusable {
12141220
}
12151221

12161222
dispose(): void {
1223+
this.onAutocompleteResize = undefined;
12171224
this.cancelAutocomplete();
12181225
this.autocompleteProvider = undefined;
12191226
this.onPaste = undefined;
@@ -2632,6 +2639,7 @@ export class Editor implements Component, Focusable {
26322639
}
26332640

26342641
this.autocompleteState = state;
2642+
this.notifyAutocompleteResize();
26352643
const kind = suggestions.prefix.startsWith('/')
26362644
? '/'
26372645
: suggestions.prefix.startsWith('@')
@@ -2659,6 +2667,21 @@ export class Editor implements Component, Focusable {
26592667
this.autocompleteSuggestions = undefined;
26602668
this.autocompletePrefix = '';
26612669
this.reportedAutocompleteKind = undefined;
2670+
this.notifyAutocompleteResize();
2671+
}
2672+
2673+
private autocompleteRows(): number {
2674+
if (!this.onAutocompleteResize || !this.autocompleteState || !this.autocompleteList) return 0;
2675+
// SelectList owns group headings and pagination rows; reuse its layout rather
2676+
// than assuming one row per candidate. Its rows do not wrap with terminal width.
2677+
return this.autocompleteList.render(this.tui.terminal.columns).length;
2678+
}
2679+
2680+
private notifyAutocompleteResize(): void {
2681+
const rows = this.autocompleteRows();
2682+
if (rows !== this.renderedAutocompleteRows) {
2683+
this.onAutocompleteResize?.(this.renderedAutocompleteRows, rows);
2684+
}
26622685
}
26632686

26642687
private cancelAutocomplete(): void {

‎packages/tui/src/tui/engine/tui-main-screen.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ export class TuiMainScreen extends TuiBase implements TUI {
129129
private previousViewportTop = 0;
130130
private resizeTimer: ReturnType<typeof setTimeout> | undefined;
131131
private historyReplayPending = false;
132+
private layoutRenderPending = false;
133+
134+
override requestLayoutRender(): void {
135+
this.layoutRenderPending = true;
136+
super.requestLayoutRender();
137+
}
132138

133139
protected override onTerminalResize(): void {
134140
// Some hosts repeat resize notifications while scrolling or reconnecting.
@@ -184,6 +190,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
184190
}
185191

186192
protected override resetRenderState(): void {
193+
this.layoutRenderPending = false;
187194
this.cancelResize();
188195
this.historyReplayPending = false;
189196
this.previousLines = [];
@@ -290,6 +297,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
290297
return targetScreenRow - currentScreenRow;
291298
};
292299

300+
const layoutRender = this.layoutRenderPending;
301+
this.layoutRenderPending = false;
293302
// Render all components to get new lines. Strip OSC 133 zone sentinels before the
294303
// differential compare so they never enter previousLines or any terminal write.
295304
let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
@@ -304,7 +313,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
304313
// screen instead. The composer stays at the bottom, historical rows stay unique,
305314
// and later output consumes this temporary space before scrolling again.
306315
if (
307-
!widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries &&
316+
!layoutRender && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries &&
308317
prevViewportTop > 0 && newLines.length > prevViewportTop &&
309318
newLines.length < prevViewportTop + height &&
310319
this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine)

‎packages/tui/src/tui/engine/tui.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,8 @@ export interface TUI extends Component {
312312
renderNow(force?: boolean): void;
313313
requestRender(force?: boolean): void;
314314
requestImmediateRender(): void;
315+
/** Restore rows exposed by a shrinking interactive layout instead of padding them. */
316+
requestLayoutRender(): void;
315317
addInputListener(listener: TuiInputListener): () => void;
316318
removeInputListener(listener: TuiInputListener): void;
317319
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
@@ -785,6 +787,10 @@ export abstract class TuiBase extends Container implements TUI {
785787
process.nextTick(() => this.scheduleRender());
786788
}
787789

790+
requestLayoutRender(): void {
791+
this.requestImmediateRender();
792+
}
793+
788794
requestImmediateRender(): void {
789795
this.cancelRenderTimer();
790796
this.renderRequested = true;

‎packages/tui/src/tui/widgets/editor/editor.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ export class Editor implements Component, Focusable {
8080
return this.engine.focused;
8181
}
8282

83+
get onAutocompleteResize(): PiEditor['onAutocompleteResize'] {
84+
return this.engine.onAutocompleteResize;
85+
}
86+
87+
set onAutocompleteResize(callback: PiEditor['onAutocompleteResize']) {
88+
this.engine.onAutocompleteResize = callback;
89+
}
90+
8391
set focused(value: boolean) {
8492
this.engine.focused = value;
8593
}

‎packages/tui/test/unit/tui-app.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2968,6 +2968,7 @@ describe("createTuiApp", () => {
29682968

29692969
app.start();
29702970
await app.ready;
2971+
const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender");
29712972
terminal.input?.("/");
29722973

29732974
await vi.waitFor(() =>
@@ -2983,6 +2984,7 @@ describe("createTuiApp", () => {
29832984
terminal.input?.("\u001B");
29842985
expect(app.editor.getText()).toBe("/");
29852986
expect(app.editor.focused).toBe(true);
2987+
expect(requestLayoutRender).not.toHaveBeenCalled();
29862988

29872989
await app.stop();
29882990
});
@@ -3026,6 +3028,83 @@ describe("createTuiApp", () => {
30263028
await app.stop();
30273029
});
30283030

3031+
describe.each([
3032+
["xterm", VirtualTerminal],
3033+
["clear-to-scrollback terminal", ClearToScrollbackTerminal],
3034+
] as const)("autocomplete viewport restoration on %s", (_terminalName, Terminal) => {
3035+
it.each([
3036+
["Escape", "\x1b", "/"],
3037+
["Tab", "\t", "/help "],
3038+
["Backspace", "\x7f", ""],
3039+
["no matches", "zzzzzz", "/zzzzzz"],
3040+
["filter to one command", "help", "/help"],
3041+
] as const)("restores the conversation viewport when Slash Command autocomplete shrinks via %s", async (_name, input, draft) => {
3042+
const terminal = new Terminal(80, 24);
3043+
const app = createTuiApp({
3044+
runtime: createRuntime(),
3045+
terminal,
3046+
version: "0.2.0",
3047+
workspaceDir: "/workspace",
3048+
tuiMode: "regular",
3049+
});
3050+
3051+
app.start();
3052+
try {
3053+
await app.ready;
3054+
for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`);
3055+
app.tui.renderNow();
3056+
await terminal.flush();
3057+
3058+
terminal.sendInput("/");
3059+
await vi.waitFor(async () => {
3060+
app.tui.renderNow();
3061+
await terminal.flush();
3062+
expect(terminal.getViewport().join("\n")).toContain("Show available commands");
3063+
});
3064+
const menuRows = app.editor.render(78).length;
3065+
terminal.sendInput(input);
3066+
await vi.waitFor(async () => {
3067+
app.tui.renderNow();
3068+
await terminal.flush();
3069+
expect(app.editor.getText()).toBe(draft);
3070+
expect(app.editor.render(78).length).toBeLessThan(menuRows);
3071+
const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24);
3072+
expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected);
3073+
});
3074+
const history = terminal.getScrollBuffer();
3075+
for (let index = 0; index < 12; index++) {
3076+
expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1);
3077+
}
3078+
} finally {
3079+
await app.stop();
3080+
}
3081+
});
3082+
});
3083+
3084+
it("dismisses autocomplete in a short document without clearing native history", async () => {
3085+
const terminal = new VirtualTerminal(80, 100);
3086+
const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.2.0", workspaceDir: "/workspace" });
3087+
const write = vi.spyOn(terminal, "write");
3088+
app.start();
3089+
try {
3090+
await app.ready;
3091+
terminal.sendInput("/");
3092+
await vi.waitFor(async () => {
3093+
app.tui.renderNow();
3094+
await terminal.flush();
3095+
expect(terminal.getViewport().join("\n")).toContain("Show available commands");
3096+
});
3097+
write.mockClear();
3098+
terminal.sendInput("\x1b");
3099+
app.tui.renderNow();
3100+
await terminal.flush();
3101+
expect(terminal.getViewport().join("\n")).not.toContain("Show available commands");
3102+
expect(write.mock.calls.map(([data]) => data).join("")).not.toContain("\x1b[3J");
3103+
} finally {
3104+
await app.stop();
3105+
}
3106+
});
3107+
30293108
it("resumes fullscreen tail following when submitting from scrolled history", async () => {
30303109
const terminal = new FakeTerminal();
30313110
terminal.rows = 6;

0 commit comments

Comments
 (0)