@dungle-scrubs/tallow 0.9.3 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +7 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +24 -10
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +285 -148
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/interactive-reset.d.ts +49 -0
- package/dist/interactive-reset.d.ts.map +1 -0
- package/dist/interactive-reset.js +40 -0
- package/dist/interactive-reset.js.map +1 -0
- package/dist/pi-tui-editor-patch.d.ts +10 -0
- package/dist/pi-tui-editor-patch.d.ts.map +1 -0
- package/dist/pi-tui-editor-patch.js +159 -0
- package/dist/pi-tui-editor-patch.js.map +1 -0
- package/dist/pi-tui-patch.d.ts +2 -0
- package/dist/pi-tui-patch.d.ts.map +1 -0
- package/dist/pi-tui-patch.js +563 -0
- package/dist/pi-tui-patch.js.map +1 -0
- package/dist/pi-tui-settings-list-patch.d.ts +11 -0
- package/dist/pi-tui-settings-list-patch.d.ts.map +1 -0
- package/dist/pi-tui-settings-list-patch.js +38 -0
- package/dist/pi-tui-settings-list-patch.js.map +1 -0
- package/dist/reset-diagnostics.d.ts +69 -0
- package/dist/reset-diagnostics.d.ts.map +1 -0
- package/dist/reset-diagnostics.js +41 -0
- package/dist/reset-diagnostics.js.map +1 -0
- package/dist/sdk.d.ts +5 -21
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +180 -149
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts +1 -0
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +7 -17
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +6 -16
- package/extensions/__integration__/teams-runtime.test.ts +4 -1
- package/extensions/_icons/index.ts +2 -4
- package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
- package/extensions/_shared/__tests__/terminal-links.test.ts +18 -0
- package/extensions/_shared/image-metadata.ts +99 -0
- package/extensions/_shared/inline-preview.ts +1 -1
- package/extensions/_shared/pid-registry.ts +0 -1
- package/extensions/_shared/terminal-links.ts +22 -0
- package/extensions/ask-user-question-tool/index.ts +0 -3
- package/extensions/clear/__tests__/clear.test.ts +270 -3
- package/extensions/command-expansion/index.ts +1 -1
- package/extensions/context-files/index.ts +5 -1
- package/extensions/context-fork/__tests__/context-fork.test.ts +94 -1
- package/extensions/context-fork/extension.json +1 -1
- package/extensions/context-fork/index.ts +32 -0
- package/extensions/edit-tool-enhanced/index.ts +2 -1
- package/extensions/hooks/index.ts +33 -11
- package/extensions/loop/index.ts +14 -1
- package/extensions/lsp/index.ts +64 -13
- package/extensions/lsp/package.json +2 -2
- package/extensions/permissions/__tests__/permissions.test.ts +4 -4
- package/extensions/random-spinner/index.ts +7 -642
- package/extensions/read-tool-enhanced/index.ts +6 -8
- package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +4 -5
- package/extensions/render-stabilizer/index.ts +6 -6
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +1 -1
- package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +26 -0
- package/extensions/slash-command-bridge/index.ts +14 -2
- package/extensions/subagent-tool/index.ts +1 -1
- package/extensions/subagent-tool/model-resolver.ts +274 -7
- package/extensions/tasks/__tests__/state-ui.test.ts +3 -3
- package/extensions/tasks/__tests__/widget-subagents.test.ts +2 -2
- package/extensions/tasks/commands/register-tasks-extension.ts +10 -10
- package/extensions/tasks/state/index.ts +1 -1
- package/extensions/tasks/ui/index.ts +2 -7
- package/extensions/teams-tool/tools/register-extension.ts +1 -3
- package/extensions/web-search-tool/index.ts +2 -1
- package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +21 -6
- package/extensions/write-tool-enhanced/index.ts +2 -1
- package/node_modules/@mariozechner/pi-tui/README.md +56 -34
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts +18 -13
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js +182 -113
- package/node_modules/@mariozechner/pi-tui/dist/autocomplete.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js +3 -3
- package/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts +45 -36
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.js +489 -325
- package/node_modules/@mariozechner/pi-tui/dist/components/editor.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts +1 -99
- package/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/image.js +17 -192
- package/node_modules/@mariozechner/pi-tui/dist/components/image.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/input.js +57 -60
- package/node_modules/@mariozechner/pi-tui/dist/components/input.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts +2 -69
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.js +5 -102
- package/node_modules/@mariozechner/pi-tui/dist/components/loader.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js +111 -53
- package/node_modules/@mariozechner/pi-tui/dist/components/markdown.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts +19 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js +78 -67
- package/node_modules/@mariozechner/pi-tui/dist/components/select-list.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts +0 -2
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js +12 -23
- package/node_modules/@mariozechner/pi-tui/dist/components/settings-list.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +8 -10
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +6 -9
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +108 -238
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +108 -365
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +33 -48
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +239 -155
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts +14 -94
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js +44 -186
- package/node_modules/@mariozechner/pi-tui/dist/terminal-image.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +13 -58
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +78 -111
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +24 -110
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -435
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts +0 -18
- package/node_modules/@mariozechner/pi-tui/dist/utils.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/utils.js +251 -119
- package/node_modules/@mariozechner/pi-tui/dist/utils.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +6 -6
- package/node_modules/@mariozechner/pi-tui/src/__tests__/__snapshots__/render.test.ts.snap +3 -40
- package/node_modules/@mariozechner/pi-tui/src/__tests__/image-component.test.ts +71 -81
- package/node_modules/@mariozechner/pi-tui/src/__tests__/render.test.ts +0 -33
- package/node_modules/@mariozechner/pi-tui/src/__tests__/terminal-image.test.ts +93 -334
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/utils.test.ts +11 -196
- package/node_modules/@mariozechner/pi-tui/src/autocomplete.ts +228 -142
- package/node_modules/@mariozechner/pi-tui/src/components/cancellable-loader.ts +3 -3
- package/node_modules/@mariozechner/pi-tui/src/components/editor.ts +624 -390
- package/node_modules/@mariozechner/pi-tui/src/components/image.ts +17 -227
- package/node_modules/@mariozechner/pi-tui/src/components/input.ts +71 -63
- package/node_modules/@mariozechner/pi-tui/src/components/loader.ts +5 -137
- package/node_modules/@mariozechner/pi-tui/src/components/markdown.ts +143 -52
- package/node_modules/@mariozechner/pi-tui/src/components/select-list.ts +136 -70
- package/node_modules/@mariozechner/pi-tui/src/components/settings-list.ts +11 -23
- package/node_modules/@mariozechner/pi-tui/src/index.ts +17 -36
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +148 -421
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +253 -181
- package/node_modules/@mariozechner/pi-tui/src/terminal-image.ts +51 -252
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +78 -133
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +202 -478
- package/node_modules/@mariozechner/pi-tui/src/utils.ts +289 -125
- package/node_modules/@mariozechner/pi-tui/tsconfig.build.json +1 -0
- package/package.json +13 -13
- package/packages/tallow-tui/node_modules/@types/mime-types/README.md +8 -2
- package/packages/tallow-tui/node_modules/@types/mime-types/index.d.ts +6 -0
- package/packages/tallow-tui/node_modules/@types/mime-types/package.json +9 -3
- package/packages/tallow-tui/node_modules/get-east-asian-width/lookup-data.js +18 -0
- package/packages/tallow-tui/node_modules/get-east-asian-width/lookup.js +116 -384
- package/packages/tallow-tui/node_modules/get-east-asian-width/package.json +5 -4
- package/packages/tallow-tui/node_modules/get-east-asian-width/utilities.js +24 -0
- package/packages/tallow-tui/node_modules/marked/README.md +5 -4
- package/packages/tallow-tui/node_modules/marked/bin/main.js +10 -8
- package/packages/tallow-tui/node_modules/marked/bin/marked.js +2 -1
- package/packages/tallow-tui/node_modules/marked/lib/marked.d.ts +156 -125
- package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js +67 -2179
- package/packages/tallow-tui/node_modules/marked/lib/marked.esm.js.map +3 -3
- package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js +67 -2201
- package/packages/tallow-tui/node_modules/marked/lib/marked.umd.js.map +3 -3
- package/packages/tallow-tui/node_modules/marked/man/marked.1 +4 -2
- package/packages/tallow-tui/node_modules/marked/man/marked.1.md +2 -1
- package/packages/tallow-tui/node_modules/marked/package.json +26 -34
- package/runtime/model-metadata-overrides.ts +10 -1
- package/runtime/pid-schema.ts +26 -6
- package/skills/tallow-expert/SKILL.md +1 -3
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts +0 -32
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.js +0 -46
- package/node_modules/@mariozechner/pi-tui/dist/border-styles.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts +0 -52
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js +0 -89
- package/node_modules/@mariozechner/pi-tui/dist/components/bordered-box.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts +0 -14
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.d.ts.map +0 -1
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js +0 -55
- package/node_modules/@mariozechner/pi-tui/dist/test-utils/capability-env.js.map +0 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-change-listener.test.ts +0 -121
- package/node_modules/@mariozechner/pi-tui/src/__tests__/editor-ghost-text.test.ts +0 -112
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +0 -134
- package/node_modules/@mariozechner/pi-tui/src/__tests__/settings-list.test.ts +0 -49
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +0 -555
- package/node_modules/@mariozechner/pi-tui/src/border-styles.ts +0 -60
- package/node_modules/@mariozechner/pi-tui/src/components/bordered-box.ts +0 -113
- package/node_modules/@mariozechner/pi-tui/src/test-utils/capability-env.ts +0 -56
- package/packages/tallow-tui/node_modules/marked/lib/marked.cjs +0 -2211
- package/packages/tallow-tui/node_modules/marked/lib/marked.cjs.map +0 -7
- package/packages/tallow-tui/node_modules/marked/lib/marked.d.cts +0 -728
- package/packages/tallow-tui/node_modules/marked/marked.min.js +0 -69
|
@@ -5,22 +5,11 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
isMouseEvent,
|
|
11
|
-
type MouseEvent,
|
|
12
|
-
matchesKey,
|
|
13
|
-
parseMouseEvent,
|
|
14
|
-
} from "./keys.js";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
9
|
+
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
15
10
|
import type { Terminal } from "./terminal.js";
|
|
16
11
|
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
17
|
-
import {
|
|
18
|
-
extractSegments,
|
|
19
|
-
sliceByColumn,
|
|
20
|
-
sliceWithWidth,
|
|
21
|
-
truncateToWidth,
|
|
22
|
-
visibleWidth,
|
|
23
|
-
} from "./utils.js";
|
|
12
|
+
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
24
13
|
|
|
25
14
|
/**
|
|
26
15
|
* Component interface - all components must implement this
|
|
@@ -51,6 +40,9 @@ export interface Component {
|
|
|
51
40
|
invalidate(): void;
|
|
52
41
|
}
|
|
53
42
|
|
|
43
|
+
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
44
|
+
type InputListener = (data: string) => InputListenerResult;
|
|
45
|
+
|
|
54
46
|
/**
|
|
55
47
|
* Interface for components that can receive focus and display a hardware cursor.
|
|
56
48
|
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
@@ -116,6 +108,10 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
|
|
|
116
108
|
return undefined;
|
|
117
109
|
}
|
|
118
110
|
|
|
111
|
+
function isTermuxSession(): boolean {
|
|
112
|
+
return Boolean(process.env.TERMUX_VERSION);
|
|
113
|
+
}
|
|
114
|
+
|
|
119
115
|
/**
|
|
120
116
|
* Options for overlay positioning and sizing.
|
|
121
117
|
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
@@ -154,6 +150,8 @@ export interface OverlayOptions {
|
|
|
154
150
|
* Called each render cycle with current terminal dimensions.
|
|
155
151
|
*/
|
|
156
152
|
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
153
|
+
/** If true, don't capture keyboard focus when shown */
|
|
154
|
+
nonCapturing?: boolean;
|
|
157
155
|
}
|
|
158
156
|
|
|
159
157
|
/**
|
|
@@ -166,6 +164,12 @@ export interface OverlayHandle {
|
|
|
166
164
|
setHidden(hidden: boolean): void;
|
|
167
165
|
/** Check if overlay is temporarily hidden */
|
|
168
166
|
isHidden(): boolean;
|
|
167
|
+
/** Focus this overlay and bring it to the visual front */
|
|
168
|
+
focus(): void;
|
|
169
|
+
/** Release focus to the previous target */
|
|
170
|
+
unfocus(): void;
|
|
171
|
+
/** Check if this overlay currently has focus */
|
|
172
|
+
isFocused(): boolean;
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
/**
|
|
@@ -198,7 +202,10 @@ export class Container implements Component {
|
|
|
198
202
|
render(width: number): string[] {
|
|
199
203
|
const lines: string[] = [];
|
|
200
204
|
for (const child of this.children) {
|
|
201
|
-
|
|
205
|
+
const childLines = child.render(width);
|
|
206
|
+
for (const line of childLines) {
|
|
207
|
+
lines.push(line);
|
|
208
|
+
}
|
|
202
209
|
}
|
|
203
210
|
return lines;
|
|
204
211
|
}
|
|
@@ -211,38 +218,33 @@ export class TUI extends Container {
|
|
|
211
218
|
public terminal: Terminal;
|
|
212
219
|
private previousLines: string[] = [];
|
|
213
220
|
private previousWidth = 0;
|
|
221
|
+
private previousHeight = 0;
|
|
214
222
|
private focusedComponent: Component | null = null;
|
|
223
|
+
private inputListeners = new Set<InputListener>();
|
|
215
224
|
|
|
216
225
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
217
226
|
public onDebug?: () => void;
|
|
218
|
-
/**
|
|
219
|
-
* Callback for mouse events. Called when a mouse event is received.
|
|
220
|
-
* Scroll events are the primary use case (scroll-up, scroll-down).
|
|
221
|
-
* Return value is ignored — mouse events are always consumed and never
|
|
222
|
-
* forwarded to focused components.
|
|
223
|
-
*/
|
|
224
|
-
public onMouse?: (event: MouseEvent) => void;
|
|
225
227
|
private renderRequested = false;
|
|
226
|
-
private
|
|
228
|
+
private renderTimer: NodeJS.Timeout | undefined;
|
|
229
|
+
private lastRenderAt = 0;
|
|
230
|
+
private static readonly MIN_RENDER_INTERVAL_MS = 16;
|
|
227
231
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
228
232
|
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
229
|
-
private inputBuffer = ""; // Buffer for parsing terminal responses
|
|
230
|
-
private cellSizeQueryPending = false;
|
|
231
233
|
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
232
234
|
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
233
235
|
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
234
236
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
235
237
|
private fullRedrawCount = 0;
|
|
236
|
-
private rollingShrinkPeak = 0; // Recent peak line count for gradual shrink detection
|
|
237
238
|
private stopped = false;
|
|
238
|
-
private pendingScrollbackClear = false; // Clear scrollback on next full render (session breaks)
|
|
239
239
|
|
|
240
240
|
// Overlay stack for modal components rendered on top of base content
|
|
241
|
+
private focusOrderCounter = 0;
|
|
241
242
|
private overlayStack: {
|
|
242
243
|
component: Component;
|
|
243
244
|
options?: OverlayOptions;
|
|
244
245
|
preFocus: Component | null;
|
|
245
246
|
hidden: boolean;
|
|
247
|
+
focusOrder: number;
|
|
246
248
|
}[] = [];
|
|
247
249
|
|
|
248
250
|
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
|
|
@@ -283,34 +285,6 @@ export class TUI extends Container {
|
|
|
283
285
|
this.clearOnShrink = enabled;
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
/**
|
|
287
|
-
* Reset the startup grace period timer, suppressing screen-clearing full
|
|
288
|
-
* redraws for another {@link STARTUP_GRACE_MS} milliseconds.
|
|
289
|
-
*
|
|
290
|
-
* Call this at the start of a session switch so the chatContainer.clear()
|
|
291
|
-
* → renderInitialMessages() transition doesn't cause visible flicker.
|
|
292
|
-
*
|
|
293
|
-
* @returns {void}
|
|
294
|
-
*/
|
|
295
|
-
resetRenderGrace(): void {
|
|
296
|
-
this.startedAtMs = Date.now();
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Request that the next full render clears the terminal scrollback buffer.
|
|
301
|
-
*
|
|
302
|
-
* Use when the session content is being replaced wholesale (workspace
|
|
303
|
-
* transitions, new sessions, session switches) so stale scrollback
|
|
304
|
-
* doesn't visually flow into the new content.
|
|
305
|
-
*
|
|
306
|
-
* Has no effect on partial (differential) redraws — the flag is consumed
|
|
307
|
-
* only when a full render is triggered by content shrink, width change,
|
|
308
|
-
* or forced invalidation.
|
|
309
|
-
*/
|
|
310
|
-
requestScrollbackClear(): void {
|
|
311
|
-
this.pendingScrollbackClear = true;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
288
|
setFocus(component: Component | null): void {
|
|
315
289
|
// Clear focused flag on old component
|
|
316
290
|
if (isFocusable(this.focusedComponent)) {
|
|
@@ -330,10 +304,16 @@ export class TUI extends Container {
|
|
|
330
304
|
* Returns a handle to control the overlay's visibility.
|
|
331
305
|
*/
|
|
332
306
|
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
|
333
|
-
const entry = {
|
|
307
|
+
const entry = {
|
|
308
|
+
component,
|
|
309
|
+
options,
|
|
310
|
+
preFocus: this.focusedComponent,
|
|
311
|
+
hidden: false,
|
|
312
|
+
focusOrder: ++this.focusOrderCounter,
|
|
313
|
+
};
|
|
334
314
|
this.overlayStack.push(entry);
|
|
335
315
|
// Only focus if overlay is actually visible
|
|
336
|
-
if (this.isOverlayVisible(entry)) {
|
|
316
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
337
317
|
this.setFocus(component);
|
|
338
318
|
}
|
|
339
319
|
this.terminal.hideCursor();
|
|
@@ -366,13 +346,29 @@ export class TUI extends Container {
|
|
|
366
346
|
}
|
|
367
347
|
} else {
|
|
368
348
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
369
|
-
if (this.isOverlayVisible(entry)) {
|
|
349
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
350
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
370
351
|
this.setFocus(component);
|
|
371
352
|
}
|
|
372
353
|
}
|
|
373
354
|
this.requestRender();
|
|
374
355
|
},
|
|
375
356
|
isHidden: () => entry.hidden,
|
|
357
|
+
focus: () => {
|
|
358
|
+
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;
|
|
359
|
+
if (this.focusedComponent !== component) {
|
|
360
|
+
this.setFocus(component);
|
|
361
|
+
}
|
|
362
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
363
|
+
this.requestRender();
|
|
364
|
+
},
|
|
365
|
+
unfocus: () => {
|
|
366
|
+
if (this.focusedComponent !== component) return;
|
|
367
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
368
|
+
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
|
369
|
+
this.requestRender();
|
|
370
|
+
},
|
|
371
|
+
isFocused: () => this.focusedComponent === component,
|
|
376
372
|
};
|
|
377
373
|
}
|
|
378
374
|
|
|
@@ -380,9 +376,11 @@ export class TUI extends Container {
|
|
|
380
376
|
hideOverlay(): void {
|
|
381
377
|
const overlay = this.overlayStack.pop();
|
|
382
378
|
if (!overlay) return;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
379
|
+
if (this.focusedComponent === overlay.component) {
|
|
380
|
+
// Find topmost visible overlay, or fall back to preFocus
|
|
381
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
382
|
+
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
383
|
+
}
|
|
386
384
|
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
|
387
385
|
this.requestRender();
|
|
388
386
|
}
|
|
@@ -401,9 +399,10 @@ export class TUI extends Container {
|
|
|
401
399
|
return true;
|
|
402
400
|
}
|
|
403
401
|
|
|
404
|
-
/** Find the topmost visible overlay, if any */
|
|
402
|
+
/** Find the topmost visible capturing overlay, if any */
|
|
405
403
|
private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
|
|
406
404
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
405
|
+
if (this.overlayStack[i].options?.nonCapturing) continue;
|
|
407
406
|
if (this.isOverlayVisible(this.overlayStack[i])) {
|
|
408
407
|
return this.overlayStack[i];
|
|
409
408
|
}
|
|
@@ -416,25 +415,8 @@ export class TUI extends Container {
|
|
|
416
415
|
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
417
416
|
}
|
|
418
417
|
|
|
419
|
-
/**
|
|
420
|
-
* Timestamp when `start()` was called.
|
|
421
|
-
* Used by startup grace period to suppress screen-clearing full redraws.
|
|
422
|
-
*/
|
|
423
|
-
private startedAtMs = 0;
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Duration (ms) after `start()` during which shrink-triggered full redraws
|
|
427
|
-
* use a gentler line-by-line overwrite instead of screen clear.
|
|
428
|
-
*
|
|
429
|
-
* This prevents the visual flicker that occurs when session resume causes
|
|
430
|
-
* rapid content height changes (extension hooks, widget adds/removes) before
|
|
431
|
-
* the full message history is rendered.
|
|
432
|
-
*/
|
|
433
|
-
private static readonly STARTUP_GRACE_MS = 3000;
|
|
434
|
-
|
|
435
418
|
start(): void {
|
|
436
419
|
this.stopped = false;
|
|
437
|
-
this.startedAtMs = Date.now();
|
|
438
420
|
this.terminal.start(
|
|
439
421
|
(data) => this.handleInput(data),
|
|
440
422
|
() => this.requestRender()
|
|
@@ -444,29 +426,32 @@ export class TUI extends Container {
|
|
|
444
426
|
this.requestRender();
|
|
445
427
|
}
|
|
446
428
|
|
|
429
|
+
addInputListener(listener: InputListener): () => void {
|
|
430
|
+
this.inputListeners.add(listener);
|
|
431
|
+
return () => {
|
|
432
|
+
this.inputListeners.delete(listener);
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
removeInputListener(listener: InputListener): void {
|
|
437
|
+
this.inputListeners.delete(listener);
|
|
438
|
+
}
|
|
439
|
+
|
|
447
440
|
private queryCellSize(): void {
|
|
448
441
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
449
442
|
if (!getCapabilities().images) {
|
|
450
443
|
return;
|
|
451
444
|
}
|
|
452
|
-
// Skip cell size query inside tmux — tmux doesn't forward CSI 16 t responses,
|
|
453
|
-
// so cellSizeQueryPending would stay true and parseCellSizeResponse would eat
|
|
454
|
-
// bare \x1b (Escape key) as a "partial response", breaking Escape handling.
|
|
455
|
-
if (process.env.TMUX) {
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
445
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
459
446
|
// Response format: CSI 6 ; height ; width t
|
|
460
|
-
this.cellSizeQueryPending = true;
|
|
461
447
|
this.terminal.write("\x1b[16t");
|
|
462
448
|
}
|
|
463
449
|
|
|
464
450
|
stop(): void {
|
|
465
451
|
this.stopped = true;
|
|
466
|
-
if (this.
|
|
467
|
-
clearTimeout(this.
|
|
468
|
-
this.
|
|
469
|
-
this.renderRequested = false;
|
|
452
|
+
if (this.renderTimer) {
|
|
453
|
+
clearTimeout(this.renderTimer);
|
|
454
|
+
this.renderTimer = undefined;
|
|
470
455
|
}
|
|
471
456
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
472
457
|
if (this.previousLines.length > 0) {
|
|
@@ -484,135 +469,75 @@ export class TUI extends Container {
|
|
|
484
469
|
this.terminal.stop();
|
|
485
470
|
}
|
|
486
471
|
|
|
487
|
-
/** When >0, scheduled renders are deferred until the batch completes. */
|
|
488
|
-
private renderBatchDepth = 0;
|
|
489
|
-
|
|
490
|
-
/** Whether a render was requested while batching was active. */
|
|
491
|
-
private renderDeferredDuringBatch = false;
|
|
492
|
-
|
|
493
|
-
/** Whether a forced render was requested while batching was active. */
|
|
494
|
-
private renderForceDeferredDuringBatch = false;
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Begin a render batch — all `requestRender()` calls are coalesced and
|
|
498
|
-
* deferred until the matching `endRenderBatch()`. Nestable.
|
|
499
|
-
*
|
|
500
|
-
* Use to prevent intermediate renders (and the screen clears they cause)
|
|
501
|
-
* during multi-step UI mutations such as session resume.
|
|
502
|
-
*
|
|
503
|
-
* @returns {void}
|
|
504
|
-
*/
|
|
505
|
-
beginRenderBatch(): void {
|
|
506
|
-
this.renderBatchDepth++;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* End a render batch. When the outermost batch ends, a single render is
|
|
511
|
-
* scheduled if any were deferred.
|
|
512
|
-
*
|
|
513
|
-
* @returns {void}
|
|
514
|
-
*/
|
|
515
|
-
endRenderBatch(): void {
|
|
516
|
-
if (this.renderBatchDepth <= 0) return;
|
|
517
|
-
this.renderBatchDepth--;
|
|
518
|
-
if (this.renderBatchDepth === 0 && this.renderDeferredDuringBatch) {
|
|
519
|
-
const wasForce = this.renderForceDeferredDuringBatch;
|
|
520
|
-
this.renderDeferredDuringBatch = false;
|
|
521
|
-
this.renderForceDeferredDuringBatch = false;
|
|
522
|
-
this.requestRender(wasForce);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
472
|
requestRender(force = false): void {
|
|
527
473
|
if (force) {
|
|
528
474
|
this.previousLines = [];
|
|
529
475
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
476
|
+
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
530
477
|
this.cursorRow = 0;
|
|
531
478
|
this.hardwareCursorRow = 0;
|
|
532
479
|
this.maxLinesRendered = 0;
|
|
533
480
|
this.previousViewportTop = 0;
|
|
534
|
-
this.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
481
|
+
if (this.renderTimer) {
|
|
482
|
+
clearTimeout(this.renderTimer);
|
|
483
|
+
this.renderTimer = undefined;
|
|
484
|
+
}
|
|
485
|
+
this.renderRequested = true;
|
|
486
|
+
process.nextTick(() => {
|
|
487
|
+
if (this.stopped || !this.renderRequested) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.renderRequested = false;
|
|
491
|
+
this.lastRenderAt = performance.now();
|
|
492
|
+
this.doRender();
|
|
493
|
+
});
|
|
539
494
|
return;
|
|
540
495
|
}
|
|
541
496
|
if (this.renderRequested) return;
|
|
542
|
-
this.
|
|
497
|
+
this.renderRequested = true;
|
|
498
|
+
process.nextTick(() => this.scheduleRender());
|
|
543
499
|
}
|
|
544
500
|
|
|
545
|
-
/**
|
|
546
|
-
* Schedule a single coalesced render in the check phase.
|
|
547
|
-
*
|
|
548
|
-
* On Bun, `setImmediate` behaves like a microtask and never enters the I/O poll
|
|
549
|
-
* phase, so stdin data callbacks are starved during streaming. `setTimeout(fn, 0)`
|
|
550
|
-
* forces a real timer (~1ms) that guarantees I/O polling between renders.
|
|
551
|
-
*
|
|
552
|
-
* On Node.js, `setTimeout(0)` has a 1ms minimum delay — slightly slower than
|
|
553
|
-
* `setImmediate` but still imperceptible (<13ms human threshold).
|
|
554
|
-
*
|
|
555
|
-
* @see Plan 177 — Bun setImmediate does not yield to I/O
|
|
556
|
-
* @returns {void}
|
|
557
|
-
*/
|
|
558
501
|
private scheduleRender(): void {
|
|
559
|
-
this.renderRequested
|
|
560
|
-
|
|
561
|
-
|
|
502
|
+
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const elapsed = performance.now() - this.lastRenderAt;
|
|
506
|
+
const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
507
|
+
this.renderTimer = setTimeout(() => {
|
|
508
|
+
this.renderTimer = undefined;
|
|
509
|
+
if (this.stopped || !this.renderRequested) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
562
512
|
this.renderRequested = false;
|
|
563
|
-
|
|
513
|
+
this.lastRenderAt = performance.now();
|
|
564
514
|
this.doRender();
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
private inputListeners = new Set<
|
|
570
|
-
(data: string) => { consume?: boolean; data?: string } | undefined
|
|
571
|
-
>();
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Register an input listener. Listeners run before the focused component and can
|
|
575
|
-
* consume input (return `{consume: true}`) or transform it (return `{data: newData}`).
|
|
576
|
-
*
|
|
577
|
-
* @param listener - Listener function
|
|
578
|
-
* @returns Unsubscribe function
|
|
579
|
-
*/
|
|
580
|
-
addInputListener(
|
|
581
|
-
listener: (data: string) => { consume?: boolean; data?: string } | undefined
|
|
582
|
-
): () => void {
|
|
583
|
-
this.inputListeners.add(listener);
|
|
584
|
-
return () => {
|
|
585
|
-
this.inputListeners.delete(listener);
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Remove a previously registered input listener.
|
|
591
|
-
*
|
|
592
|
-
* @param listener - The listener function to remove
|
|
593
|
-
*/
|
|
594
|
-
removeInputListener(
|
|
595
|
-
listener: (data: string) => { consume?: boolean; data?: string } | undefined
|
|
596
|
-
): void {
|
|
597
|
-
this.inputListeners.delete(listener);
|
|
515
|
+
if (this.renderRequested) {
|
|
516
|
+
this.scheduleRender();
|
|
517
|
+
}
|
|
518
|
+
}, delay);
|
|
598
519
|
}
|
|
599
520
|
|
|
600
521
|
private handleInput(data: string): void {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
this.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
522
|
+
if (this.inputListeners.size > 0) {
|
|
523
|
+
let current = data;
|
|
524
|
+
for (const listener of this.inputListeners) {
|
|
525
|
+
const result = listener(current);
|
|
526
|
+
if (result?.consume) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (result?.data !== undefined) {
|
|
530
|
+
current = result.data;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (current.length === 0) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
data = current;
|
|
607
537
|
}
|
|
608
538
|
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
if (isMouseEvent(data)) {
|
|
612
|
-
const event = parseMouseEvent(data);
|
|
613
|
-
if (event && this.onMouse) {
|
|
614
|
-
this.onMouse(event);
|
|
615
|
-
}
|
|
539
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
540
|
+
if (this.consumeCellSizeResponse(data)) {
|
|
616
541
|
return;
|
|
617
542
|
}
|
|
618
543
|
|
|
@@ -636,24 +561,6 @@ export class TUI extends Container {
|
|
|
636
561
|
}
|
|
637
562
|
}
|
|
638
563
|
|
|
639
|
-
// Run input listeners — can consume or transform input
|
|
640
|
-
if (this.inputListeners.size > 0) {
|
|
641
|
-
let current = data;
|
|
642
|
-
for (const listener of this.inputListeners) {
|
|
643
|
-
const result = listener(current);
|
|
644
|
-
if (result?.consume) {
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
if (result?.data !== undefined) {
|
|
648
|
-
current = result.data;
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
if (current.length === 0) {
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
data = current;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
564
|
// Pass input to focused component (including Ctrl+C)
|
|
658
565
|
// The focused component can decide how to handle Ctrl+C
|
|
659
566
|
if (this.focusedComponent?.handleInput) {
|
|
@@ -661,64 +568,29 @@ export class TUI extends Container {
|
|
|
661
568
|
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
662
569
|
return;
|
|
663
570
|
}
|
|
664
|
-
if (process.env.TALLOW_KEY_DEBUG && (data === "\x1b" || data === "\x03")) {
|
|
665
|
-
const escMatch = matchesKey(data, "escape");
|
|
666
|
-
const ctrlcMatch = matchesKey(data, "ctrl+c");
|
|
667
|
-
const comp = this.focusedComponent as unknown as {
|
|
668
|
-
onEscape?: () => void;
|
|
669
|
-
actionHandlers?: Map<string, unknown>;
|
|
670
|
-
};
|
|
671
|
-
const hasOnEscape = typeof comp.onEscape === "function";
|
|
672
|
-
const actionCount = comp.actionHandlers instanceof Map ? comp.actionHandlers.size : -1;
|
|
673
|
-
process.stderr.write(
|
|
674
|
-
`[key] ${data === "\x1b" ? "ESC" : "C-C"} matchEsc=${escMatch} matchCC=${ctrlcMatch} hasOnEscape=${hasOnEscape} actions=${actionCount}\n`
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
571
|
this.focusedComponent.handleInput(data);
|
|
678
572
|
this.requestRender();
|
|
679
573
|
}
|
|
680
574
|
}
|
|
681
575
|
|
|
682
|
-
private
|
|
576
|
+
private consumeCellSizeResponse(data: string): boolean {
|
|
683
577
|
// Response format: ESC [ 6 ; height ; width t
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (match) {
|
|
689
|
-
const heightPx = parseInt(match[1], 10);
|
|
690
|
-
const widthPx = parseInt(match[2], 10);
|
|
691
|
-
|
|
692
|
-
if (heightPx > 0 && widthPx > 0) {
|
|
693
|
-
setCellDimensions({ widthPx, heightPx });
|
|
694
|
-
// Invalidate all components so images re-render with correct dimensions
|
|
695
|
-
this.invalidate();
|
|
696
|
-
this.requestRender();
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Remove the response from buffer
|
|
700
|
-
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
701
|
-
this.cellSizeQueryPending = false;
|
|
578
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
579
|
+
if (!match) {
|
|
580
|
+
return false;
|
|
702
581
|
}
|
|
703
582
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
709
|
-
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
710
|
-
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
711
|
-
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
712
|
-
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
713
|
-
return "";
|
|
714
|
-
}
|
|
583
|
+
const heightPx = parseInt(match[1], 10);
|
|
584
|
+
const widthPx = parseInt(match[2], 10);
|
|
585
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
586
|
+
return true;
|
|
715
587
|
}
|
|
716
588
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
this.
|
|
720
|
-
this.
|
|
721
|
-
return
|
|
589
|
+
setCellDimensions({ widthPx, heightPx });
|
|
590
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
591
|
+
this.invalidate();
|
|
592
|
+
this.requestRender();
|
|
593
|
+
return true;
|
|
722
594
|
}
|
|
723
595
|
|
|
724
596
|
/**
|
|
@@ -870,7 +742,7 @@ export class TUI extends Container {
|
|
|
870
742
|
}
|
|
871
743
|
}
|
|
872
744
|
|
|
873
|
-
/** Composite all overlays into content lines (
|
|
745
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
874
746
|
private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
|
|
875
747
|
if (this.overlayStack.length === 0) return lines;
|
|
876
748
|
const result = [...lines];
|
|
@@ -879,10 +751,9 @@ export class TUI extends Container {
|
|
|
879
751
|
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
|
880
752
|
let minLinesNeeded = result.length;
|
|
881
753
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
754
|
+
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
|
755
|
+
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
|
|
756
|
+
for (const entry of visibleEntries) {
|
|
886
757
|
const { component, options } = entry;
|
|
887
758
|
|
|
888
759
|
// Get layout with height=0 first to determine width and maxHeight
|
|
@@ -909,9 +780,10 @@ export class TUI extends Container {
|
|
|
909
780
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
910
781
|
}
|
|
911
782
|
|
|
912
|
-
//
|
|
913
|
-
// maxLinesRendered
|
|
914
|
-
|
|
783
|
+
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
784
|
+
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
785
|
+
// inflation that pushed content into scrollback on terminal widen.
|
|
786
|
+
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
|
|
915
787
|
|
|
916
788
|
// Extend result with empty lines if content is too short for overlay placement or working area
|
|
917
789
|
while (result.length < workingHeight) {
|
|
@@ -920,9 +792,6 @@ export class TUI extends Container {
|
|
|
920
792
|
|
|
921
793
|
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
922
794
|
|
|
923
|
-
// Track which lines were modified for final verification
|
|
924
|
-
const modifiedLines = new Set<number>();
|
|
925
|
-
|
|
926
795
|
// Composite each overlay
|
|
927
796
|
for (const { overlayLines, row, col, w } of rendered) {
|
|
928
797
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
@@ -935,22 +804,10 @@ export class TUI extends Container {
|
|
|
935
804
|
? sliceByColumn(overlayLines[i], 0, w, true)
|
|
936
805
|
: overlayLines[i];
|
|
937
806
|
result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
|
|
938
|
-
modifiedLines.add(idx);
|
|
939
807
|
}
|
|
940
808
|
}
|
|
941
809
|
}
|
|
942
810
|
|
|
943
|
-
// Final verification: ensure no composited line exceeds terminal width
|
|
944
|
-
// This is a belt-and-suspenders safeguard - compositeLineAt should already
|
|
945
|
-
// guarantee this, but we verify here to prevent crashes from any edge cases
|
|
946
|
-
// Only check lines that were actually modified (optimization)
|
|
947
|
-
for (const idx of modifiedLines) {
|
|
948
|
-
const lineWidth = visibleWidth(result[idx]);
|
|
949
|
-
if (lineWidth > termWidth) {
|
|
950
|
-
result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
811
|
return result;
|
|
955
812
|
}
|
|
956
813
|
|
|
@@ -1053,8 +910,14 @@ export class TUI extends Container {
|
|
|
1053
910
|
if (this.stopped) return;
|
|
1054
911
|
const width = this.terminal.columns;
|
|
1055
912
|
const height = this.terminal.rows;
|
|
1056
|
-
|
|
1057
|
-
|
|
913
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
914
|
+
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
915
|
+
const previousBufferLength =
|
|
916
|
+
this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
|
917
|
+
let prevViewportTop = heightChanged
|
|
918
|
+
? Math.max(0, previousBufferLength - height)
|
|
919
|
+
: this.previousViewportTop;
|
|
920
|
+
let viewportTop = prevViewportTop;
|
|
1058
921
|
let hardwareCursorRow = this.hardwareCursorRow;
|
|
1059
922
|
const computeLineDiff = (targetRow: number): number => {
|
|
1060
923
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
@@ -1075,24 +938,11 @@ export class TUI extends Container {
|
|
|
1075
938
|
|
|
1076
939
|
newLines = this.applyLineResets(newLines);
|
|
1077
940
|
|
|
1078
|
-
//
|
|
1079
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
1080
|
-
|
|
1081
|
-
// Whether we are within the startup grace period where screen-clearing
|
|
1082
|
-
// full redraws are softened to prevent flicker during session resume.
|
|
1083
|
-
const inStartupGrace =
|
|
1084
|
-
this.startedAtMs > 0 && Date.now() - this.startedAtMs < TUI.STARTUP_GRACE_MS;
|
|
1085
|
-
|
|
1086
|
-
// Helper to clear viewport (and optionally scrollback) and render all new lines
|
|
941
|
+
// Helper to clear scrollback and viewport and render all new lines
|
|
1087
942
|
const fullRender = (clear: boolean): void => {
|
|
1088
943
|
this.fullRedrawCount += 1;
|
|
1089
944
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1090
|
-
if (clear
|
|
1091
|
-
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
|
|
1092
|
-
this.pendingScrollbackClear = false;
|
|
1093
|
-
} else if (clear) {
|
|
1094
|
-
buffer += "\x1b[2J\x1b[H"; // Clear screen and home (preserve scrollback)
|
|
1095
|
-
}
|
|
945
|
+
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
|
1096
946
|
for (let i = 0; i < newLines.length; i++) {
|
|
1097
947
|
if (i > 0) buffer += "\r\n";
|
|
1098
948
|
buffer += newLines[i];
|
|
@@ -1107,49 +957,12 @@ export class TUI extends Container {
|
|
|
1107
957
|
} else {
|
|
1108
958
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1109
959
|
}
|
|
1110
|
-
|
|
1111
|
-
this.
|
|
1112
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1113
|
-
this.previousLines = newLines;
|
|
1114
|
-
this.previousWidth = width;
|
|
1115
|
-
};
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* Gentle full redraw: home cursor + overwrite each line + clear below.
|
|
1119
|
-
*
|
|
1120
|
-
* Used during the startup grace period instead of fullRender(true) for
|
|
1121
|
-
* shrink-triggered redraws. Avoids the visible blank frame caused by
|
|
1122
|
-
* `\x1b[2J` (clear screen), which makes messages appear to flash in and
|
|
1123
|
-
* out when session resume triggers rapid content height changes.
|
|
1124
|
-
*
|
|
1125
|
-
* Unlike fullRender(true), this never clears the screen — it writes each
|
|
1126
|
-
* line with a preceding `\x1b[2K` (clear line) so stale content is
|
|
1127
|
-
* overwritten without a blank frame. Lines below the new content are
|
|
1128
|
-
* individually erased.
|
|
1129
|
-
*/
|
|
1130
|
-
const gentleFullRender = (): void => {
|
|
1131
|
-
this.fullRedrawCount += 1;
|
|
1132
|
-
let buffer = "\x1b[?2026h\x1b[H"; // Begin synchronized output + home cursor
|
|
1133
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
1134
|
-
buffer += "\x1b[2K"; // Clear current line
|
|
1135
|
-
buffer += newLines[i];
|
|
1136
|
-
if (i < newLines.length - 1) buffer += "\r\n";
|
|
1137
|
-
}
|
|
1138
|
-
// Erase lines that were previously rendered but are no longer needed
|
|
1139
|
-
const staleLines = Math.max(0, this.maxLinesRendered - newLines.length);
|
|
1140
|
-
for (let i = 0; i < staleLines; i++) {
|
|
1141
|
-
buffer += "\r\n\x1b[2K";
|
|
1142
|
-
}
|
|
1143
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1144
|
-
this.terminal.write(buffer);
|
|
1145
|
-
this.cursorRow = Math.max(0, newLines.length + staleLines - 1);
|
|
1146
|
-
this.hardwareCursorRow = this.cursorRow;
|
|
1147
|
-
this.maxLinesRendered = newLines.length;
|
|
1148
|
-
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
1149
|
-
this.rollingShrinkPeak = newLines.length;
|
|
960
|
+
const bufferLength = Math.max(height, newLines.length);
|
|
961
|
+
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
1150
962
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1151
963
|
this.previousLines = newLines;
|
|
1152
964
|
this.previousWidth = width;
|
|
965
|
+
this.previousHeight = height;
|
|
1153
966
|
};
|
|
1154
967
|
|
|
1155
968
|
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
|
@@ -1161,15 +974,24 @@ export class TUI extends Container {
|
|
|
1161
974
|
};
|
|
1162
975
|
|
|
1163
976
|
// First render - just output everything without clearing (assumes clean screen)
|
|
1164
|
-
if (this.previousLines.length === 0 && !widthChanged) {
|
|
977
|
+
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
1165
978
|
logRedraw("first render");
|
|
1166
979
|
fullRender(false);
|
|
1167
980
|
return;
|
|
1168
981
|
}
|
|
1169
982
|
|
|
1170
|
-
// Width
|
|
983
|
+
// Width changes always need a full re-render because wrapping changes.
|
|
1171
984
|
if (widthChanged) {
|
|
1172
|
-
logRedraw(`width changed (${this.previousWidth} -> ${width})`);
|
|
985
|
+
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
|
986
|
+
fullRender(true);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
991
|
+
// but Termux changes height when the software keyboard shows or hides.
|
|
992
|
+
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
993
|
+
if (heightChanged && !isTermuxSession()) {
|
|
994
|
+
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
|
1173
995
|
fullRender(true);
|
|
1174
996
|
return;
|
|
1175
997
|
}
|
|
@@ -1183,73 +1005,10 @@ export class TUI extends Container {
|
|
|
1183
1005
|
this.overlayStack.length === 0
|
|
1184
1006
|
) {
|
|
1185
1007
|
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
|
1186
|
-
|
|
1187
|
-
gentleFullRender();
|
|
1188
|
-
} else {
|
|
1189
|
-
fullRender(true);
|
|
1190
|
-
}
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
// Large content shrinks (e.g., tool output collapse) are hard to partially redraw
|
|
1195
|
-
// correctly — cursor positions drift when many lines disappear at once, causing
|
|
1196
|
-
// ghost copies of previous frames. Force a full redraw when the shrink exceeds a
|
|
1197
|
-
// threshold. More targeted than clearOnShrink (which fires on ANY shrink).
|
|
1198
|
-
const shrinkDelta = this.previousLines.length - newLines.length;
|
|
1199
|
-
if (shrinkDelta > 5 && this.overlayStack.length === 0) {
|
|
1200
|
-
logRedraw(`large shrink (${shrinkDelta} lines)`);
|
|
1201
|
-
if (inStartupGrace) {
|
|
1202
|
-
gentleFullRender();
|
|
1203
|
-
} else {
|
|
1204
|
-
fullRender(true);
|
|
1205
|
-
}
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
// Rolling shrink detection: catches gradual shrinks where each individual
|
|
1210
|
-
// frame-to-frame delta is ≤5 lines (below the large-shrink threshold) but the
|
|
1211
|
-
// accumulated shrink from a recent peak exceeds it. This happens when
|
|
1212
|
-
// pollStates.clear() collapses tool-result anchors across multiple render
|
|
1213
|
-
// cycles while animations (loader, widget spinners) keep triggering renders.
|
|
1214
|
-
if (newLines.length >= this.rollingShrinkPeak) {
|
|
1215
|
-
this.rollingShrinkPeak = newLines.length;
|
|
1216
|
-
} else if (this.overlayStack.length === 0 && this.rollingShrinkPeak - newLines.length > 5) {
|
|
1217
|
-
logRedraw(
|
|
1218
|
-
`rolling shrink (peak=${this.rollingShrinkPeak}, now=${newLines.length}, delta=${this.rollingShrinkPeak - newLines.length})`
|
|
1219
|
-
);
|
|
1220
|
-
if (inStartupGrace) {
|
|
1221
|
-
gentleFullRender();
|
|
1222
|
-
} else {
|
|
1223
|
-
fullRender(true);
|
|
1224
|
-
}
|
|
1008
|
+
fullRender(true);
|
|
1225
1009
|
return;
|
|
1226
1010
|
}
|
|
1227
1011
|
|
|
1228
|
-
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
|
|
1229
|
-
// Detect viewport basis drift: maxLinesRendered exceeds actual content,
|
|
1230
|
-
// causing viewportTop to be computed from a stale high-water mark.
|
|
1231
|
-
// Compare against newLines.length (not previousLines.length) so the
|
|
1232
|
-
// correction fires on the same render cycle as a shrink, not one cycle late.
|
|
1233
|
-
const hasViewportBasisDrift =
|
|
1234
|
-
this.overlayStack.length === 0 &&
|
|
1235
|
-
this.previousLines.length > 0 &&
|
|
1236
|
-
this.maxLinesRendered > newLines.length &&
|
|
1237
|
-
prevViewportTop !== previousContentViewportTop;
|
|
1238
|
-
|
|
1239
|
-
// After shrink-heavy updates with clearOnShrink disabled, maxLinesRendered can stay larger
|
|
1240
|
-
// than current content. Instead of a destructive full redraw (which clears the screen and
|
|
1241
|
-
// disrupts scrollback), realign the working-area coordinates so the partial-redraw path
|
|
1242
|
-
// can operate on a consistent viewport basis.
|
|
1243
|
-
// Use newLines.length (not previousLines.length) so the correction is exact for the
|
|
1244
|
-
// current frame — previousLines.length can still exceed newLines.length, leaving
|
|
1245
|
-
// residual drift for one more cycle and causing ghost lines.
|
|
1246
|
-
if (hasViewportBasisDrift) {
|
|
1247
|
-
this.maxLinesRendered = newLines.length;
|
|
1248
|
-
viewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
1249
|
-
prevViewportTop = viewportTop;
|
|
1250
|
-
this.previousViewportTop = viewportTop;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
1012
|
// Find first and last changed lines
|
|
1254
1013
|
let firstChanged = -1;
|
|
1255
1014
|
let lastChanged = -1;
|
|
@@ -1278,7 +1037,8 @@ export class TUI extends Container {
|
|
|
1278
1037
|
// No changes - but still need to update hardware cursor position if it moved
|
|
1279
1038
|
if (firstChanged === -1) {
|
|
1280
1039
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1281
|
-
this.previousViewportTop =
|
|
1040
|
+
this.previousViewportTop = prevViewportTop;
|
|
1041
|
+
this.previousHeight = height;
|
|
1282
1042
|
return;
|
|
1283
1043
|
}
|
|
1284
1044
|
|
|
@@ -1288,6 +1048,11 @@ export class TUI extends Container {
|
|
|
1288
1048
|
let buffer = "\x1b[?2026h";
|
|
1289
1049
|
// Move to end of new content (clamp to 0 for empty content)
|
|
1290
1050
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
1051
|
+
if (targetRow < prevViewportTop) {
|
|
1052
|
+
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
|
1053
|
+
fullRender(true);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1291
1056
|
const lineDiff = computeLineDiff(targetRow);
|
|
1292
1057
|
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
|
1293
1058
|
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
|
@@ -1296,11 +1061,7 @@ export class TUI extends Container {
|
|
|
1296
1061
|
const extraLines = this.previousLines.length - newLines.length;
|
|
1297
1062
|
if (extraLines > height) {
|
|
1298
1063
|
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
1299
|
-
|
|
1300
|
-
gentleFullRender();
|
|
1301
|
-
} else {
|
|
1302
|
-
fullRender(true);
|
|
1303
|
-
}
|
|
1064
|
+
fullRender(true);
|
|
1304
1065
|
return;
|
|
1305
1066
|
}
|
|
1306
1067
|
if (extraLines > 0) {
|
|
@@ -1321,18 +1082,16 @@ export class TUI extends Container {
|
|
|
1321
1082
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1322
1083
|
this.previousLines = newLines;
|
|
1323
1084
|
this.previousWidth = width;
|
|
1324
|
-
this.
|
|
1085
|
+
this.previousHeight = height;
|
|
1086
|
+
this.previousViewportTop = prevViewportTop;
|
|
1325
1087
|
return;
|
|
1326
1088
|
}
|
|
1327
1089
|
|
|
1328
|
-
//
|
|
1090
|
+
// Differential rendering can only touch what was actually visible.
|
|
1091
|
+
// If the first changed line is above the previous viewport, we need a full redraw.
|
|
1329
1092
|
if (firstChanged < prevViewportTop) {
|
|
1330
1093
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1331
|
-
|
|
1332
|
-
gentleFullRender();
|
|
1333
|
-
} else {
|
|
1334
|
-
fullRender(true);
|
|
1335
|
-
}
|
|
1094
|
+
fullRender(true);
|
|
1336
1095
|
return;
|
|
1337
1096
|
}
|
|
1338
1097
|
|
|
@@ -1373,7 +1132,7 @@ export class TUI extends Container {
|
|
|
1373
1132
|
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
1374
1133
|
if (i > firstChanged) buffer += "\r\n";
|
|
1375
1134
|
buffer += "\x1b[2K"; // Clear current line
|
|
1376
|
-
|
|
1135
|
+
const line = newLines[i];
|
|
1377
1136
|
const isImage = isImageLine(line);
|
|
1378
1137
|
if (!isImage && visibleWidth(line) > width) {
|
|
1379
1138
|
// Log all lines to crash file for debugging
|
|
@@ -1390,23 +1149,18 @@ export class TUI extends Container {
|
|
|
1390
1149
|
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
|
1391
1150
|
fs.writeFileSync(crashLogPath, crashData);
|
|
1392
1151
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
this.stop();
|
|
1396
|
-
const errorMsg = [
|
|
1397
|
-
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
1398
|
-
"",
|
|
1399
|
-
"This is likely caused by a custom TUI component not truncating its output.",
|
|
1400
|
-
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
1401
|
-
"",
|
|
1402
|
-
`Debug log written to: ${crashLogPath}`,
|
|
1403
|
-
].join("\n");
|
|
1404
|
-
throw new Error(errorMsg);
|
|
1405
|
-
}
|
|
1152
|
+
// Clean up terminal state before throwing
|
|
1153
|
+
this.stop();
|
|
1406
1154
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1155
|
+
const errorMsg = [
|
|
1156
|
+
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
1157
|
+
"",
|
|
1158
|
+
"This is likely caused by a custom TUI component not truncating its output.",
|
|
1159
|
+
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
1160
|
+
"",
|
|
1161
|
+
`Debug log written to: ${crashLogPath}`,
|
|
1162
|
+
].join("\n");
|
|
1163
|
+
throw new Error(errorMsg);
|
|
1410
1164
|
}
|
|
1411
1165
|
buffer += line;
|
|
1412
1166
|
}
|
|
@@ -1416,21 +1170,13 @@ export class TUI extends Container {
|
|
|
1416
1170
|
|
|
1417
1171
|
// If we had more lines before, clear them and move cursor back
|
|
1418
1172
|
if (this.previousLines.length > newLines.length) {
|
|
1419
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
1420
|
-
// Safety guard: when extraLines exceeds terminal height, the \r\n
|
|
1421
|
-
// sequence would scroll the viewport and desynchronize cursor tracking.
|
|
1422
|
-
// Fall back to a full redraw instead (same guard as the deleted-lines-only path).
|
|
1423
|
-
if (extraLines > height) {
|
|
1424
|
-
logRedraw(`extraLines > height in diff path (${extraLines} > ${height})`);
|
|
1425
|
-
fullRender(true);
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
1173
|
// Move to end of new content first if we stopped before it
|
|
1429
1174
|
if (renderEnd < newLines.length - 1) {
|
|
1430
1175
|
const moveDown = newLines.length - 1 - renderEnd;
|
|
1431
1176
|
buffer += `\x1b[${moveDown}B`;
|
|
1432
1177
|
finalCursorRow = newLines.length - 1;
|
|
1433
1178
|
}
|
|
1179
|
+
const extraLines = this.previousLines.length - newLines.length;
|
|
1434
1180
|
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
1435
1181
|
buffer += "\r\n\x1b[2K";
|
|
1436
1182
|
}
|
|
@@ -1443,21 +1189,9 @@ export class TUI extends Container {
|
|
|
1443
1189
|
if (process.env.PI_TUI_DEBUG === "1") {
|
|
1444
1190
|
const debugDir = "/tmp/tui";
|
|
1445
1191
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
1446
|
-
|
|
1447
|
-
// Log large height fluctuations for diagnosing intermittent ghost gaps
|
|
1448
|
-
const heightDelta = newLines.length - this.previousLines.length;
|
|
1449
|
-
if (Math.abs(heightDelta) > 5) {
|
|
1450
|
-
const fluctPath = path.join(debugDir, "height-fluctuations.log");
|
|
1451
|
-
const fluctMsg =
|
|
1452
|
-
`[${new Date().toISOString()}] heightFluctuation: ${heightDelta > 0 ? "+" : ""}${heightDelta} ` +
|
|
1453
|
-
`(prev=${this.previousLines.length}, new=${newLines.length}, ` +
|
|
1454
|
-
`maxLR=${this.maxLinesRendered}, viewportTop=${viewportTop})\n`;
|
|
1455
|
-
fs.appendFileSync(fluctPath, fluctMsg);
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
1192
|
const debugPath = path.join(
|
|
1459
1193
|
debugDir,
|
|
1460
|
-
`render-${Date.now()}-${
|
|
1194
|
+
`render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`
|
|
1461
1195
|
);
|
|
1462
1196
|
const debugData = [
|
|
1463
1197
|
`firstChanged: ${firstChanged}`,
|
|
@@ -1471,7 +1205,6 @@ export class TUI extends Container {
|
|
|
1471
1205
|
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
1472
1206
|
`newLines.length: ${newLines.length}`,
|
|
1473
1207
|
`previousLines.length: ${this.previousLines.length}`,
|
|
1474
|
-
`maxLinesRendered: ${this.maxLinesRendered}`,
|
|
1475
1208
|
"",
|
|
1476
1209
|
"=== newLines ===",
|
|
1477
1210
|
JSON.stringify(newLines, null, 2),
|
|
@@ -1482,7 +1215,7 @@ export class TUI extends Container {
|
|
|
1482
1215
|
"=== buffer ===",
|
|
1483
1216
|
JSON.stringify(buffer),
|
|
1484
1217
|
].join("\n");
|
|
1485
|
-
fs.writeFileSync(debugPath, debugData
|
|
1218
|
+
fs.writeFileSync(debugPath, debugData);
|
|
1486
1219
|
}
|
|
1487
1220
|
|
|
1488
1221
|
// Write entire buffer at once
|
|
@@ -1493,25 +1226,16 @@ export class TUI extends Container {
|
|
|
1493
1226
|
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
|
1494
1227
|
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1495
1228
|
this.hardwareCursorRow = finalCursorRow;
|
|
1496
|
-
// Track terminal's working area
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
// content size — this prevents permanent ghost height from stale maxLinesRendered.
|
|
1500
|
-
if (this.overlayStack.length === 0) {
|
|
1501
|
-
this.maxLinesRendered = newLines.length;
|
|
1502
|
-
} else {
|
|
1503
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1504
|
-
}
|
|
1505
|
-
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
1506
|
-
// Update rolling peak for gradual shrink detection (partial path only —
|
|
1507
|
-
// fullRender paths reset it inside the fullRender closure).
|
|
1508
|
-
this.rollingShrinkPeak = Math.max(this.rollingShrinkPeak, newLines.length);
|
|
1229
|
+
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
1230
|
+
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1231
|
+
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
1509
1232
|
|
|
1510
1233
|
// Position hardware cursor for IME
|
|
1511
1234
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1512
1235
|
|
|
1513
1236
|
this.previousLines = newLines;
|
|
1514
1237
|
this.previousWidth = width;
|
|
1238
|
+
this.previousHeight = height;
|
|
1515
1239
|
}
|
|
1516
1240
|
|
|
1517
1241
|
/**
|