@fractaal/pi-tui 0.85.4 → 0.86.2

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.
Files changed (184) hide show
  1. package/README.md +175 -19
  2. package/dist/alt-screen-search.d.ts +46 -0
  3. package/dist/alt-screen-search.d.ts.map +1 -0
  4. package/dist/alt-screen-search.js +282 -0
  5. package/dist/alt-screen-search.js.map +1 -0
  6. package/dist/autocomplete.d.ts +2 -1
  7. package/dist/autocomplete.d.ts.map +1 -1
  8. package/dist/autocomplete.js +85 -24
  9. package/dist/autocomplete.js.map +1 -1
  10. package/dist/colors.d.ts +73 -0
  11. package/dist/colors.d.ts.map +1 -0
  12. package/dist/colors.js +273 -0
  13. package/dist/colors.js.map +1 -0
  14. package/dist/components/alt-screen-flash.d.ts +13 -0
  15. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  16. package/dist/components/alt-screen-flash.js +37 -0
  17. package/dist/components/alt-screen-flash.js.map +1 -0
  18. package/dist/components/box.d.ts +3 -1
  19. package/dist/components/box.d.ts.map +1 -1
  20. package/dist/components/box.js +38 -8
  21. package/dist/components/box.js.map +1 -1
  22. package/dist/components/cancellable-loader.d.ts.map +1 -1
  23. package/dist/components/cancellable-loader.js.map +1 -1
  24. package/dist/components/editor.d.ts +6 -1
  25. package/dist/components/editor.d.ts.map +1 -1
  26. package/dist/components/editor.js +118 -24
  27. package/dist/components/editor.js.map +1 -1
  28. package/dist/components/h-stack.d.ts +7 -0
  29. package/dist/components/h-stack.d.ts.map +1 -0
  30. package/dist/components/h-stack.js +43 -0
  31. package/dist/components/h-stack.js.map +1 -0
  32. package/dist/components/image.d.ts +12 -0
  33. package/dist/components/image.d.ts.map +1 -1
  34. package/dist/components/image.js +36 -3
  35. package/dist/components/image.js.map +1 -1
  36. package/dist/components/input.d.ts +12 -1
  37. package/dist/components/input.d.ts.map +1 -1
  38. package/dist/components/input.js +44 -5
  39. package/dist/components/input.js.map +1 -1
  40. package/dist/components/loader.d.ts +2 -0
  41. package/dist/components/loader.d.ts.map +1 -1
  42. package/dist/components/loader.js +10 -3
  43. package/dist/components/loader.js.map +1 -1
  44. package/dist/components/markdown.d.ts +5 -0
  45. package/dist/components/markdown.d.ts.map +1 -1
  46. package/dist/components/markdown.js +150 -9
  47. package/dist/components/markdown.js.map +1 -1
  48. package/dist/components/mouse-region.d.ts +12 -0
  49. package/dist/components/mouse-region.d.ts.map +1 -0
  50. package/dist/components/mouse-region.js +21 -0
  51. package/dist/components/mouse-region.js.map +1 -0
  52. package/dist/components/scroll-view.d.ts +59 -0
  53. package/dist/components/scroll-view.d.ts.map +1 -0
  54. package/dist/components/scroll-view.js +195 -0
  55. package/dist/components/scroll-view.js.map +1 -0
  56. package/dist/components/select-list.d.ts +4 -1
  57. package/dist/components/select-list.d.ts.map +1 -1
  58. package/dist/components/select-list.js +49 -2
  59. package/dist/components/select-list.js.map +1 -1
  60. package/dist/components/settings-list.d.ts +13 -3
  61. package/dist/components/settings-list.d.ts.map +1 -1
  62. package/dist/components/settings-list.js +82 -15
  63. package/dist/components/settings-list.js.map +1 -1
  64. package/dist/components/spacer.d.ts.map +1 -1
  65. package/dist/components/spacer.js.map +1 -1
  66. package/dist/components/stack.d.ts +32 -0
  67. package/dist/components/stack.d.ts.map +1 -0
  68. package/dist/components/stack.js +111 -0
  69. package/dist/components/stack.js.map +1 -0
  70. package/dist/components/text.d.ts.map +1 -1
  71. package/dist/components/text.js +7 -5
  72. package/dist/components/text.js.map +1 -1
  73. package/dist/components/truncated-text.d.ts.map +1 -1
  74. package/dist/components/truncated-text.js.map +1 -1
  75. package/dist/components/v-stack.d.ts +8 -0
  76. package/dist/components/v-stack.d.ts.map +1 -0
  77. package/dist/components/v-stack.js +26 -0
  78. package/dist/components/v-stack.js.map +1 -0
  79. package/dist/editor-component.d.ts.map +1 -1
  80. package/dist/fuzzy.d.ts.map +1 -1
  81. package/dist/fuzzy.js +23 -22
  82. package/dist/fuzzy.js.map +1 -1
  83. package/dist/index.d.ts +18 -6
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +20 -6
  86. package/dist/index.js.map +1 -1
  87. package/dist/keybindings.d.ts +82 -2
  88. package/dist/keybindings.d.ts.map +1 -1
  89. package/dist/keybindings.js +61 -2
  90. package/dist/keybindings.js.map +1 -1
  91. package/dist/keys.d.ts.map +1 -1
  92. package/dist/keys.js.map +1 -1
  93. package/dist/kill-ring.d.ts.map +1 -1
  94. package/dist/kill-ring.js.map +1 -1
  95. package/dist/latex.d.ts +10 -0
  96. package/dist/latex.d.ts.map +1 -0
  97. package/dist/latex.js +1378 -0
  98. package/dist/latex.js.map +1 -0
  99. package/dist/layout-node.d.ts +40 -0
  100. package/dist/layout-node.d.ts.map +1 -0
  101. package/dist/layout-node.js +6 -0
  102. package/dist/layout-node.js.map +1 -0
  103. package/dist/layout.d.ts +42 -0
  104. package/dist/layout.d.ts.map +1 -0
  105. package/dist/layout.js +344 -0
  106. package/dist/layout.js.map +1 -0
  107. package/dist/native-modifiers.d.ts +2 -1
  108. package/dist/native-modifiers.d.ts.map +1 -1
  109. package/dist/native-modifiers.js +3 -43
  110. package/dist/native-modifiers.js.map +1 -1
  111. package/dist/native-module-path.d.ts +7 -0
  112. package/dist/native-module-path.d.ts.map +1 -0
  113. package/dist/native-module-path.js +19 -0
  114. package/dist/native-module-path.js.map +1 -0
  115. package/dist/native-platform.d.ts +20 -0
  116. package/dist/native-platform.d.ts.map +1 -0
  117. package/dist/native-platform.js +42 -0
  118. package/dist/native-platform.js.map +1 -0
  119. package/dist/oklab.d.ts +43 -0
  120. package/dist/oklab.d.ts.map +1 -0
  121. package/dist/oklab.js +210 -0
  122. package/dist/oklab.js.map +1 -0
  123. package/dist/stdin-buffer.d.ts +8 -2
  124. package/dist/stdin-buffer.d.ts.map +1 -1
  125. package/dist/stdin-buffer.js +7 -2
  126. package/dist/stdin-buffer.js.map +1 -1
  127. package/dist/terminal-colors.d.ts +19 -2
  128. package/dist/terminal-colors.d.ts.map +1 -1
  129. package/dist/terminal-colors.js +13 -8
  130. package/dist/terminal-colors.js.map +1 -1
  131. package/dist/terminal-image.d.ts +29 -1
  132. package/dist/terminal-image.d.ts.map +1 -1
  133. package/dist/terminal-image.js +223 -18
  134. package/dist/terminal-image.js.map +1 -1
  135. package/dist/terminal.d.ts +16 -0
  136. package/dist/terminal.d.ts.map +1 -1
  137. package/dist/terminal.js +62 -46
  138. package/dist/terminal.js.map +1 -1
  139. package/dist/tui-alt-screen.d.ts +166 -0
  140. package/dist/tui-alt-screen.d.ts.map +1 -0
  141. package/dist/tui-alt-screen.js +1546 -0
  142. package/dist/tui-alt-screen.js.map +1 -0
  143. package/dist/tui-main-screen.d.ts +39 -0
  144. package/dist/tui-main-screen.d.ts.map +1 -0
  145. package/dist/tui-main-screen.js +594 -0
  146. package/dist/tui-main-screen.js.map +1 -0
  147. package/dist/tui.d.ts +178 -60
  148. package/dist/tui.d.ts.map +1 -1
  149. package/dist/tui.js +303 -641
  150. package/dist/tui.js.map +1 -1
  151. package/dist/undo-stack.d.ts.map +1 -1
  152. package/dist/undo-stack.js.map +1 -1
  153. package/dist/utils.d.ts +22 -0
  154. package/dist/utils.d.ts.map +1 -1
  155. package/dist/utils.js +245 -63
  156. package/dist/utils.js.map +1 -1
  157. package/dist/wheel-scroll.d.ts +23 -0
  158. package/dist/wheel-scroll.d.ts.map +1 -0
  159. package/dist/wheel-scroll.js +73 -0
  160. package/dist/wheel-scroll.js.map +1 -0
  161. package/dist/word-navigation.d.ts.map +1 -1
  162. package/dist/word-navigation.js.map +1 -1
  163. package/native/clipboard.h +208 -0
  164. package/native/darwin/README.md +22 -0
  165. package/native/darwin/build.sh +69 -0
  166. package/native/darwin/prebuilds/darwin-arm64/{darwin-modifiers.node → darwin-platform.node} +0 -0
  167. package/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  168. package/native/darwin/src/darwin-platform.m +132 -0
  169. package/native/linux/README.md +23 -0
  170. package/native/linux/build.sh +65 -0
  171. package/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  172. package/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  173. package/native/linux/src/clipboard-worker.h +70 -0
  174. package/native/linux/src/linux-platform-x11.c +274 -0
  175. package/native/napi.h +91 -0
  176. package/native/win32/README.md +31 -0
  177. package/native/win32/build.mjs +232 -0
  178. package/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  179. package/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  180. package/native/win32/src/win32-platform.c +241 -0
  181. package/package.json +19 -4
  182. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  183. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  184. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
