@dungle-scrubs/tallow 0.9.4 → 0.9.7
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 +8 -5
- 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 -12
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +229 -146
- 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/process-cleanup.js +1 -1
- package/dist/process-cleanup.js.map +1 -1
- 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 +7 -23
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +211 -174
- 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 +8 -18
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +4 -5
- package/extensions/_icons/index.ts +2 -4
- package/extensions/_shared/__tests__/image-metadata.test.ts +33 -0
- package/extensions/_shared/__tests__/shell-policy.test.ts +19 -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/shell-policy.ts +121 -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 +269 -2
- package/extensions/command-expansion/index.ts +9 -3
- 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/frontmatter-index.ts +6 -1
- package/extensions/context-fork/index.ts +32 -0
- package/extensions/edit-tool-enhanced/index.ts +2 -1
- package/extensions/git-status/__tests__/git-status.test.ts +65 -2
- package/extensions/git-status/index.ts +268 -98
- 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/minimal-skill-display/index.ts +7 -1
- package/extensions/random-spinner/index.ts +7 -642
- package/extensions/read-tool-enhanced/index.ts +13 -10
- package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +2 -3
- package/extensions/render-stabilizer/index.ts +6 -6
- package/extensions/rewind/__tests__/session-files.test.ts +115 -0
- package/extensions/rewind/__tests__/snapshots.test.ts +23 -0
- package/extensions/rewind/index.ts +5 -0
- package/extensions/rewind/session-files.ts +138 -0
- package/extensions/rewind/snapshots.ts +104 -5
- package/extensions/skill-commands/index.ts +6 -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/model-resolver.ts +274 -7
- package/extensions/subagent-tool/schema.ts +1 -2
- package/extensions/tasks/commands/register-tasks-extension.ts +9 -9
- package/extensions/teams-tool/tools/register-extension.ts +1 -3
- package/extensions/teams-tool/tools/teammate-tools.ts +1 -2
- package/extensions/web-search-tool/index.ts +2 -1
- package/extensions/wezterm-pane-control/index.ts +1 -2
- 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 +1 -25
- 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 +13 -50
- 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 +12 -51
- 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/skills/tallow-expert/SKILL.md +3 -5
- 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 -81
- 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
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
8
9
|
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
9
|
-
import { extractSegments, sliceByColumn, sliceWithWidth,
|
|
10
|
+
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
10
11
|
/** Type guard to check if a component implements Focusable */
|
|
11
12
|
export function isFocusable(component) {
|
|
12
13
|
return component !== null && "focused" in component;
|
|
@@ -32,6 +33,9 @@ function parseSizeValue(value, referenceSize) {
|
|
|
32
33
|
}
|
|
33
34
|
return undefined;
|
|
34
35
|
}
|
|
36
|
+
function isTermuxSession() {
|
|
37
|
+
return Boolean(process.env.TERMUX_VERSION);
|
|
38
|
+
}
|
|
35
39
|
/**
|
|
36
40
|
* Container - a component that contains other components
|
|
37
41
|
*/
|
|
@@ -57,7 +61,10 @@ export class Container {
|
|
|
57
61
|
render(width) {
|
|
58
62
|
const lines = [];
|
|
59
63
|
for (const child of this.children) {
|
|
60
|
-
|
|
64
|
+
const childLines = child.render(width);
|
|
65
|
+
for (const line of childLines) {
|
|
66
|
+
lines.push(line);
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
return lines;
|
|
63
70
|
}
|
|
@@ -69,31 +76,25 @@ export class TUI extends Container {
|
|
|
69
76
|
terminal;
|
|
70
77
|
previousLines = [];
|
|
71
78
|
previousWidth = 0;
|
|
79
|
+
previousHeight = 0;
|
|
72
80
|
focusedComponent = null;
|
|
81
|
+
inputListeners = new Set();
|
|
73
82
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
74
83
|
onDebug;
|
|
75
|
-
/**
|
|
76
|
-
* Callback for mouse events. Called when a mouse event is received.
|
|
77
|
-
* Scroll events are the primary use case (scroll-up, scroll-down).
|
|
78
|
-
* Return value is ignored — mouse events are always consumed and never
|
|
79
|
-
* forwarded to focused components.
|
|
80
|
-
*/
|
|
81
|
-
onMouse;
|
|
82
84
|
renderRequested = false;
|
|
83
|
-
|
|
85
|
+
renderTimer;
|
|
86
|
+
lastRenderAt = 0;
|
|
87
|
+
static MIN_RENDER_INTERVAL_MS = 16;
|
|
84
88
|
cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
85
89
|
hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
86
|
-
inputBuffer = ""; // Buffer for parsing terminal responses
|
|
87
|
-
cellSizeQueryPending = false;
|
|
88
90
|
showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
89
91
|
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
90
92
|
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
91
93
|
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
92
94
|
fullRedrawCount = 0;
|
|
93
|
-
rollingShrinkPeak = 0; // Recent peak line count for gradual shrink detection
|
|
94
95
|
stopped = false;
|
|
95
|
-
pendingScrollbackClear = false; // Clear scrollback on next full render (session breaks)
|
|
96
96
|
// Overlay stack for modal components rendered on top of base content
|
|
97
|
+
focusOrderCounter = 0;
|
|
97
98
|
overlayStack = [];
|
|
98
99
|
constructor(terminal, showHardwareCursor) {
|
|
99
100
|
super();
|
|
@@ -128,32 +129,6 @@ export class TUI extends Container {
|
|
|
128
129
|
setClearOnShrink(enabled) {
|
|
129
130
|
this.clearOnShrink = enabled;
|
|
130
131
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Reset the startup grace period timer, suppressing screen-clearing full
|
|
133
|
-
* redraws for another {@link STARTUP_GRACE_MS} milliseconds.
|
|
134
|
-
*
|
|
135
|
-
* Call this at the start of a session switch so the chatContainer.clear()
|
|
136
|
-
* → renderInitialMessages() transition doesn't cause visible flicker.
|
|
137
|
-
*
|
|
138
|
-
* @returns {void}
|
|
139
|
-
*/
|
|
140
|
-
resetRenderGrace() {
|
|
141
|
-
this.startedAtMs = Date.now();
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Request that the next full render clears the terminal scrollback buffer.
|
|
145
|
-
*
|
|
146
|
-
* Use when the session content is being replaced wholesale (workspace
|
|
147
|
-
* transitions, new sessions, session switches) so stale scrollback
|
|
148
|
-
* doesn't visually flow into the new content.
|
|
149
|
-
*
|
|
150
|
-
* Has no effect on partial (differential) redraws — the flag is consumed
|
|
151
|
-
* only when a full render is triggered by content shrink, width change,
|
|
152
|
-
* or forced invalidation.
|
|
153
|
-
*/
|
|
154
|
-
requestScrollbackClear() {
|
|
155
|
-
this.pendingScrollbackClear = true;
|
|
156
|
-
}
|
|
157
132
|
setFocus(component) {
|
|
158
133
|
// Clear focused flag on old component
|
|
159
134
|
if (isFocusable(this.focusedComponent)) {
|
|
@@ -170,10 +145,16 @@ export class TUI extends Container {
|
|
|
170
145
|
* Returns a handle to control the overlay's visibility.
|
|
171
146
|
*/
|
|
172
147
|
showOverlay(component, options) {
|
|
173
|
-
const entry = {
|
|
148
|
+
const entry = {
|
|
149
|
+
component,
|
|
150
|
+
options,
|
|
151
|
+
preFocus: this.focusedComponent,
|
|
152
|
+
hidden: false,
|
|
153
|
+
focusOrder: ++this.focusOrderCounter,
|
|
154
|
+
};
|
|
174
155
|
this.overlayStack.push(entry);
|
|
175
156
|
// Only focus if overlay is actually visible
|
|
176
|
-
if (this.isOverlayVisible(entry)) {
|
|
157
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
177
158
|
this.setFocus(component);
|
|
178
159
|
}
|
|
179
160
|
this.terminal.hideCursor();
|
|
@@ -208,13 +189,31 @@ export class TUI extends Container {
|
|
|
208
189
|
}
|
|
209
190
|
else {
|
|
210
191
|
// Restore focus to this overlay when showing (if it's actually visible)
|
|
211
|
-
if (this.isOverlayVisible(entry)) {
|
|
192
|
+
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
193
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
212
194
|
this.setFocus(component);
|
|
213
195
|
}
|
|
214
196
|
}
|
|
215
197
|
this.requestRender();
|
|
216
198
|
},
|
|
217
199
|
isHidden: () => entry.hidden,
|
|
200
|
+
focus: () => {
|
|
201
|
+
if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
|
|
202
|
+
return;
|
|
203
|
+
if (this.focusedComponent !== component) {
|
|
204
|
+
this.setFocus(component);
|
|
205
|
+
}
|
|
206
|
+
entry.focusOrder = ++this.focusOrderCounter;
|
|
207
|
+
this.requestRender();
|
|
208
|
+
},
|
|
209
|
+
unfocus: () => {
|
|
210
|
+
if (this.focusedComponent !== component)
|
|
211
|
+
return;
|
|
212
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
213
|
+
this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
|
|
214
|
+
this.requestRender();
|
|
215
|
+
},
|
|
216
|
+
isFocused: () => this.focusedComponent === component,
|
|
218
217
|
};
|
|
219
218
|
}
|
|
220
219
|
/** Hide the topmost overlay and restore previous focus. */
|
|
@@ -222,9 +221,11 @@ export class TUI extends Container {
|
|
|
222
221
|
const overlay = this.overlayStack.pop();
|
|
223
222
|
if (!overlay)
|
|
224
223
|
return;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
if (this.focusedComponent === overlay.component) {
|
|
225
|
+
// Find topmost visible overlay, or fall back to preFocus
|
|
226
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
227
|
+
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
228
|
+
}
|
|
228
229
|
if (this.overlayStack.length === 0)
|
|
229
230
|
this.terminal.hideCursor();
|
|
230
231
|
this.requestRender();
|
|
@@ -242,9 +243,11 @@ export class TUI extends Container {
|
|
|
242
243
|
}
|
|
243
244
|
return true;
|
|
244
245
|
}
|
|
245
|
-
/** Find the topmost visible overlay, if any */
|
|
246
|
+
/** Find the topmost visible capturing overlay, if any */
|
|
246
247
|
getTopmostVisibleOverlay() {
|
|
247
248
|
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
|
249
|
+
if (this.overlayStack[i].options?.nonCapturing)
|
|
250
|
+
continue;
|
|
248
251
|
if (this.isOverlayVisible(this.overlayStack[i])) {
|
|
249
252
|
return this.overlayStack[i];
|
|
250
253
|
}
|
|
@@ -256,50 +259,36 @@ export class TUI extends Container {
|
|
|
256
259
|
for (const overlay of this.overlayStack)
|
|
257
260
|
overlay.component.invalidate?.();
|
|
258
261
|
}
|
|
259
|
-
/**
|
|
260
|
-
* Timestamp when `start()` was called.
|
|
261
|
-
* Used by startup grace period to suppress screen-clearing full redraws.
|
|
262
|
-
*/
|
|
263
|
-
startedAtMs = 0;
|
|
264
|
-
/**
|
|
265
|
-
* Duration (ms) after `start()` during which shrink-triggered full redraws
|
|
266
|
-
* use a gentler line-by-line overwrite instead of screen clear.
|
|
267
|
-
*
|
|
268
|
-
* This prevents the visual flicker that occurs when session resume causes
|
|
269
|
-
* rapid content height changes (extension hooks, widget adds/removes) before
|
|
270
|
-
* the full message history is rendered.
|
|
271
|
-
*/
|
|
272
|
-
static STARTUP_GRACE_MS = 3000;
|
|
273
262
|
start() {
|
|
274
263
|
this.stopped = false;
|
|
275
|
-
this.startedAtMs = Date.now();
|
|
276
264
|
this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
|
|
277
265
|
this.terminal.hideCursor();
|
|
278
266
|
this.queryCellSize();
|
|
279
267
|
this.requestRender();
|
|
280
268
|
}
|
|
269
|
+
addInputListener(listener) {
|
|
270
|
+
this.inputListeners.add(listener);
|
|
271
|
+
return () => {
|
|
272
|
+
this.inputListeners.delete(listener);
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
removeInputListener(listener) {
|
|
276
|
+
this.inputListeners.delete(listener);
|
|
277
|
+
}
|
|
281
278
|
queryCellSize() {
|
|
282
279
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
283
280
|
if (!getCapabilities().images) {
|
|
284
281
|
return;
|
|
285
282
|
}
|
|
286
|
-
// Skip cell size query inside tmux — tmux doesn't forward CSI 16 t responses,
|
|
287
|
-
// so cellSizeQueryPending would stay true and parseCellSizeResponse would eat
|
|
288
|
-
// bare \x1b (Escape key) as a "partial response", breaking Escape handling.
|
|
289
|
-
if (process.env.TMUX) {
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
283
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
293
284
|
// Response format: CSI 6 ; height ; width t
|
|
294
|
-
this.cellSizeQueryPending = true;
|
|
295
285
|
this.terminal.write("\x1b[16t");
|
|
296
286
|
}
|
|
297
287
|
stop() {
|
|
298
288
|
this.stopped = true;
|
|
299
|
-
if (this.
|
|
300
|
-
clearTimeout(this.
|
|
301
|
-
this.
|
|
302
|
-
this.renderRequested = false;
|
|
289
|
+
if (this.renderTimer) {
|
|
290
|
+
clearTimeout(this.renderTimer);
|
|
291
|
+
this.renderTimer = undefined;
|
|
303
292
|
}
|
|
304
293
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
305
294
|
if (this.previousLines.length > 0) {
|
|
@@ -316,123 +305,73 @@ export class TUI extends Container {
|
|
|
316
305
|
this.terminal.showCursor();
|
|
317
306
|
this.terminal.stop();
|
|
318
307
|
}
|
|
319
|
-
/** When >0, scheduled renders are deferred until the batch completes. */
|
|
320
|
-
renderBatchDepth = 0;
|
|
321
|
-
/** Whether a render was requested while batching was active. */
|
|
322
|
-
renderDeferredDuringBatch = false;
|
|
323
|
-
/** Whether a forced render was requested while batching was active. */
|
|
324
|
-
renderForceDeferredDuringBatch = false;
|
|
325
|
-
/**
|
|
326
|
-
* Begin a render batch — all `requestRender()` calls are coalesced and
|
|
327
|
-
* deferred until the matching `endRenderBatch()`. Nestable.
|
|
328
|
-
*
|
|
329
|
-
* Use to prevent intermediate renders (and the screen clears they cause)
|
|
330
|
-
* during multi-step UI mutations such as session resume.
|
|
331
|
-
*
|
|
332
|
-
* @returns {void}
|
|
333
|
-
*/
|
|
334
|
-
beginRenderBatch() {
|
|
335
|
-
this.renderBatchDepth++;
|
|
336
|
-
}
|
|
337
|
-
/**
|
|
338
|
-
* End a render batch. When the outermost batch ends, a single render is
|
|
339
|
-
* scheduled if any were deferred.
|
|
340
|
-
*
|
|
341
|
-
* @returns {void}
|
|
342
|
-
*/
|
|
343
|
-
endRenderBatch() {
|
|
344
|
-
if (this.renderBatchDepth <= 0)
|
|
345
|
-
return;
|
|
346
|
-
this.renderBatchDepth--;
|
|
347
|
-
if (this.renderBatchDepth === 0 && this.renderDeferredDuringBatch) {
|
|
348
|
-
const wasForce = this.renderForceDeferredDuringBatch;
|
|
349
|
-
this.renderDeferredDuringBatch = false;
|
|
350
|
-
this.renderForceDeferredDuringBatch = false;
|
|
351
|
-
this.requestRender(wasForce);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
308
|
requestRender(force = false) {
|
|
355
309
|
if (force) {
|
|
356
310
|
this.previousLines = [];
|
|
357
311
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
312
|
+
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
358
313
|
this.cursorRow = 0;
|
|
359
314
|
this.hardwareCursorRow = 0;
|
|
360
315
|
this.maxLinesRendered = 0;
|
|
361
316
|
this.previousViewportTop = 0;
|
|
362
|
-
this.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
317
|
+
if (this.renderTimer) {
|
|
318
|
+
clearTimeout(this.renderTimer);
|
|
319
|
+
this.renderTimer = undefined;
|
|
320
|
+
}
|
|
321
|
+
this.renderRequested = true;
|
|
322
|
+
process.nextTick(() => {
|
|
323
|
+
if (this.stopped || !this.renderRequested) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.renderRequested = false;
|
|
327
|
+
this.lastRenderAt = performance.now();
|
|
328
|
+
this.doRender();
|
|
329
|
+
});
|
|
368
330
|
return;
|
|
369
331
|
}
|
|
370
332
|
if (this.renderRequested)
|
|
371
333
|
return;
|
|
372
|
-
this.
|
|
334
|
+
this.renderRequested = true;
|
|
335
|
+
process.nextTick(() => this.scheduleRender());
|
|
373
336
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Schedule a single coalesced render in the check phase.
|
|
376
|
-
*
|
|
377
|
-
* On Bun, `setImmediate` behaves like a microtask and never enters the I/O poll
|
|
378
|
-
* phase, so stdin data callbacks are starved during streaming. `setTimeout(fn, 0)`
|
|
379
|
-
* forces a real timer (~1ms) that guarantees I/O polling between renders.
|
|
380
|
-
*
|
|
381
|
-
* On Node.js, `setTimeout(0)` has a 1ms minimum delay — slightly slower than
|
|
382
|
-
* `setImmediate` but still imperceptible (<13ms human threshold).
|
|
383
|
-
*
|
|
384
|
-
* @see Plan 177 — Bun setImmediate does not yield to I/O
|
|
385
|
-
* @returns {void}
|
|
386
|
-
*/
|
|
387
337
|
scheduleRender() {
|
|
388
|
-
this.renderRequested
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
338
|
+
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const elapsed = performance.now() - this.lastRenderAt;
|
|
342
|
+
const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
343
|
+
this.renderTimer = setTimeout(() => {
|
|
344
|
+
this.renderTimer = undefined;
|
|
345
|
+
if (this.stopped || !this.renderRequested) {
|
|
393
346
|
return;
|
|
347
|
+
}
|
|
348
|
+
this.renderRequested = false;
|
|
349
|
+
this.lastRenderAt = performance.now();
|
|
394
350
|
this.doRender();
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Register an input listener. Listeners run before the focused component and can
|
|
401
|
-
* consume input (return `{consume: true}`) or transform it (return `{data: newData}`).
|
|
402
|
-
*
|
|
403
|
-
* @param listener - Listener function
|
|
404
|
-
* @returns Unsubscribe function
|
|
405
|
-
*/
|
|
406
|
-
addInputListener(listener) {
|
|
407
|
-
this.inputListeners.add(listener);
|
|
408
|
-
return () => {
|
|
409
|
-
this.inputListeners.delete(listener);
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
/**
|
|
413
|
-
* Remove a previously registered input listener.
|
|
414
|
-
*
|
|
415
|
-
* @param listener - The listener function to remove
|
|
416
|
-
*/
|
|
417
|
-
removeInputListener(listener) {
|
|
418
|
-
this.inputListeners.delete(listener);
|
|
351
|
+
if (this.renderRequested) {
|
|
352
|
+
this.scheduleRender();
|
|
353
|
+
}
|
|
354
|
+
}, delay);
|
|
419
355
|
}
|
|
420
356
|
handleInput(data) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
424
|
-
|
|
425
|
-
|
|
357
|
+
if (this.inputListeners.size > 0) {
|
|
358
|
+
let current = data;
|
|
359
|
+
for (const listener of this.inputListeners) {
|
|
360
|
+
const result = listener(current);
|
|
361
|
+
if (result?.consume) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (result?.data !== undefined) {
|
|
365
|
+
current = result.data;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (current.length === 0) {
|
|
426
369
|
return;
|
|
427
|
-
data = filtered;
|
|
428
|
-
}
|
|
429
|
-
// Mouse events — intercept before any key handling.
|
|
430
|
-
// Always consumed: mouse sequences must never reach components as text.
|
|
431
|
-
if (isMouseEvent(data)) {
|
|
432
|
-
const event = parseMouseEvent(data);
|
|
433
|
-
if (event && this.onMouse) {
|
|
434
|
-
this.onMouse(event);
|
|
435
370
|
}
|
|
371
|
+
data = current;
|
|
372
|
+
}
|
|
373
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
374
|
+
if (this.consumeCellSizeResponse(data)) {
|
|
436
375
|
return;
|
|
437
376
|
}
|
|
438
377
|
// Global debug key handler (Shift+Ctrl+D)
|
|
@@ -454,23 +393,6 @@ export class TUI extends Container {
|
|
|
454
393
|
this.setFocus(focusedOverlay.preFocus);
|
|
455
394
|
}
|
|
456
395
|
}
|
|
457
|
-
// Run input listeners — can consume or transform input
|
|
458
|
-
if (this.inputListeners.size > 0) {
|
|
459
|
-
let current = data;
|
|
460
|
-
for (const listener of this.inputListeners) {
|
|
461
|
-
const result = listener(current);
|
|
462
|
-
if (result?.consume) {
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (result?.data !== undefined) {
|
|
466
|
-
current = result.data;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
if (current.length === 0) {
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
data = current;
|
|
473
|
-
}
|
|
474
396
|
// Pass input to focused component (including Ctrl+C)
|
|
475
397
|
// The focused component can decide how to handle Ctrl+C
|
|
476
398
|
if (this.focusedComponent?.handleInput) {
|
|
@@ -478,53 +400,26 @@ export class TUI extends Container {
|
|
|
478
400
|
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
479
401
|
return;
|
|
480
402
|
}
|
|
481
|
-
if (process.env.TALLOW_KEY_DEBUG && (data === "\x1b" || data === "\x03")) {
|
|
482
|
-
const escMatch = matchesKey(data, "escape");
|
|
483
|
-
const ctrlcMatch = matchesKey(data, "ctrl+c");
|
|
484
|
-
const comp = this.focusedComponent;
|
|
485
|
-
const hasOnEscape = typeof comp.onEscape === "function";
|
|
486
|
-
const actionCount = comp.actionHandlers instanceof Map ? comp.actionHandlers.size : -1;
|
|
487
|
-
process.stderr.write(`[key] ${data === "\x1b" ? "ESC" : "C-C"} matchEsc=${escMatch} matchCC=${ctrlcMatch} hasOnEscape=${hasOnEscape} actions=${actionCount}\n`);
|
|
488
|
-
}
|
|
489
403
|
this.focusedComponent.handleInput(data);
|
|
490
404
|
this.requestRender();
|
|
491
405
|
}
|
|
492
406
|
}
|
|
493
|
-
|
|
407
|
+
consumeCellSizeResponse(data) {
|
|
494
408
|
// Response format: ESC [ 6 ; height ; width t
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (match) {
|
|
499
|
-
const heightPx = parseInt(match[1], 10);
|
|
500
|
-
const widthPx = parseInt(match[2], 10);
|
|
501
|
-
if (heightPx > 0 && widthPx > 0) {
|
|
502
|
-
setCellDimensions({ widthPx, heightPx });
|
|
503
|
-
// Invalidate all components so images re-render with correct dimensions
|
|
504
|
-
this.invalidate();
|
|
505
|
-
this.requestRender();
|
|
506
|
-
}
|
|
507
|
-
// Remove the response from buffer
|
|
508
|
-
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
509
|
-
this.cellSizeQueryPending = false;
|
|
409
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
410
|
+
if (!match) {
|
|
411
|
+
return false;
|
|
510
412
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
516
|
-
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
517
|
-
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
518
|
-
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
519
|
-
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
520
|
-
return "";
|
|
521
|
-
}
|
|
413
|
+
const heightPx = parseInt(match[1], 10);
|
|
414
|
+
const widthPx = parseInt(match[2], 10);
|
|
415
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
416
|
+
return true;
|
|
522
417
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
this.
|
|
526
|
-
this.
|
|
527
|
-
return
|
|
418
|
+
setCellDimensions({ widthPx, heightPx });
|
|
419
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
420
|
+
this.invalidate();
|
|
421
|
+
this.requestRender();
|
|
422
|
+
return true;
|
|
528
423
|
}
|
|
529
424
|
/**
|
|
530
425
|
* Resolve overlay layout from options.
|
|
@@ -652,7 +547,7 @@ export class TUI extends Container {
|
|
|
652
547
|
return marginLeft + Math.floor((availWidth - width) / 2);
|
|
653
548
|
}
|
|
654
549
|
}
|
|
655
|
-
/** Composite all overlays into content lines (
|
|
550
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
656
551
|
compositeOverlays(lines, termWidth, termHeight) {
|
|
657
552
|
if (this.overlayStack.length === 0)
|
|
658
553
|
return lines;
|
|
@@ -660,10 +555,9 @@ export class TUI extends Container {
|
|
|
660
555
|
// Pre-render all visible overlays and calculate positions
|
|
661
556
|
const rendered = [];
|
|
662
557
|
let minLinesNeeded = result.length;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
continue;
|
|
558
|
+
const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
|
|
559
|
+
visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
|
|
560
|
+
for (const entry of visibleEntries) {
|
|
667
561
|
const { component, options } = entry;
|
|
668
562
|
// Get layout with height=0 first to determine width and maxHeight
|
|
669
563
|
// (width and maxHeight don't depend on overlay height)
|
|
@@ -679,16 +573,15 @@ export class TUI extends Container {
|
|
|
679
573
|
rendered.push({ overlayLines, row, col, w: width });
|
|
680
574
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
681
575
|
}
|
|
682
|
-
//
|
|
683
|
-
// maxLinesRendered
|
|
684
|
-
|
|
576
|
+
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
577
|
+
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
578
|
+
// inflation that pushed content into scrollback on terminal widen.
|
|
579
|
+
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
|
|
685
580
|
// Extend result with empty lines if content is too short for overlay placement or working area
|
|
686
581
|
while (result.length < workingHeight) {
|
|
687
582
|
result.push("");
|
|
688
583
|
}
|
|
689
584
|
const viewportStart = Math.max(0, workingHeight - termHeight);
|
|
690
|
-
// Track which lines were modified for final verification
|
|
691
|
-
const modifiedLines = new Set();
|
|
692
585
|
// Composite each overlay
|
|
693
586
|
for (const { overlayLines, row, col, w } of rendered) {
|
|
694
587
|
for (let i = 0; i < overlayLines.length; i++) {
|
|
@@ -700,20 +593,9 @@ export class TUI extends Container {
|
|
|
700
593
|
? sliceByColumn(overlayLines[i], 0, w, true)
|
|
701
594
|
: overlayLines[i];
|
|
702
595
|
result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
|
|
703
|
-
modifiedLines.add(idx);
|
|
704
596
|
}
|
|
705
597
|
}
|
|
706
598
|
}
|
|
707
|
-
// Final verification: ensure no composited line exceeds terminal width
|
|
708
|
-
// This is a belt-and-suspenders safeguard - compositeLineAt should already
|
|
709
|
-
// guarantee this, but we verify here to prevent crashes from any edge cases
|
|
710
|
-
// Only check lines that were actually modified (optimization)
|
|
711
|
-
for (const idx of modifiedLines) {
|
|
712
|
-
const lineWidth = visibleWidth(result[idx]);
|
|
713
|
-
if (lineWidth > termWidth) {
|
|
714
|
-
result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
599
|
return result;
|
|
718
600
|
}
|
|
719
601
|
static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
@@ -796,8 +678,13 @@ export class TUI extends Container {
|
|
|
796
678
|
return;
|
|
797
679
|
const width = this.terminal.columns;
|
|
798
680
|
const height = this.terminal.rows;
|
|
799
|
-
|
|
800
|
-
|
|
681
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
682
|
+
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
683
|
+
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
|
684
|
+
let prevViewportTop = heightChanged
|
|
685
|
+
? Math.max(0, previousBufferLength - height)
|
|
686
|
+
: this.previousViewportTop;
|
|
687
|
+
let viewportTop = prevViewportTop;
|
|
801
688
|
let hardwareCursorRow = this.hardwareCursorRow;
|
|
802
689
|
const computeLineDiff = (targetRow) => {
|
|
803
690
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
@@ -813,22 +700,12 @@ export class TUI extends Container {
|
|
|
813
700
|
// Extract cursor position before applying line resets (marker must be found first)
|
|
814
701
|
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
815
702
|
newLines = this.applyLineResets(newLines);
|
|
816
|
-
//
|
|
817
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
818
|
-
// Whether we are within the startup grace period where screen-clearing
|
|
819
|
-
// full redraws are softened to prevent flicker during session resume.
|
|
820
|
-
const inStartupGrace = this.startedAtMs > 0 && Date.now() - this.startedAtMs < TUI.STARTUP_GRACE_MS;
|
|
821
|
-
// Helper to clear viewport (and optionally scrollback) and render all new lines
|
|
703
|
+
// Helper to clear scrollback and viewport and render all new lines
|
|
822
704
|
const fullRender = (clear) => {
|
|
823
705
|
this.fullRedrawCount += 1;
|
|
824
706
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
825
|
-
if (clear
|
|
826
|
-
buffer += "\x1b[
|
|
827
|
-
this.pendingScrollbackClear = false;
|
|
828
|
-
}
|
|
829
|
-
else if (clear) {
|
|
830
|
-
buffer += "\x1b[2J\x1b[H"; // Clear screen and home (preserve scrollback)
|
|
831
|
-
}
|
|
707
|
+
if (clear)
|
|
708
|
+
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
|
832
709
|
for (let i = 0; i < newLines.length; i++) {
|
|
833
710
|
if (i > 0)
|
|
834
711
|
buffer += "\r\n";
|
|
@@ -845,49 +722,12 @@ export class TUI extends Container {
|
|
|
845
722
|
else {
|
|
846
723
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
847
724
|
}
|
|
848
|
-
|
|
849
|
-
this.
|
|
850
|
-
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
851
|
-
this.previousLines = newLines;
|
|
852
|
-
this.previousWidth = width;
|
|
853
|
-
};
|
|
854
|
-
/**
|
|
855
|
-
* Gentle full redraw: home cursor + overwrite each line + clear below.
|
|
856
|
-
*
|
|
857
|
-
* Used during the startup grace period instead of fullRender(true) for
|
|
858
|
-
* shrink-triggered redraws. Avoids the visible blank frame caused by
|
|
859
|
-
* `\x1b[2J` (clear screen), which makes messages appear to flash in and
|
|
860
|
-
* out when session resume triggers rapid content height changes.
|
|
861
|
-
*
|
|
862
|
-
* Unlike fullRender(true), this never clears the screen — it writes each
|
|
863
|
-
* line with a preceding `\x1b[2K` (clear line) so stale content is
|
|
864
|
-
* overwritten without a blank frame. Lines below the new content are
|
|
865
|
-
* individually erased.
|
|
866
|
-
*/
|
|
867
|
-
const gentleFullRender = () => {
|
|
868
|
-
this.fullRedrawCount += 1;
|
|
869
|
-
let buffer = "\x1b[?2026h\x1b[H"; // Begin synchronized output + home cursor
|
|
870
|
-
for (let i = 0; i < newLines.length; i++) {
|
|
871
|
-
buffer += "\x1b[2K"; // Clear current line
|
|
872
|
-
buffer += newLines[i];
|
|
873
|
-
if (i < newLines.length - 1)
|
|
874
|
-
buffer += "\r\n";
|
|
875
|
-
}
|
|
876
|
-
// Erase lines that were previously rendered but are no longer needed
|
|
877
|
-
const staleLines = Math.max(0, this.maxLinesRendered - newLines.length);
|
|
878
|
-
for (let i = 0; i < staleLines; i++) {
|
|
879
|
-
buffer += "\r\n\x1b[2K";
|
|
880
|
-
}
|
|
881
|
-
buffer += "\x1b[?2026l"; // End synchronized output
|
|
882
|
-
this.terminal.write(buffer);
|
|
883
|
-
this.cursorRow = Math.max(0, newLines.length + staleLines - 1);
|
|
884
|
-
this.hardwareCursorRow = this.cursorRow;
|
|
885
|
-
this.maxLinesRendered = newLines.length;
|
|
886
|
-
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
887
|
-
this.rollingShrinkPeak = newLines.length;
|
|
725
|
+
const bufferLength = Math.max(height, newLines.length);
|
|
726
|
+
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
888
727
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
889
728
|
this.previousLines = newLines;
|
|
890
729
|
this.previousWidth = width;
|
|
730
|
+
this.previousHeight = height;
|
|
891
731
|
};
|
|
892
732
|
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
|
893
733
|
const logRedraw = (reason) => {
|
|
@@ -898,14 +738,22 @@ export class TUI extends Container {
|
|
|
898
738
|
fs.appendFileSync(logPath, msg);
|
|
899
739
|
};
|
|
900
740
|
// First render - just output everything without clearing (assumes clean screen)
|
|
901
|
-
if (this.previousLines.length === 0 && !widthChanged) {
|
|
741
|
+
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
|
902
742
|
logRedraw("first render");
|
|
903
743
|
fullRender(false);
|
|
904
744
|
return;
|
|
905
745
|
}
|
|
906
|
-
// Width
|
|
746
|
+
// Width changes always need a full re-render because wrapping changes.
|
|
907
747
|
if (widthChanged) {
|
|
908
|
-
logRedraw(`width changed (${this.previousWidth} -> ${width})`);
|
|
748
|
+
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
|
|
749
|
+
fullRender(true);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
753
|
+
// but Termux changes height when the software keyboard shows or hides.
|
|
754
|
+
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
755
|
+
if (heightChanged && !isTermuxSession()) {
|
|
756
|
+
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
|
909
757
|
fullRender(true);
|
|
910
758
|
return;
|
|
911
759
|
}
|
|
@@ -916,69 +764,9 @@ export class TUI extends Container {
|
|
|
916
764
|
newLines.length < this.maxLinesRendered &&
|
|
917
765
|
this.overlayStack.length === 0) {
|
|
918
766
|
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
|
919
|
-
|
|
920
|
-
gentleFullRender();
|
|
921
|
-
}
|
|
922
|
-
else {
|
|
923
|
-
fullRender(true);
|
|
924
|
-
}
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
// Large content shrinks (e.g., tool output collapse) are hard to partially redraw
|
|
928
|
-
// correctly — cursor positions drift when many lines disappear at once, causing
|
|
929
|
-
// ghost copies of previous frames. Force a full redraw when the shrink exceeds a
|
|
930
|
-
// threshold. More targeted than clearOnShrink (which fires on ANY shrink).
|
|
931
|
-
const shrinkDelta = this.previousLines.length - newLines.length;
|
|
932
|
-
if (shrinkDelta > 5 && this.overlayStack.length === 0) {
|
|
933
|
-
logRedraw(`large shrink (${shrinkDelta} lines)`);
|
|
934
|
-
if (inStartupGrace) {
|
|
935
|
-
gentleFullRender();
|
|
936
|
-
}
|
|
937
|
-
else {
|
|
938
|
-
fullRender(true);
|
|
939
|
-
}
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
// Rolling shrink detection: catches gradual shrinks where each individual
|
|
943
|
-
// frame-to-frame delta is ≤5 lines (below the large-shrink threshold) but the
|
|
944
|
-
// accumulated shrink from a recent peak exceeds it. This happens when
|
|
945
|
-
// pollStates.clear() collapses tool-result anchors across multiple render
|
|
946
|
-
// cycles while animations (loader, widget spinners) keep triggering renders.
|
|
947
|
-
if (newLines.length >= this.rollingShrinkPeak) {
|
|
948
|
-
this.rollingShrinkPeak = newLines.length;
|
|
949
|
-
}
|
|
950
|
-
else if (this.overlayStack.length === 0 && this.rollingShrinkPeak - newLines.length > 5) {
|
|
951
|
-
logRedraw(`rolling shrink (peak=${this.rollingShrinkPeak}, now=${newLines.length}, delta=${this.rollingShrinkPeak - newLines.length})`);
|
|
952
|
-
if (inStartupGrace) {
|
|
953
|
-
gentleFullRender();
|
|
954
|
-
}
|
|
955
|
-
else {
|
|
956
|
-
fullRender(true);
|
|
957
|
-
}
|
|
767
|
+
fullRender(true);
|
|
958
768
|
return;
|
|
959
769
|
}
|
|
960
|
-
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
|
|
961
|
-
// Detect viewport basis drift: maxLinesRendered exceeds actual content,
|
|
962
|
-
// causing viewportTop to be computed from a stale high-water mark.
|
|
963
|
-
// Compare against newLines.length (not previousLines.length) so the
|
|
964
|
-
// correction fires on the same render cycle as a shrink, not one cycle late.
|
|
965
|
-
const hasViewportBasisDrift = this.overlayStack.length === 0 &&
|
|
966
|
-
this.previousLines.length > 0 &&
|
|
967
|
-
this.maxLinesRendered > newLines.length &&
|
|
968
|
-
prevViewportTop !== previousContentViewportTop;
|
|
969
|
-
// After shrink-heavy updates with clearOnShrink disabled, maxLinesRendered can stay larger
|
|
970
|
-
// than current content. Instead of a destructive full redraw (which clears the screen and
|
|
971
|
-
// disrupts scrollback), realign the working-area coordinates so the partial-redraw path
|
|
972
|
-
// can operate on a consistent viewport basis.
|
|
973
|
-
// Use newLines.length (not previousLines.length) so the correction is exact for the
|
|
974
|
-
// current frame — previousLines.length can still exceed newLines.length, leaving
|
|
975
|
-
// residual drift for one more cycle and causing ghost lines.
|
|
976
|
-
if (hasViewportBasisDrift) {
|
|
977
|
-
this.maxLinesRendered = newLines.length;
|
|
978
|
-
viewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
979
|
-
prevViewportTop = viewportTop;
|
|
980
|
-
this.previousViewportTop = viewportTop;
|
|
981
|
-
}
|
|
982
770
|
// Find first and last changed lines
|
|
983
771
|
let firstChanged = -1;
|
|
984
772
|
let lastChanged = -1;
|
|
@@ -1004,7 +792,8 @@ export class TUI extends Container {
|
|
|
1004
792
|
// No changes - but still need to update hardware cursor position if it moved
|
|
1005
793
|
if (firstChanged === -1) {
|
|
1006
794
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1007
|
-
this.previousViewportTop =
|
|
795
|
+
this.previousViewportTop = prevViewportTop;
|
|
796
|
+
this.previousHeight = height;
|
|
1008
797
|
return;
|
|
1009
798
|
}
|
|
1010
799
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
@@ -1013,6 +802,11 @@ export class TUI extends Container {
|
|
|
1013
802
|
let buffer = "\x1b[?2026h";
|
|
1014
803
|
// Move to end of new content (clamp to 0 for empty content)
|
|
1015
804
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
805
|
+
if (targetRow < prevViewportTop) {
|
|
806
|
+
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
|
807
|
+
fullRender(true);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
1016
810
|
const lineDiff = computeLineDiff(targetRow);
|
|
1017
811
|
if (lineDiff > 0)
|
|
1018
812
|
buffer += `\x1b[${lineDiff}B`;
|
|
@@ -1023,12 +817,7 @@ export class TUI extends Container {
|
|
|
1023
817
|
const extraLines = this.previousLines.length - newLines.length;
|
|
1024
818
|
if (extraLines > height) {
|
|
1025
819
|
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
|
1026
|
-
|
|
1027
|
-
gentleFullRender();
|
|
1028
|
-
}
|
|
1029
|
-
else {
|
|
1030
|
-
fullRender(true);
|
|
1031
|
-
}
|
|
820
|
+
fullRender(true);
|
|
1032
821
|
return;
|
|
1033
822
|
}
|
|
1034
823
|
if (extraLines > 0) {
|
|
@@ -1050,18 +839,15 @@ export class TUI extends Container {
|
|
|
1050
839
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1051
840
|
this.previousLines = newLines;
|
|
1052
841
|
this.previousWidth = width;
|
|
1053
|
-
this.
|
|
842
|
+
this.previousHeight = height;
|
|
843
|
+
this.previousViewportTop = prevViewportTop;
|
|
1054
844
|
return;
|
|
1055
845
|
}
|
|
1056
|
-
//
|
|
846
|
+
// Differential rendering can only touch what was actually visible.
|
|
847
|
+
// If the first changed line is above the previous viewport, we need a full redraw.
|
|
1057
848
|
if (firstChanged < prevViewportTop) {
|
|
1058
849
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1059
|
-
|
|
1060
|
-
gentleFullRender();
|
|
1061
|
-
}
|
|
1062
|
-
else {
|
|
1063
|
-
fullRender(true);
|
|
1064
|
-
}
|
|
850
|
+
fullRender(true);
|
|
1065
851
|
return;
|
|
1066
852
|
}
|
|
1067
853
|
// Render from first changed line to end
|
|
@@ -1097,7 +883,7 @@ export class TUI extends Container {
|
|
|
1097
883
|
if (i > firstChanged)
|
|
1098
884
|
buffer += "\r\n";
|
|
1099
885
|
buffer += "\x1b[2K"; // Clear current line
|
|
1100
|
-
|
|
886
|
+
const line = newLines[i];
|
|
1101
887
|
const isImage = isImageLine(line);
|
|
1102
888
|
if (!isImage && visibleWidth(line) > width) {
|
|
1103
889
|
// Log all lines to crash file for debugging
|
|
@@ -1113,22 +899,17 @@ export class TUI extends Container {
|
|
|
1113
899
|
].join("\n");
|
|
1114
900
|
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
|
1115
901
|
fs.writeFileSync(crashLogPath, crashData);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
throw new Error(errorMsg);
|
|
1128
|
-
}
|
|
1129
|
-
// Production: defensively clamp the line instead of crashing
|
|
1130
|
-
line = truncateToWidth(line, width, "");
|
|
1131
|
-
newLines[i] = line;
|
|
902
|
+
// Clean up terminal state before throwing
|
|
903
|
+
this.stop();
|
|
904
|
+
const errorMsg = [
|
|
905
|
+
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
906
|
+
"",
|
|
907
|
+
"This is likely caused by a custom TUI component not truncating its output.",
|
|
908
|
+
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
909
|
+
"",
|
|
910
|
+
`Debug log written to: ${crashLogPath}`,
|
|
911
|
+
].join("\n");
|
|
912
|
+
throw new Error(errorMsg);
|
|
1132
913
|
}
|
|
1133
914
|
buffer += line;
|
|
1134
915
|
}
|
|
@@ -1136,21 +917,13 @@ export class TUI extends Container {
|
|
|
1136
917
|
let finalCursorRow = renderEnd;
|
|
1137
918
|
// If we had more lines before, clear them and move cursor back
|
|
1138
919
|
if (this.previousLines.length > newLines.length) {
|
|
1139
|
-
const extraLines = this.previousLines.length - newLines.length;
|
|
1140
|
-
// Safety guard: when extraLines exceeds terminal height, the \r\n
|
|
1141
|
-
// sequence would scroll the viewport and desynchronize cursor tracking.
|
|
1142
|
-
// Fall back to a full redraw instead (same guard as the deleted-lines-only path).
|
|
1143
|
-
if (extraLines > height) {
|
|
1144
|
-
logRedraw(`extraLines > height in diff path (${extraLines} > ${height})`);
|
|
1145
|
-
fullRender(true);
|
|
1146
|
-
return;
|
|
1147
|
-
}
|
|
1148
920
|
// Move to end of new content first if we stopped before it
|
|
1149
921
|
if (renderEnd < newLines.length - 1) {
|
|
1150
922
|
const moveDown = newLines.length - 1 - renderEnd;
|
|
1151
923
|
buffer += `\x1b[${moveDown}B`;
|
|
1152
924
|
finalCursorRow = newLines.length - 1;
|
|
1153
925
|
}
|
|
926
|
+
const extraLines = this.previousLines.length - newLines.length;
|
|
1154
927
|
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
1155
928
|
buffer += "\r\n\x1b[2K";
|
|
1156
929
|
}
|
|
@@ -1161,16 +934,7 @@ export class TUI extends Container {
|
|
|
1161
934
|
if (process.env.PI_TUI_DEBUG === "1") {
|
|
1162
935
|
const debugDir = "/tmp/tui";
|
|
1163
936
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
1164
|
-
|
|
1165
|
-
const heightDelta = newLines.length - this.previousLines.length;
|
|
1166
|
-
if (Math.abs(heightDelta) > 5) {
|
|
1167
|
-
const fluctPath = path.join(debugDir, "height-fluctuations.log");
|
|
1168
|
-
const fluctMsg = `[${new Date().toISOString()}] heightFluctuation: ${heightDelta > 0 ? "+" : ""}${heightDelta} ` +
|
|
1169
|
-
`(prev=${this.previousLines.length}, new=${newLines.length}, ` +
|
|
1170
|
-
`maxLR=${this.maxLinesRendered}, viewportTop=${viewportTop})\n`;
|
|
1171
|
-
fs.appendFileSync(fluctPath, fluctMsg);
|
|
1172
|
-
}
|
|
1173
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${crypto.randomUUID().slice(0, 8)}.log`);
|
|
937
|
+
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
1174
938
|
const debugData = [
|
|
1175
939
|
`firstChanged: ${firstChanged}`,
|
|
1176
940
|
`viewportTop: ${viewportTop}`,
|
|
@@ -1183,7 +947,6 @@ export class TUI extends Container {
|
|
|
1183
947
|
`cursorPos: ${JSON.stringify(cursorPos)}`,
|
|
1184
948
|
`newLines.length: ${newLines.length}`,
|
|
1185
949
|
`previousLines.length: ${this.previousLines.length}`,
|
|
1186
|
-
`maxLinesRendered: ${this.maxLinesRendered}`,
|
|
1187
950
|
"",
|
|
1188
951
|
"=== newLines ===",
|
|
1189
952
|
JSON.stringify(newLines, null, 2),
|
|
@@ -1194,7 +957,7 @@ export class TUI extends Container {
|
|
|
1194
957
|
"=== buffer ===",
|
|
1195
958
|
JSON.stringify(buffer),
|
|
1196
959
|
].join("\n");
|
|
1197
|
-
fs.writeFileSync(debugPath, debugData
|
|
960
|
+
fs.writeFileSync(debugPath, debugData);
|
|
1198
961
|
}
|
|
1199
962
|
// Write entire buffer at once
|
|
1200
963
|
this.terminal.write(buffer);
|
|
@@ -1203,24 +966,14 @@ export class TUI extends Container {
|
|
|
1203
966
|
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
|
1204
967
|
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
1205
968
|
this.hardwareCursorRow = finalCursorRow;
|
|
1206
|
-
// Track terminal's working area
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
// content size — this prevents permanent ghost height from stale maxLinesRendered.
|
|
1210
|
-
if (this.overlayStack.length === 0) {
|
|
1211
|
-
this.maxLinesRendered = newLines.length;
|
|
1212
|
-
}
|
|
1213
|
-
else {
|
|
1214
|
-
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
1215
|
-
}
|
|
1216
|
-
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
|
1217
|
-
// Update rolling peak for gradual shrink detection (partial path only —
|
|
1218
|
-
// fullRender paths reset it inside the fullRender closure).
|
|
1219
|
-
this.rollingShrinkPeak = Math.max(this.rollingShrinkPeak, newLines.length);
|
|
969
|
+
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
970
|
+
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
971
|
+
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
1220
972
|
// Position hardware cursor for IME
|
|
1221
973
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
1222
974
|
this.previousLines = newLines;
|
|
1223
975
|
this.previousWidth = width;
|
|
976
|
+
this.previousHeight = height;
|
|
1224
977
|
}
|
|
1225
978
|
/**
|
|
1226
979
|
* Position the hardware cursor for IME candidate window.
|