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
17 changes: 10 additions & 7 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions packages/tui/src/tui/app-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions packages/tui/src/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 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": "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 保留草稿;无参数命令保持原行为。"
},
Expand Down Expand Up @@ -163,15 +163,15 @@
},
{
"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 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。",
"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": "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 分页交给焦点面板;默认仍由外层视口处理。"
Expand Down
12 changes: 6 additions & 6 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
23 changes: 0 additions & 23 deletions packages/tui/src/tui/engine/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}

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

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

this.autocompleteState = state;
this.notifyAutocompleteResize();
const kind = suggestions.prefix.startsWith('/')
? '/'
: suggestions.prefix.startsWith('@')
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 24 additions & 11 deletions packages/tui/src/tui/engine/tui-main-screen.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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. */
Expand All @@ -129,12 +131,8 @@ 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();
}
private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = [];
private hadOverlays = false;

protected override onTerminalResize(): void {
// Some hosts repeat resize notifications while scrolling or reconnecting.
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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 = [];
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Loading
Loading