package/dist/tui.js CHANGED
@@ -1,48 +1,61 @@
1
1
  /**
2
2
  * Minimal TUI implementation with differential rendering
3
3
  */
4
- import * as fs from "node:fs";
5
- import * as os from "node:os";
6
- import * as path from "node:path";
7
4
  import { performance } from "node:perf_hooks";
8
5
  import { isKeyRelease, matchesKey } from "./keys.js";
9
- import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
10
- import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
6
+ import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
7
+ import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
11
8
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
12
- const KITTY_SEQUENCE_PREFIX = "\x1b_G";
13
- function parseKittyImageHeader(line) {
14
- const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
15
- if (sequenceStart === -1)
16
- return undefined;
17
- const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
18
- const paramsEnd = line.indexOf(";", paramsStart);
19
- if (paramsEnd === -1)
9
+ /**
10
+ * Dispatch an event to a component and retain the exact target and coordinate
11
+ * transform. Containers use this when forwarding events to nested children.
12
+ */
13
+ export function dispatchMouseEvent(component, event) {
14
+ const result = component.handleMouse?.(event);
15
+ if (!result)
20
16
  return undefined;
21
- const ids = [];
22
- let rows = 1;
23
- const params = line.slice(paramsStart, paramsEnd);
24
- for (const param of params.split(",")) {
25
- const [key, value] = param.split("=", 2);
26
- if (value === undefined)
27
- continue;
28
- const numberValue = Number(value);
29
- if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff)
30
- continue;
31
- if (key === "i") {
32
- ids.push(numberValue);
33
- }
34
- else if (key === "r") {
35
- rows = numberValue;
36
- }
17
+ if ("target" in result) {
18
+ // The component forwarded the event to a child it hosts. Like a delegating container, it routes
19
+ // keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
20
+ // focus on a detached component once the host removes it, e.g. a closed settings submenu.
21
+ const forwarded = result;
22
+ return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
37
23
  }
38
- return { ids, rows };
39
- }
40
- function extractKittyImageIds(line) {
41
- return parseKittyImageHeader(line)?.ids ?? [];
24
+ if (!result.handled && !result.capture && !result.focus)
25
+ return undefined;
26
+ return {
27
+ ...result,
28
+ handled: true,
29
+ ...(result.focus ? { focusTarget: component } : {}),
30
+ target: {
31
+ component,
32
+ originX: event.screenX - event.x,
33
+ originY: event.screenY - event.y,
34
+ width: event.width,
35
+ height: event.height,
36
+ },
37
+ };
42
38
  }
43
- function extractKittyImageRows(line) {
44
- return parseKittyImageHeader(line)?.rows ?? 1;
39
+ /** Recreate local coordinates for a previously dispatched mouse target. */
40
+ export function retargetMouseEvent(event, target) {
41
+ return {
42
+ ...event,
43
+ x: event.screenX - target.originX,
44
+ y: event.screenY - target.originY,
45
+ width: target.width,
46
+ height: target.height,
47
+ };
45
48
  }
49
+ const TERMINAL_PALETTE_SIZE = 16;
50
+ /** OSC 10 and 11 plus OSC 4 for every palette color. */
51
+ const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
52
+ /**
53
+ * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
54
+ * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
55
+ * the color replies, including for terminals that ignore the color queries.
56
+ */
57
+ const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from({ length: TERMINAL_PALETTE_SIZE }, (_, index) => `\x1b]4;${index};?\x07`).join("")}\x1b[c`;
58
+ const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
46
59
  /** Type guard to check if a component implements Focusable */
47
60
  export function isFocusable(component) {
48
61
  return component !== null && "focused" in component;
@@ -68,14 +81,12 @@ function parseSizeValue(value, referenceSize) {
68
81
  }
69
82
  return undefined;
70
83
  }
71
- function isTermuxSession() {
72
- return Boolean(process.env.TERMUX_VERSION);
73
- }
74
84
  /**
75
85
  * Container - a component that contains other components
76
86
  */
77
87
  export class Container {
78
88
  children = [];
89
+ mouseLayout;
79
90
  addChild(component) {
80
91
  this.children.push(component);
81
92
  }
@@ -93,59 +104,118 @@ export class Container {
93
104
  child.invalidate?.();
94
105
  }
95
106
  }
107
+ handleMouse(event) {
108
+ if (event.y < 0 || event.y >= event.height)
109
+ return undefined;
110
+ const mouseChildren = this.mouseLayout?.width === event.width
111
+ ? this.mouseLayout.children
112
+ : this.children.map((component) => ({ component, height: component.render(event.width).length }));
113
+ let childY = 0;
114
+ for (const { component: child, height: childHeight } of mouseChildren) {
115
+ if (event.y >= childY && event.y < childY + childHeight) {
116
+ const result = dispatchMouseEvent(child, {
117
+ ...event,
118
+ y: event.y - childY,
119
+ height: childHeight,
120
+ });
121
+ if (result?.focus && this.handleInput)
122
+ return { ...result, focusTarget: this };
123
+ return result;
124
+ }
125
+ childY += childHeight;
126
+ }
127
+ return undefined;
128
+ }
96
129
  render(width) {
97
130
  const lines = [];
131
+ const mouseChildren = [];
98
132
  for (const child of this.children) {
99
133
  const childLines = child.render(width);
134
+ mouseChildren.push({ component: child, height: childLines.length });
100
135
  for (const line of childLines) {
101
136
  lines.push(line);
102
137
  }
103
138
  }
139
+ this.mouseLayout = { width, children: mouseChildren };
104
140
  return lines;
105
141
  }
106
142
  }
107
143
  /**
108
144
  * TUI - Main class for managing terminal UI with differential rendering
109
145
  */
110
- export class TUI extends Container {
146
+ const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
147
+ /** Composite overlay content into a terminal line at a fixed column. */
148
+ export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
149
+ if (isImageLine(baseLine))
150
+ return baseLine;
151
+ const afterStart = startCol + overlayWidth;
152
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
153
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
154
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
155
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
156
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
157
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
158
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
159
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
160
+ const result = base.before +
161
+ " ".repeat(beforePad) +
162
+ SEGMENT_RESET +
163
+ overlay.text +
164
+ " ".repeat(overlayPad) +
165
+ SEGMENT_RESET +
166
+ base.after +
167
+ " ".repeat(afterPad);
168
+ return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
169
+ }
170
+ export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
171
+ export function isViewportTUI(tui) {
172
+ return tui[VIEWPORT_TUI] === true;
173
+ }
174
+ export class TuiBase extends Container {
111
175
  terminal;
112
- previousLines = [];
113
- previousKittyImageIds = new Set();
114
- previousWidth = 0;
115
- previousHeight = 0;
116
176
  focusedComponent = null;
117
177
  inputListeners = new Set();
118
178
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
119
179
  onDebug;
120
180
  renderRequested = false;
181
+ immediateRenderScheduled = false;
121
182
  renderTimer;
122
183
  lastRenderAt = 0;
123
184
  static MIN_RENDER_INTERVAL_MS = 16;
124
- cursorRow = 0; // Logical cursor row (end of rendered content)
125
- hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
126
- showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
127
- clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
128
- maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
129
- previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
185
+ showHardwareCursor = false;
186
+ clearOnShrink = false;
130
187
  fullRedrawCount = 0;
131
188
  stopped = false;
132
- pendingOsc11BackgroundReplies = 0;
133
- pendingOsc11BackgroundQueries = [];
189
+ /**
190
+ * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
191
+ * replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
192
+ */
193
+ pendingTerminalColorQueries = [];
134
194
  terminalColorSchemeListeners = new Set();
135
195
  terminalColorSchemeNotificationsEnabled = false;
196
+ /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
136
197
  logDirectory;
137
198
  // Overlay stack for modal components rendered on top of base content
138
199
  focusOrderCounter = 0;
139
200
  overlayStack = [];
201
+ renderedOverlayLayouts = [];
202
+ get hasOverlayEntries() {
203
+ return this.overlayStack.length > 0;
204
+ }
140
205
  overlayFocusRestore = { status: "inactive" };
141
206
  constructor(terminal, showHardwareCursor, logDirectory) {
142
207
  super();
143
208
  this.terminal = terminal;
144
- this.logDirectory = logDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
209
+ this.logDirectory = logDirectory;
145
210
  if (showHardwareCursor !== undefined) {
146
211
  this.showHardwareCursor = showHardwareCursor;
147
212
  }
148
213
  }
214
+ resetRenderState() { }
215
+ beforeTerminalStart() { }
216
+ afterTerminalStart() { }
217
+ beforeTerminalStop(_options) { }
218
+ afterTerminalStop(_options) { }
149
219
  get fullRedraws() {
150
220
  return this.fullRedrawCount;
151
221
  }
@@ -157,7 +227,7 @@ export class TUI extends Container {
157
227
  return;
158
228
  this.showHardwareCursor = enabled;
159
229
  if (!enabled) {
160
- this.terminal.hideCursor();
230
+ this.hideTerminalCursor();
161
231
  }
162
232
  this.requestRender();
163
233
  }
@@ -166,12 +236,15 @@ export class TUI extends Container {
166
236
  }
167
237
  /**
168
238
  * Set whether to trigger full re-render when content shrinks.
169
- * When true (default), empty rows are cleared when content shrinks.
170
- * When false, empty rows remain (reduces redraws on slower terminals).
239
+ * When true, empty rows are cleared when content shrinks.
240
+ * When false (default), empty rows remain (reduces redraws on slower terminals).
171
241
  */
172
242
  setClearOnShrink(enabled) {
173
243
  this.clearOnShrink = enabled;
174
244
  }
245
+ getFocusedComponent() {
246
+ return this.focusedComponent;
247
+ }
175
248
  setFocus(component) {
176
249
  this.setFocusInternal({ component, overlayFocusRestore: "clear" });
177
250
  }
@@ -272,8 +345,11 @@ export class TUI extends Container {
272
345
  }
273
346
  }
274
347
  }
348
+ getMountedRoots() {
349
+ return this.children;
350
+ }
275
351
  isComponentMounted(component) {
276
- return this.children.some((child) => this.containsComponent(child, component));
352
+ return this.getMountedRoots().some((child) => this.containsComponent(child, component));
277
353
  }
278
354
  containsComponent(root, target) {
279
355
  if (root === target)
@@ -299,7 +375,7 @@ export class TUI extends Container {
299
375
  if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
300
376
  this.setFocus(component);
301
377
  }
302
- this.terminal.hideCursor();
378
+ this.hideTerminalCursor();
303
379
  this.requestRender();
304
380
  // Return handle for controlling this overlay
305
381
  return {
@@ -315,7 +391,7 @@ export class TUI extends Container {
315
391
  this.setFocus(topVisible?.component ?? entry.preFocus);
316
392
  }
317
393
  if (this.overlayStack.length === 0)
318
- this.terminal.hideCursor();
394
+ this.hideTerminalCursor();
319
395
  this.requestRender();
320
396
  }
321
397
  },
@@ -381,6 +457,11 @@ export class TUI extends Container {
381
457
  this.requestRender();
382
458
  },
383
459
  isFocused: () => this.focusedComponent === component,
460
+ getBounds: () => {
461
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds)
462
+ return undefined;
463
+ return { ...entry.bounds };
464
+ },
384
465
  };
385
466
  }
386
467
  /** Hide the topmost overlay and restore previous focus. */
@@ -397,13 +478,58 @@ export class TUI extends Container {
397
478
  this.setFocus(topVisible?.component ?? overlay.preFocus);
398
479
  }
399
480
  if (this.overlayStack.length === 0)
400
- this.terminal.hideCursor();
481
+ this.hideTerminalCursor();
401
482
  this.requestRender();
402
483
  }
484
+ /** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
485
+ hideTerminalCursor() {
486
+ if (!this.stopped)
487
+ this.terminal.hideCursor();
488
+ }
403
489
  /** Check if there are any visible overlays */
404
490
  hasOverlay() {
405
491
  return this.overlayStack.some((o) => this.isOverlayVisible(o));
406
492
  }
493
+ /** Check if the focused component is a visible overlay */
494
+ isOverlayFocused() {
495
+ return this.overlayStack.some((entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry));
496
+ }
497
+ /** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
498
+ resolveMouseFocusTarget(component) {
499
+ for (let index = this.overlayStack.length - 1; index >= 0; index--) {
500
+ const overlay = this.overlayStack[index];
501
+ if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
502
+ return overlay.component;
503
+ }
504
+ }
505
+ return component;
506
+ }
507
+ /** Dispatch to the visually topmost overlay under the pointer. */
508
+ dispatchMouseToOverlay(event) {
509
+ for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
510
+ const layout = this.renderedOverlayLayouts[index];
511
+ if (event.screenX < layout.col ||
512
+ event.screenX >= layout.col + layout.width ||
513
+ event.screenY < layout.row ||
514
+ event.screenY >= layout.row + layout.height) {
515
+ continue;
516
+ }
517
+ const result = dispatchMouseEvent(layout.entry.component, {
518
+ ...event,
519
+ x: event.screenX - layout.col,
520
+ y: event.screenY - layout.row,
521
+ width: layout.width,
522
+ height: layout.height,
523
+ });
524
+ return result
525
+ ? {
526
+ hit: true,
527
+ result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
528
+ }
529
+ : { hit: true };
530
+ }
531
+ return { hit: false };
532
+ }
407
533
  /** Check if an overlay entry is currently visible */
408
534
  isOverlayVisible(entry) {
409
535
  if (entry.hidden)
@@ -426,13 +552,16 @@ export class TUI extends Container {
426
552
  return topmost;
427
553
  }
428
554
  invalidate() {
429
- super.invalidate();
555
+ for (const root of this.getMountedRoots())
556
+ root.invalidate();
430
557
  for (const overlay of this.overlayStack)
431
- overlay.component.invalidate?.();
558
+ overlay.component.invalidate();
432
559
  }
433
560
  start() {
434
561
  this.stopped = false;
435
- this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
562
+ this.beforeTerminalStart();
563
+ this.terminal.start((data) => this.handleTerminalInput(data), () => this.requestRender());
564
+ this.afterTerminalStart();
436
565
  this.terminal.hideCursor();
437
566
  if (this.terminalColorSchemeNotificationsEnabled) {
438
567
  this.terminal.write("\x1b[?2031h");
@@ -473,54 +602,29 @@ export class TUI extends Container {
473
602
  // Response format: CSI 6 ; height ; width t
474
603
  this.terminal.write("\x1b[16t");
475
604
  }
476
- stop() {
605
+ stop(options = {}) {
477
606
  this.stopped = true;
478
- if (this.renderTimer) {
479
- clearTimeout(this.renderTimer);
480
- this.renderTimer = undefined;
481
- }
607
+ this.cancelRenderTimer();
482
608
  if (this.terminalColorSchemeNotificationsEnabled) {
483
609
  this.terminal.write("\x1b[?2031l");
484
610
  }
485
- // Move cursor to the end of the content to prevent overwriting/artifacts on exit
486
- if (this.previousLines.length > 0) {
487
- // Overwrite the inverted cursor with a normal space to clear the artifact
488
- this.terminal.write(" ");
489
- const targetRow = this.previousLines.length; // Line after the last content
490
- const lineDiff = targetRow - this.hardwareCursorRow;
491
- if (lineDiff > 0) {
492
- this.terminal.write(`\x1b[${lineDiff}B`);
493
- }
494
- else if (lineDiff < 0) {
495
- this.terminal.write(`\x1b[${-lineDiff}A`);
496
- }
497
- this.terminal.write("\r\n");
498
- }
611
+ this.beforeTerminalStop(options);
499
612
  this.terminal.showCursor();
500
613
  this.terminal.stop();
614
+ this.afterTerminalStop(options);
615
+ }
616
+ renderNow(force = false) {
617
+ if (force)
618
+ this.resetRenderState();
619
+ this.renderRequested = false;
620
+ this.cancelRenderTimer();
621
+ this.lastRenderAt = performance.now();
622
+ this.doRender();
501
623
  }
502
624
  requestRender(force = false) {
503
625
  if (force) {
504
- this.previousLines = [];
505
- this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
506
- this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
507
- this.cursorRow = 0;
508
- this.hardwareCursorRow = 0;
509
- this.maxLinesRendered = 0;
510
- this.previousViewportTop = 0;
511
- if (this.renderTimer) {
512
- clearTimeout(this.renderTimer);
513
- this.renderTimer = undefined;
514
- }
515
- this.renderRequested = true;
516
- process.nextTick(() => {
517
- if (this.stopped || !this.renderRequested) {
518
- return;
519
- }
520
- this.renderRequested = false;
521
- this.lastRenderAt = performance.now();
522
- this.doRender();
523
- });
626
+ this.resetRenderState();
627
+ this.requestImmediateRender();
524
628
  return;
525
629
  }
526
630
  if (this.renderRequested)
@@ -528,12 +632,36 @@ export class TUI extends Container {
528
632
  this.renderRequested = true;
529
633
  process.nextTick(() => this.scheduleRender());
530
634
  }
635
+ requestImmediateRender() {
636
+ this.cancelRenderTimer();
637
+ this.renderRequested = true;
638
+ if (this.immediateRenderScheduled)
639
+ return;
640
+ this.immediateRenderScheduled = true;
641
+ process.nextTick(() => {
642
+ this.immediateRenderScheduled = false;
643
+ if (this.stopped || !this.renderRequested)
644
+ return;
645
+ // A previously queued scheduleRender() can create a timer before this
646
+ // callback runs. User input must preempt that throttled frame.
647
+ this.cancelRenderTimer();
648
+ this.renderRequested = false;
649
+ this.lastRenderAt = performance.now();
650
+ this.doRender();
651
+ });
652
+ }
653
+ cancelRenderTimer() {
654
+ if (!this.renderTimer)
655
+ return;
656
+ clearTimeout(this.renderTimer);
657
+ this.renderTimer = undefined;
658
+ }
531
659
  scheduleRender() {
532
660
  if (this.stopped || this.renderTimer || !this.renderRequested) {
533
661
  return;
534
662
  }
535
663
  const elapsed = performance.now() - this.lastRenderAt;
536
- const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
664
+ const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
537
665
  this.renderTimer = setTimeout(() => {
538
666
  this.renderTimer = undefined;
539
667
  if (this.stopped || !this.renderRequested) {
@@ -547,8 +675,8 @@ export class TUI extends Container {
547
675
  }
548
676
  }, delay);
549
677
  }
550
- handleInput(data) {
551
- if (this.consumeOsc11BackgroundResponse(data)) {
678
+ handleTerminalInput(data) {
679
+ if (this.consumeTerminalColorResponse(data)) {
552
680
  return;
553
681
  }
554
682
  if (this.consumeTerminalColorSchemeReport(data)) {
@@ -616,30 +744,55 @@ export class TUI extends Container {
616
744
  return;
617
745
  }
618
746
  this.focusedComponent.handleInput(data);
619
- this.requestRender();
747
+ // Keyboard input is latency-sensitive. Avoid the throttled timer path,
748
+ // where even setTimeout(0) can take a full 16 ms tick on Windows.
749
+ this.requestImmediateRender();
620
750
  }
621
751
  }
622
- consumeOsc11BackgroundResponse(data) {
623
- if (this.pendingOsc11BackgroundReplies <= 0) {
752
+ consumeTerminalColorResponse(data) {
753
+ const query = this.pendingTerminalColorQueries[0];
754
+ if (!query) {
624
755
  return false;
625
756
  }
626
- if (!isOsc11BackgroundColorResponse(data)) {
757
+ if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
758
+ this.pendingTerminalColorQueries.shift();
759
+ this.completeTerminalColorQuery(query);
760
+ return true;
761
+ }
762
+ const response = parseOscColorResponse(data);
763
+ if (!response) {
627
764
  return false;
628
765
  }
629
- const rgb = parseOsc11BackgroundColor(data);
630
- this.pendingOsc11BackgroundReplies -= 1;
631
- const query = this.pendingOsc11BackgroundQueries.shift();
632
- if (query && !query.settled) {
633
- query.settled = true;
634
- if (query.timer) {
635
- clearTimeout(query.timer);
636
- query.timer = undefined;
637
- }
638
- query.resolve?.(rgb);
639
- query.resolve = undefined;
766
+ const { target, rgb } = response;
767
+ const key = String(target);
768
+ if (!query.deliver || query.replied.has(key)) {
769
+ return true;
770
+ }
771
+ query.replied.add(key);
772
+ if (target === "foreground") {
773
+ query.foreground = rgb;
774
+ }
775
+ else if (target === "background") {
776
+ query.background = rgb;
777
+ }
778
+ else if (target < TERMINAL_PALETTE_SIZE) {
779
+ query.palette[target] = rgb;
780
+ }
781
+ if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
782
+ this.completeTerminalColorQuery(query);
640
783
  }
641
784
  return true;
642
785
  }
786
+ terminalColorQueryResult(query) {
787
+ const palette = query.palette.every((color) => color !== undefined) ? query.palette : undefined;
788
+ return { foreground: query.foreground, background: query.background, palette };
789
+ }
790
+ completeTerminalColorQuery(query) {
791
+ const deliver = query.deliver;
792
+ query.deliver = undefined;
793
+ clearTimeout(query.timer);
794
+ deliver?.(this.terminalColorQueryResult(query));
795
+ }
643
796
  consumeTerminalColorSchemeReport(data) {
644
797
  const scheme = parseTerminalColorSchemeReport(data);
645
798
  if (!scheme) {
@@ -795,9 +948,13 @@ export class TUI extends Container {
795
948
  }
796
949
  /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
797
950
  compositeOverlays(lines, termWidth, termHeight) {
798
- if (this.overlayStack.length === 0)
951
+ if (this.overlayStack.length === 0) {
952
+ this.renderedOverlayLayouts = [];
799
953
  return lines;
954
+ }
800
955
  const result = [...lines];
956
+ for (const entry of this.overlayStack)
957
+ entry.bounds = undefined;
801
958
  // Pre-render all visible overlays and calculate positions
802
959
  const rendered = [];
803
960
  let minLinesNeeded = result.length;
@@ -816,9 +973,17 @@ export class TUI extends Container {
816
973
  }
817
974
  // Get final row/col with actual overlay height
818
975
  const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
819
- rendered.push({ overlayLines, row, col, w: width });
976
+ entry.bounds = { row, col, width, height: overlayLines.length };
977
+ rendered.push({ entry, overlayLines, row, col, w: width });
820
978
  minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
821
979
  }
980
+ this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
981
+ entry,
982
+ row,
983
+ col,
984
+ width: w,
985
+ height: overlayLines.length,
986
+ }));
822
987
  // Pad to at least terminal height so overlays have screen-relative positions.
823
988
  // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
824
989
  // inflation that pushed content into scrollback on terminal widen.
@@ -842,9 +1007,8 @@ export class TUI extends Container {
842
1007
  }
843
1008
  return result;
844
1009
  }
845
- static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
846
1010
  applyLineResets(lines) {
847
- const reset = TUI.SEGMENT_RESET;
1011
+ const reset = SEGMENT_RESET;
848
1012
  for (let i = 0; i < lines.length; i++) {
849
1013
  const line = lines[i];
850
1014
  if (!isImageLine(line)) {
@@ -853,104 +1017,8 @@ export class TUI extends Container {
853
1017
  }
854
1018
  return lines;
855
1019
  }
856
- collectKittyImageIds(lines) {
857
- const ids = new Set();
858
- for (const line of lines) {
859
- for (const id of extractKittyImageIds(line)) {
860
- ids.add(id);
861
- }
862
- }
863
- return ids;
864
- }
865
- deleteKittyImages(ids) {
866
- let buffer = "";
867
- for (const id of ids) {
868
- buffer += deleteKittyImage(id);
869
- }
870
- return buffer;
871
- }
872
- getKittyImageReservedRows(lines, index, maxIndex = lines.length - 1) {
873
- const rows = extractKittyImageRows(lines[index] ?? "");
874
- if (rows <= 1)
875
- return 1;
876
- const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
877
- let reservedRows = 1;
878
- while (reservedRows < maxRows) {
879
- const line = lines[index + reservedRows] ?? "";
880
- if (isImageLine(line) || visibleWidth(line) > 0)
881
- break;
882
- reservedRows++;
883
- }
884
- return reservedRows;
885
- }
886
- expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines) {
887
- let expandedFirstChanged = firstChanged;
888
- let expandedLastChanged = lastChanged;
889
- const expandForLines = (lines) => {
890
- for (let i = 0; i < lines.length; i++) {
891
- if (extractKittyImageIds(lines[i]).length === 0)
892
- continue;
893
- const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
894
- if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
895
- expandedFirstChanged = Math.min(expandedFirstChanged, i);
896
- expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
897
- }
898
- }
899
- };
900
- expandForLines(this.previousLines);
901
- expandForLines(newLines);
902
- return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
903
- }
904
- deleteChangedKittyImages(firstChanged, lastChanged) {
905
- if (firstChanged < 0 || lastChanged < firstChanged)
906
- return "";
907
- const ids = new Set();
908
- const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
909
- for (let i = firstChanged; i <= maxLine; i++) {
910
- for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
911
- ids.add(id);
912
- }
913
- }
914
- return this.deleteKittyImages(ids);
915
- }
916
- /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
917
1020
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
918
- if (isImageLine(baseLine))
919
- return baseLine;
920
- // Single pass through baseLine extracts both before and after segments
921
- const afterStart = startCol + overlayWidth;
922
- const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
923
- // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
924
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
925
- // Pad segments to target widths
926
- const beforePad = Math.max(0, startCol - base.beforeWidth);
927
- const overlayPad = Math.max(0, overlayWidth - overlay.width);
928
- const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
929
- const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
930
- const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
931
- const afterPad = Math.max(0, afterTarget - base.afterWidth);
932
- // Compose result
933
- const r = TUI.SEGMENT_RESET;
934
- const result = base.before +
935
- " ".repeat(beforePad) +
936
- r +
937
- overlay.text +
938
- " ".repeat(overlayPad) +
939
- r +
940
- base.after +
941
- " ".repeat(afterPad);
942
- // CRITICAL: Always verify and truncate to terminal width.
943
- // This is the final safeguard against width overflow which would crash the TUI.
944
- // Width tracking can drift from actual visible width due to:
945
- // - Complex ANSI/OSC sequences (hyperlinks, colors)
946
- // - Wide characters at segment boundaries
947
- // - Edge cases in segment extraction
948
- const resultWidth = visibleWidth(result);
949
- if (resultWidth <= totalWidth) {
950
- return result;
951
- }
952
- // Truncate with strict=true to ensure we don't exceed totalWidth
953
- return sliceByColumn(result, 0, totalWidth, true);
1021
+ return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
954
1022
  }
955
1023
  /**
956
1024
  * Find and extract cursor position from rendered lines.
@@ -977,435 +1045,29 @@ export class TUI extends Container {
977
1045
  }
978
1046
  return null;
979
1047
  }
980
- doRender() {
981
- if (this.stopped)
982
- return;
983
- const width = this.terminal.columns;
984
- const height = this.terminal.rows;
985
- const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
986
- const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
987
- const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
988
- let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
989
- let viewportTop = prevViewportTop;
990
- let hardwareCursorRow = this.hardwareCursorRow;
991
- const computeLineDiff = (targetRow) => {
992
- const currentScreenRow = hardwareCursorRow - prevViewportTop;
993
- const targetScreenRow = targetRow - viewportTop;
994
- return targetScreenRow - currentScreenRow;
995
- };
996
- // Render all components to get new lines
997
- let newLines = this.render(width);
998
- // Composite overlays into the rendered lines (before differential compare)
999
- if (this.overlayStack.length > 0) {
1000
- newLines = this.compositeOverlays(newLines, width, height);
1001
- }
1002
- // Extract cursor position before applying line resets (marker must be found first)
1003
- const cursorPos = this.extractCursorPosition(newLines, height);
1004
- newLines = this.applyLineResets(newLines);
1005
- // Helper to clear scrollback and viewport and render all new lines
1006
- const fullRender = (clear) => {
1007
- this.fullRedrawCount += 1;
1008
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1009
- if (clear) {
1010
- buffer += this.deleteKittyImages(this.previousKittyImageIds);
1011
- buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1012
- }
1013
- for (let i = 0; i < newLines.length; i++) {
1014
- if (i > 0)
1015
- buffer += "\r\n";
1016
- const line = newLines[i];
1017
- const isImage = isImageLine(line);
1018
- const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
1019
- if (imageReservedRows > 1 && imageReservedRows <= height) {
1020
- for (let row = 1; row < imageReservedRows; row++) {
1021
- buffer += "\r\n";
1022
- }
1023
- buffer += `\x1b[${imageReservedRows - 1}A`;
1024
- buffer += line;
1025
- buffer += `\x1b[${imageReservedRows - 1}B`;
1026
- i += imageReservedRows - 1;
1027
- continue;
1028
- }
1029
- buffer += line;
1030
- }
1031
- buffer += "\x1b[?2026l"; // End synchronized output
1032
- this.terminal.write(buffer);
1033
- this.cursorRow = Math.max(0, newLines.length - 1);
1034
- this.hardwareCursorRow = this.cursorRow;
1035
- // Reset max lines when clearing, otherwise track growth
1036
- if (clear) {
1037
- this.maxLinesRendered = newLines.length;
1038
- }
1039
- else {
1040
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1041
- }
1042
- const bufferLength = Math.max(height, newLines.length);
1043
- this.previousViewportTop = Math.max(0, bufferLength - height);
1044
- this.positionHardwareCursor(cursorPos, newLines.length);
1045
- this.previousLines = newLines;
1046
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1047
- this.previousWidth = width;
1048
- this.previousHeight = height;
1049
- };
1050
- const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
1051
- const logRedraw = (reason) => {
1052
- if (!debugRedraw)
1053
- return;
1054
- const logPath = path.join(this.logDirectory, "pi-debug.log");
1055
- const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
1056
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
1057
- fs.appendFileSync(logPath, msg);
1058
- };
1059
- // First render - just output everything without clearing (assumes clean screen)
1060
- if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
1061
- logRedraw("first render");
1062
- fullRender(false);
1063
- return;
1064
- }
1065
- // Width changes always need a full re-render because wrapping changes.
1066
- if (widthChanged) {
1067
- logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
1068
- fullRender(true);
1069
- return;
1070
- }
1071
- // Height changes normally need a full re-render to keep the visible viewport aligned,
1072
- // but Termux changes height when the software keyboard shows or hides.
1073
- // In that environment, a full redraw causes the entire history to replay on every toggle.
1074
- if (heightChanged && !isTermuxSession()) {
1075
- logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
1076
- fullRender(true);
1077
- return;
1078
- }
1079
- // Content shrunk below the working area and no overlays - re-render to clear empty rows
1080
- // (overlays need the padding, so only do this when no overlays are active)
1081
- // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1082
- if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
1083
- logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1084
- fullRender(true);
1085
- return;
1086
- }
1087
- // Find first and last changed lines
1088
- let firstChanged = -1;
1089
- let lastChanged = -1;
1090
- const maxLines = Math.max(newLines.length, this.previousLines.length);
1091
- for (let i = 0; i < maxLines; i++) {
1092
- const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
1093
- const newLine = i < newLines.length ? newLines[i] : "";
1094
- if (oldLine !== newLine) {
1095
- if (firstChanged === -1) {
1096
- firstChanged = i;
1097
- }
1098
- lastChanged = i;
1099
- }
1100
- }
1101
- const appendedLines = newLines.length > this.previousLines.length;
1102
- if (appendedLines) {
1103
- if (firstChanged === -1) {
1104
- firstChanged = this.previousLines.length;
1105
- }
1106
- lastChanged = newLines.length - 1;
1107
- }
1108
- if (firstChanged !== -1) {
1109
- const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
1110
- firstChanged = expandedRange.firstChanged;
1111
- lastChanged = expandedRange.lastChanged;
1112
- }
1113
- const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
1114
- // No changes - but still need to update hardware cursor position if it moved
1115
- if (firstChanged === -1) {
1116
- this.positionHardwareCursor(cursorPos, newLines.length);
1117
- this.previousViewportTop = prevViewportTop;
1118
- this.previousHeight = height;
1119
- return;
1120
- }
1121
- // All changes are in deleted lines (nothing to render, just clear)
1122
- if (firstChanged >= newLines.length) {
1123
- if (this.previousLines.length > newLines.length) {
1124
- let buffer = "\x1b[?2026h";
1125
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1126
- // Move to end of new content (clamp to 0 for empty content)
1127
- const targetRow = Math.max(0, newLines.length - 1);
1128
- if (targetRow < prevViewportTop) {
1129
- logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
1130
- fullRender(true);
1131
- return;
1132
- }
1133
- const lineDiff = computeLineDiff(targetRow);
1134
- if (lineDiff > 0)
1135
- buffer += `\x1b[${lineDiff}B`;
1136
- else if (lineDiff < 0)
1137
- buffer += `\x1b[${-lineDiff}A`;
1138
- buffer += "\r";
1139
- // Clear extra lines without scrolling
1140
- const extraLines = this.previousLines.length - newLines.length;
1141
- if (extraLines > height) {
1142
- logRedraw(`extraLines > height (${extraLines} > ${height})`);
1143
- fullRender(true);
1144
- return;
1145
- }
1146
- const clearStartOffset = newLines.length === 0 ? 0 : 1;
1147
- if (extraLines > 0 && clearStartOffset > 0) {
1148
- buffer += `\x1b[${clearStartOffset}B`;
1149
- }
1150
- for (let i = 0; i < extraLines; i++) {
1151
- buffer += "\r\x1b[2K";
1152
- if (i < extraLines - 1)
1153
- buffer += "\x1b[1B";
1154
- }
1155
- const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
1156
- if (moveBack > 0) {
1157
- buffer += `\x1b[${moveBack}A`;
1158
- }
1159
- buffer += "\x1b[?2026l";
1160
- this.terminal.write(buffer);
1161
- this.cursorRow = targetRow;
1162
- this.hardwareCursorRow = targetRow;
1163
- }
1164
- this.positionHardwareCursor(cursorPos, newLines.length);
1165
- this.previousLines = newLines;
1166
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1167
- this.previousWidth = width;
1168
- this.previousHeight = height;
1169
- this.previousViewportTop = prevViewportTop;
1170
- return;
1171
- }
1172
- // Differential rendering can only touch what was actually visible.
1173
- // If the first changed line is above the previous viewport, we need a full redraw.
1174
- if (firstChanged < prevViewportTop) {
1175
- logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1176
- fullRender(true);
1177
- return;
1178
- }
1179
- // Render from first changed line to end
1180
- // Build buffer with all updates wrapped in synchronized output
1181
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1182
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1183
- const prevViewportBottom = prevViewportTop + height - 1;
1184
- const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1185
- if (moveTargetRow > prevViewportBottom) {
1186
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1187
- const moveToBottom = height - 1 - currentScreenRow;
1188
- if (moveToBottom > 0) {
1189
- buffer += `\x1b[${moveToBottom}B`;
1190
- }
1191
- const scroll = moveTargetRow - prevViewportBottom;
1192
- buffer += "\r\n".repeat(scroll);
1193
- prevViewportTop += scroll;
1194
- viewportTop += scroll;
1195
- hardwareCursorRow = moveTargetRow;
1196
- }
1197
- // Move cursor to first changed line (use hardwareCursorRow for actual position)
1198
- const lineDiff = computeLineDiff(moveTargetRow);
1199
- if (lineDiff > 0) {
1200
- buffer += `\x1b[${lineDiff}B`; // Move down
1201
- }
1202
- else if (lineDiff < 0) {
1203
- buffer += `\x1b[${-lineDiff}A`; // Move up
1204
- }
1205
- buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1206
- // Only render changed lines (firstChanged to lastChanged), not all lines to end
1207
- // This reduces flicker when only a single line changes (e.g., spinner animation)
1208
- const renderEnd = Math.min(lastChanged, newLines.length - 1);
1209
- for (let i = firstChanged; i <= renderEnd; i++) {
1210
- if (i > firstChanged)
1211
- buffer += "\r\n";
1212
- const line = newLines[i];
1213
- const isImage = isImageLine(line);
1214
- const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
1215
- if (imageReservedRows > 1) {
1216
- const imageStartScreenRow = i - viewportTop;
1217
- if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
1218
- logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
1219
- fullRender(true);
1220
- return;
1221
- }
1222
- buffer += "\x1b[2K";
1223
- for (let row = 1; row < imageReservedRows; row++) {
1224
- buffer += "\r\n\x1b[2K";
1225
- }
1226
- buffer += `\x1b[${imageReservedRows - 1}A`;
1227
- buffer += line;
1228
- buffer += `\x1b[${imageReservedRows - 1}B`;
1229
- i += imageReservedRows - 1;
1230
- continue;
1231
- }
1232
- buffer += "\x1b[2K"; // Clear current line
1233
- if (!isImage && visibleWidth(line) > width) {
1234
- // Log all lines to crash file for debugging
1235
- const crashLogPath = path.join(this.logDirectory, "pi-crash.log");
1236
- const crashData = [
1237
- `Crash at ${new Date().toISOString()}`,
1238
- `Terminal width: ${width}`,
1239
- `Line ${i} visible width: ${visibleWidth(line)}`,
1240
- "",
1241
- "=== All rendered lines ===",
1242
- ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1243
- "",
1244
- ].join("\n");
1245
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1246
- fs.writeFileSync(crashLogPath, crashData);
1247
- // Clean up terminal state before throwing
1248
- this.stop();
1249
- const errorMsg = [
1250
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1251
- "",
1252
- "This is likely caused by a custom TUI component not truncating its output.",
1253
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1254
- "",
1255
- `Debug log written to: ${crashLogPath}`,
1256
- ].join("\n");
1257
- throw new Error(errorMsg);
1258
- }
1259
- buffer += line;
1260
- }
1261
- // Track where cursor ended up after rendering
1262
- let finalCursorRow = renderEnd;
1263
- // If we had more lines before, clear them and move cursor back
1264
- if (this.previousLines.length > newLines.length) {
1265
- // Move to end of new content first if we stopped before it
1266
- if (renderEnd < newLines.length - 1) {
1267
- const moveDown = newLines.length - 1 - renderEnd;
1268
- buffer += `\x1b[${moveDown}B`;
1269
- finalCursorRow = newLines.length - 1;
1270
- }
1271
- const extraLines = this.previousLines.length - newLines.length;
1272
- for (let i = newLines.length; i < this.previousLines.length; i++) {
1273
- buffer += "\r\n\x1b[2K";
1274
- }
1275
- // Move cursor back to end of new content
1276
- buffer += `\x1b[${extraLines}A`;
1277
- }
1278
- buffer += "\x1b[?2026l"; // End synchronized output
1279
- if (process.env.PI_TUI_DEBUG === "1") {
1280
- const debugDir = "/tmp/tui";
1281
- fs.mkdirSync(debugDir, { recursive: true });
1282
- const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1283
- const debugData = [
1284
- `firstChanged: ${firstChanged}`,
1285
- `viewportTop: ${viewportTop}`,
1286
- `cursorRow: ${this.cursorRow}`,
1287
- `height: ${height}`,
1288
- `lineDiff: ${lineDiff}`,
1289
- `hardwareCursorRow: ${hardwareCursorRow}`,
1290
- `renderEnd: ${renderEnd}`,
1291
- `finalCursorRow: ${finalCursorRow}`,
1292
- `cursorPos: ${JSON.stringify(cursorPos)}`,
1293
- `newLines.length: ${newLines.length}`,
1294
- `previousLines.length: ${this.previousLines.length}`,
1295
- "",
1296
- "=== newLines ===",
1297
- JSON.stringify(newLines, null, 2),
1298
- "",
1299
- "=== previousLines ===",
1300
- JSON.stringify(this.previousLines, null, 2),
1301
- "",
1302
- "=== buffer ===",
1303
- JSON.stringify(buffer),
1304
- ].join("\n");
1305
- fs.writeFileSync(debugPath, debugData);
1306
- }
1307
- // Write entire buffer at once
1308
- this.terminal.write(buffer);
1309
- // Track cursor position for next render
1310
- // cursorRow tracks end of content (for viewport calculation)
1311
- // hardwareCursorRow tracks actual terminal cursor position (for movement)
1312
- this.cursorRow = Math.max(0, newLines.length - 1);
1313
- this.hardwareCursorRow = finalCursorRow;
1314
- // Track terminal's working area (grows but doesn't shrink unless cleared)
1315
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1316
- this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1317
- // Position hardware cursor for IME
1318
- this.positionHardwareCursor(cursorPos, newLines.length);
1319
- this.previousLines = newLines;
1320
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1321
- this.previousWidth = width;
1322
- this.previousHeight = height;
1323
- }
1324
1048
  /**
1325
- * Position the hardware cursor for IME candidate window.
1326
- * @param cursorPos The cursor position extracted from rendered output, or null
1327
- * @param totalLines Total number of rendered lines
1049
+ * Query the terminal's theme colors: the default foreground (OSC 10), the default background
1050
+ * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
1051
+ * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
1052
+ * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
1053
+ * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
1054
+ * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
1328
1055
  */
1329
- positionHardwareCursor(cursorPos, totalLines) {
1330
- if (!cursorPos || totalLines <= 0) {
1331
- this.terminal.hideCursor();
1332
- return;
1333
- }
1334
- // Clamp cursor position to valid range
1335
- const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1336
- const targetCol = Math.max(0, cursorPos.col);
1337
- // Move cursor from current position to target
1338
- const rowDelta = targetRow - this.hardwareCursorRow;
1339
- let buffer = "";
1340
- if (rowDelta > 0) {
1341
- buffer += `\x1b[${rowDelta}B`; // Move down
1342
- }
1343
- else if (rowDelta < 0) {
1344
- buffer += `\x1b[${-rowDelta}A`; // Move up
1345
- }
1346
- // Move to absolute column (1-indexed)
1347
- buffer += `\x1b[${targetCol + 1}G`;
1348
- if (buffer) {
1349
- this.terminal.write(buffer);
1350
- }
1351
- this.hardwareCursorRow = targetRow;
1352
- if (this.showHardwareCursor) {
1353
- this.terminal.showCursor();
1354
- }
1355
- else {
1356
- this.terminal.hideCursor();
1357
- }
1358
- }
1359
- /**
1360
- * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
1361
- * @param timeoutMs Query timeout in milliseconds.
1362
- * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
1363
- */
1364
- queryTerminalBackgroundColor({ timeoutMs }) {
1056
+ queryTerminalColors({ timeoutMs, onLateReply, }) {
1365
1057
  return new Promise((resolve) => {
1366
1058
  const query = {
1367
- settled: false,
1368
- resolve,
1059
+ palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
1060
+ replied: new Set(),
1061
+ deliver: resolve,
1369
1062
  timer: undefined,
1370
1063
  };
1064
+ // Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
1371
1065
  query.timer = setTimeout(() => {
1372
- if (query.settled) {
1373
- return;
1374
- }
1375
- query.settled = true;
1376
- query.timer = undefined;
1377
- query.resolve?.(undefined);
1378
- query.resolve = undefined;
1066
+ query.deliver = onLateReply;
1067
+ resolve(this.terminalColorQueryResult(query));
1379
1068
  }, timeoutMs);
1380
- this.pendingOsc11BackgroundQueries.push(query);
1381
- this.pendingOsc11BackgroundReplies += 1;
1382
- this.terminal.write("\x1b]11;?\x07");
1383
- });
1384
- }
1385
- /**
1386
- * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
1387
- * Terminals that support the color palette notification protocol reply with
1388
- * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
1389
- */
1390
- queryTerminalColorScheme({ timeoutMs }) {
1391
- return new Promise((resolve) => {
1392
- let settled = false;
1393
- let timer;
1394
- let unsubscribe = () => { };
1395
- const settle = (scheme) => {
1396
- if (settled)
1397
- return;
1398
- settled = true;
1399
- if (timer) {
1400
- clearTimeout(timer);
1401
- timer = undefined;
1402
- }
1403
- unsubscribe();
1404
- resolve(scheme);
1405
- };
1406
- unsubscribe = this.onTerminalColorSchemeChange(settle);
1407
- timer = setTimeout(() => settle(undefined), timeoutMs);
1408
- this.terminal.write("\x1b[?996n");
1069
+ this.pendingTerminalColorQueries.push(query);
1070
+ this.terminal.write(TERMINAL_COLOR_QUERY);
1409
1071
  });
1410
1072
  }
1411
1073
  }