Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/tui/app-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
12 changes: 6 additions & 6 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 保留草稿;无参数命令保持原行为。"
},
Expand Down Expand Up @@ -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 分页交给焦点面板;默认仍由外层视口处理。"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions packages/tui/src/tui/engine/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1214,6 +1220,7 @@ export class Editor implements Component, Focusable {
}

dispose(): void {
this.onAutocompleteResize = undefined;
this.cancelAutocomplete();
this.autocompleteProvider = undefined;
this.onPaste = undefined;
Expand Down Expand Up @@ -2632,6 +2639,7 @@ export class Editor implements Component, Focusable {
}

this.autocompleteState = state;
this.notifyAutocompleteResize();
const kind = suggestions.prefix.startsWith('/')
? '/'
: suggestions.prefix.startsWith('@')
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion packages/tui/src/tui/engine/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export class TuiMainScreen extends TuiBase implements TUI {
private previousViewportTop = 0;
private resizeTimer: ReturnType<typeof setTimeout> | 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.
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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, ""));
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/tui/engine/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/tui/src/tui/widgets/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
79 changes: 79 additions & 0 deletions packages/tui/test/unit/tui-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2968,6 +2968,7 @@ describe("createTuiApp", () => {

app.start();
await app.ready;
const requestLayoutRender = vi.spyOn(app.tui, "requestLayoutRender");
terminal.input?.("/");

await vi.waitFor(() =>
Expand All @@ -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();
});
Expand Down Expand Up @@ -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;
Expand Down
Loading