@draht/tui 2026.5.12 → 2026.7.7-1

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 (97) hide show
  1. package/dist/autocomplete.d.ts +5 -1
  2. package/dist/autocomplete.d.ts.map +1 -1
  3. package/dist/autocomplete.js +16 -7
  4. package/dist/autocomplete.js.map +1 -1
  5. package/dist/components/box.d.ts +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js.map +1 -1
  8. package/dist/components/cancellable-loader.d.ts +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js.map +1 -1
  11. package/dist/components/editor.d.ts +14 -3
  12. package/dist/components/editor.d.ts.map +1 -1
  13. package/dist/components/editor.js +234 -175
  14. package/dist/components/editor.js.map +1 -1
  15. package/dist/components/image.d.ts +2 -2
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +31 -11
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts +1 -1
  20. package/dist/components/input.d.ts.map +1 -1
  21. package/dist/components/input.js +7 -55
  22. package/dist/components/input.js.map +1 -1
  23. package/dist/components/loader.d.ts +17 -7
  24. package/dist/components/loader.d.ts.map +1 -1
  25. package/dist/components/loader.js +34 -14
  26. package/dist/components/loader.js.map +1 -1
  27. package/dist/components/markdown.d.ts +11 -7
  28. package/dist/components/markdown.d.ts.map +1 -1
  29. package/dist/components/markdown.js +119 -102
  30. package/dist/components/markdown.js.map +1 -1
  31. package/dist/components/select-list.d.ts +1 -1
  32. package/dist/components/select-list.d.ts.map +1 -1
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts +1 -1
  35. package/dist/components/settings-list.d.ts.map +1 -1
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts +1 -1
  38. package/dist/components/spacer.d.ts.map +1 -1
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts +1 -1
  41. package/dist/components/text.d.ts.map +1 -1
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts +1 -1
  44. package/dist/components/truncated-text.d.ts.map +1 -1
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts +2 -2
  47. package/dist/editor-component.d.ts.map +1 -1
  48. package/dist/editor-component.js.map +1 -1
  49. package/dist/fuzzy.d.ts +1 -1
  50. package/dist/fuzzy.d.ts.map +1 -1
  51. package/dist/fuzzy.js +5 -2
  52. package/dist/fuzzy.js.map +1 -1
  53. package/dist/index.d.ts +23 -22
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +4 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/keybindings.d.ts +2 -2
  58. package/dist/keybindings.d.ts.map +1 -1
  59. package/dist/keybindings.js +1 -1
  60. package/dist/keybindings.js.map +1 -1
  61. package/dist/keys.d.ts +19 -5
  62. package/dist/keys.d.ts.map +1 -1
  63. package/dist/keys.js +82 -33
  64. package/dist/keys.js.map +1 -1
  65. package/dist/native-modifiers.d.ts +3 -0
  66. package/dist/native-modifiers.d.ts.map +1 -0
  67. package/dist/native-modifiers.js +53 -0
  68. package/dist/native-modifiers.js.map +1 -0
  69. package/dist/stdin-buffer.d.ts +2 -0
  70. package/dist/stdin-buffer.d.ts.map +1 -1
  71. package/dist/stdin-buffer.js +48 -4
  72. package/dist/stdin-buffer.js.map +1 -1
  73. package/dist/terminal-colors.d.ts +10 -0
  74. package/dist/terminal-colors.d.ts.map +1 -0
  75. package/dist/terminal-colors.js +59 -0
  76. package/dist/terminal-colors.js.map +1 -0
  77. package/dist/terminal-image.d.ts +23 -1
  78. package/dist/terminal-image.d.ts.map +1 -1
  79. package/dist/terminal-image.js +98 -16
  80. package/dist/terminal-image.js.map +1 -1
  81. package/dist/terminal.d.ts +36 -10
  82. package/dist/terminal.d.ts.map +1 -1
  83. package/dist/terminal.js +212 -64
  84. package/dist/terminal.js.map +1 -1
  85. package/dist/tui.d.ts +51 -5
  86. package/dist/tui.d.ts.map +1 -1
  87. package/dist/tui.js +431 -32
  88. package/dist/tui.js.map +1 -1
  89. package/dist/utils.d.ts +8 -1
  90. package/dist/utils.d.ts.map +1 -1
  91. package/dist/utils.js +143 -43
  92. package/dist/utils.js.map +1 -1
  93. package/dist/word-navigation.d.ts +25 -0
  94. package/dist/word-navigation.d.ts.map +1 -0
  95. package/dist/word-navigation.js +96 -0
  96. package/dist/word-navigation.js.map +1 -0
  97. package/package.json +1 -1
package/dist/tui.js CHANGED
@@ -6,8 +6,43 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { isKeyRelease, matchesKey } from "./keys.js";
9
- import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
10
- import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
9
+ import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
10
+ import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
11
+ 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)
20
+ 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
+ }
37
+ }
38
+ return { ids, rows };
39
+ }
40
+ function extractKittyImageIds(line) {
41
+ return parseKittyImageHeader(line)?.ids ?? [];
42
+ }
43
+ function extractKittyImageRows(line) {
44
+ return parseKittyImageHeader(line)?.rows ?? 1;
45
+ }
11
46
  /** Type guard to check if a component implements Focusable */
12
47
  export function isFocusable(component) {
13
48
  return component !== null && "focused" in component;
@@ -75,6 +110,7 @@ export class Container {
75
110
  export class TUI extends Container {
76
111
  terminal;
77
112
  previousLines = [];
113
+ previousKittyImageIds = new Set();
78
114
  previousWidth = 0;
79
115
  previousHeight = 0;
80
116
  focusedComponent = null;
@@ -93,9 +129,14 @@ export class TUI extends Container {
93
129
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
94
130
  fullRedrawCount = 0;
95
131
  stopped = false;
132
+ pendingOsc11BackgroundReplies = 0;
133
+ pendingOsc11BackgroundQueries = [];
134
+ terminalColorSchemeListeners = new Set();
135
+ terminalColorSchemeNotificationsEnabled = false;
96
136
  // Overlay stack for modal components rendered on top of base content
97
137
  focusOrderCounter = 0;
98
138
  overlayStack = [];
139
+ overlayFocusRestore = { status: "inactive" };
99
140
  constructor(terminal, showHardwareCursor) {
100
141
  super();
101
142
  this.terminal = terminal;
@@ -130,16 +171,115 @@ export class TUI extends Container {
130
171
  this.clearOnShrink = enabled;
131
172
  }
132
173
  setFocus(component) {
133
- // Clear focused flag on old component
174
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
175
+ }
176
+ setFocusInternal({ component, overlayFocusRestore, }) {
177
+ const previousFocus = this.focusedComponent;
178
+ let nextFocus = component;
179
+ const previousFocusedOverlay = previousFocus
180
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
181
+ : undefined;
182
+ const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
183
+ const restoreState = this.getVisibleOverlayFocusRestore();
184
+ if (nextFocus && !nextFocusIsOverlay) {
185
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
186
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
187
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
188
+ }
189
+ else {
190
+ this.overlayFocusRestore = {
191
+ status: "blocked",
192
+ overlay: restoreState.overlay,
193
+ blockedBy: nextFocus,
194
+ resume: restoreState.resume,
195
+ };
196
+ }
197
+ }
198
+ else if (previousFocusedOverlay &&
199
+ restoreState.status !== "inactive" &&
200
+ restoreState.overlay === previousFocusedOverlay &&
201
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
202
+ this.overlayFocusRestore = {
203
+ status: "blocked",
204
+ overlay: previousFocusedOverlay,
205
+ blockedBy: nextFocus,
206
+ resume: { status: "restore-overlay" },
207
+ };
208
+ }
209
+ }
210
+ else if (nextFocus === null) {
211
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
212
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
213
+ }
214
+ else if (overlayFocusRestore === "clear") {
215
+ this.clearOverlayFocusRestore();
216
+ }
217
+ }
134
218
  if (isFocusable(this.focusedComponent)) {
135
219
  this.focusedComponent.focused = false;
136
220
  }
137
- this.focusedComponent = component;
138
- // Set focused flag on new component
139
- if (isFocusable(component)) {
140
- component.focused = true;
221
+ this.focusedComponent = nextFocus;
222
+ if (isFocusable(nextFocus)) {
223
+ nextFocus.focused = true;
224
+ }
225
+ const focusedOverlay = nextFocus
226
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
227
+ : undefined;
228
+ if (focusedOverlay) {
229
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
230
+ }
231
+ }
232
+ clearOverlayFocusRestore() {
233
+ this.overlayFocusRestore = { status: "inactive" };
234
+ }
235
+ clearOverlayFocusRestoreFor(overlay) {
236
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
237
+ this.clearOverlayFocusRestore();
238
+ }
239
+ }
240
+ resolveBlockedOverlayFocusResume(restoreState) {
241
+ if (restoreState.resume.status === "restore-overlay")
242
+ return restoreState.overlay.component;
243
+ this.clearOverlayFocusRestore();
244
+ return restoreState.resume.target;
245
+ }
246
+ getVisibleOverlayFocusRestore() {
247
+ const restoreState = this.overlayFocusRestore;
248
+ if (restoreState.status === "inactive")
249
+ return restoreState;
250
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
251
+ return { status: "inactive" };
252
+ }
253
+ return restoreState;
254
+ }
255
+ isOverlayFocusAncestor(entry, component) {
256
+ const visited = new Set();
257
+ let current = entry.preFocus;
258
+ while (current && !visited.has(current)) {
259
+ visited.add(current);
260
+ if (current === component)
261
+ return true;
262
+ current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
263
+ }
264
+ return false;
265
+ }
266
+ retargetOverlayPreFocus(removed) {
267
+ for (const overlay of this.overlayStack) {
268
+ if (overlay !== removed && overlay.preFocus === removed.component) {
269
+ overlay.preFocus = removed.preFocus;
270
+ }
141
271
  }
142
272
  }
273
+ isComponentMounted(component) {
274
+ return this.children.some((child) => this.containsComponent(child, component));
275
+ }
276
+ containsComponent(root, target) {
277
+ if (root === target)
278
+ return true;
279
+ if (!(root instanceof Container))
280
+ return false;
281
+ return root.children.some((child) => this.containsComponent(child, target));
282
+ }
143
283
  /**
144
284
  * Show an overlay component with configurable positioning and sizing.
145
285
  * Returns a handle to control the overlay's visibility.
@@ -147,7 +287,7 @@ export class TUI extends Container {
147
287
  showOverlay(component, options) {
148
288
  const entry = {
149
289
  component,
150
- options,
290
+ ...(options === undefined ? {} : { options }),
151
291
  preFocus: this.focusedComponent,
152
292
  hidden: false,
153
293
  focusOrder: ++this.focusOrderCounter,
@@ -164,6 +304,8 @@ export class TUI extends Container {
164
304
  hide: () => {
165
305
  const index = this.overlayStack.indexOf(entry);
166
306
  if (index !== -1) {
307
+ this.clearOverlayFocusRestoreFor(entry);
308
+ this.retargetOverlayPreFocus(entry);
167
309
  this.overlayStack.splice(index, 1);
168
310
  // Restore focus if this overlay had focus
169
311
  if (this.focusedComponent === component) {
@@ -181,6 +323,7 @@ export class TUI extends Container {
181
323
  entry.hidden = hidden;
182
324
  // Update focus when hiding/showing
183
325
  if (hidden) {
326
+ this.clearOverlayFocusRestoreFor(entry);
184
327
  // If this overlay had focus, move focus to next visible or preFocus
185
328
  if (this.focusedComponent === component) {
186
329
  const topVisible = this.getTopmostVisibleOverlay();
@@ -200,17 +343,39 @@ export class TUI extends Container {
200
343
  focus: () => {
201
344
  if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
202
345
  return;
203
- if (this.focusedComponent !== component) {
204
- this.setFocus(component);
205
- }
206
346
  entry.focusOrder = ++this.focusOrderCounter;
347
+ this.setFocus(component);
207
348
  this.requestRender();
208
349
  },
209
- unfocus: () => {
210
- if (this.focusedComponent !== component)
350
+ unfocus: (unfocusOptions) => {
351
+ const isFocused = this.focusedComponent === component;
352
+ const restoreState = this.overlayFocusRestore;
353
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
354
+ if (!isFocused && !hasPendingRestore)
211
355
  return;
212
- const topVisible = this.getTopmostVisibleOverlay();
213
- this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
356
+ if (restoreState.status === "blocked" &&
357
+ restoreState.overlay === entry &&
358
+ this.focusedComponent === restoreState.blockedBy) {
359
+ if (unfocusOptions) {
360
+ this.overlayFocusRestore = {
361
+ status: "blocked",
362
+ overlay: entry,
363
+ blockedBy: restoreState.blockedBy,
364
+ resume: { status: "focus-target", target: unfocusOptions.target },
365
+ };
366
+ }
367
+ else {
368
+ this.clearOverlayFocusRestore();
369
+ }
370
+ this.requestRender();
371
+ return;
372
+ }
373
+ this.clearOverlayFocusRestoreFor(entry);
374
+ if (isFocused || unfocusOptions) {
375
+ const topVisible = this.getTopmostVisibleOverlay();
376
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
377
+ this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
378
+ }
214
379
  this.requestRender();
215
380
  },
216
381
  isFocused: () => this.focusedComponent === component,
@@ -218,9 +383,12 @@ export class TUI extends Container {
218
383
  }
219
384
  /** Hide the topmost overlay and restore previous focus. */
220
385
  hideOverlay() {
221
- const overlay = this.overlayStack.pop();
386
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
222
387
  if (!overlay)
223
388
  return;
389
+ this.clearOverlayFocusRestoreFor(overlay);
390
+ this.retargetOverlayPreFocus(overlay);
391
+ this.overlayStack.pop();
224
392
  if (this.focusedComponent === overlay.component) {
225
393
  // Find topmost visible overlay, or fall back to preFocus
226
394
  const topVisible = this.getTopmostVisibleOverlay();
@@ -243,16 +411,17 @@ export class TUI extends Container {
243
411
  }
244
412
  return true;
245
413
  }
246
- /** Find the topmost visible capturing overlay, if any */
414
+ /** Find the visual-frontmost visible capturing overlay, if any */
247
415
  getTopmostVisibleOverlay() {
248
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
249
- if (this.overlayStack[i].options?.nonCapturing)
416
+ let topmost;
417
+ for (const overlay of this.overlayStack) {
418
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
250
419
  continue;
251
- if (this.isOverlayVisible(this.overlayStack[i])) {
252
- return this.overlayStack[i];
420
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
421
+ topmost = overlay;
253
422
  }
254
423
  }
255
- return undefined;
424
+ return topmost;
256
425
  }
257
426
  invalidate() {
258
427
  super.invalidate();
@@ -263,6 +432,9 @@ export class TUI extends Container {
263
432
  this.stopped = false;
264
433
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
265
434
  this.terminal.hideCursor();
435
+ if (this.terminalColorSchemeNotificationsEnabled) {
436
+ this.terminal.write("\x1b[?2031h");
437
+ }
266
438
  this.queryCellSize();
267
439
  this.requestRender();
268
440
  }
@@ -275,6 +447,21 @@ export class TUI extends Container {
275
447
  removeInputListener(listener) {
276
448
  this.inputListeners.delete(listener);
277
449
  }
450
+ onTerminalColorSchemeChange(listener) {
451
+ this.terminalColorSchemeListeners.add(listener);
452
+ return () => {
453
+ this.terminalColorSchemeListeners.delete(listener);
454
+ };
455
+ }
456
+ setTerminalColorSchemeNotifications(enabled) {
457
+ if (this.terminalColorSchemeNotificationsEnabled === enabled) {
458
+ return;
459
+ }
460
+ this.terminalColorSchemeNotificationsEnabled = enabled;
461
+ if (!this.stopped) {
462
+ this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");
463
+ }
464
+ }
278
465
  queryCellSize() {
279
466
  // Only query if terminal supports images (cell size is only used for image rendering)
280
467
  if (!getCapabilities().images) {
@@ -290,6 +477,9 @@ export class TUI extends Container {
290
477
  clearTimeout(this.renderTimer);
291
478
  this.renderTimer = undefined;
292
479
  }
480
+ if (this.terminalColorSchemeNotificationsEnabled) {
481
+ this.terminal.write("\x1b[?2031l");
482
+ }
293
483
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
294
484
  if (this.previousLines.length > 0) {
295
485
  const targetRow = this.previousLines.length; // Line after the last content
@@ -354,6 +544,12 @@ export class TUI extends Container {
354
544
  }, delay);
355
545
  }
356
546
  handleInput(data) {
547
+ if (this.consumeOsc11BackgroundResponse(data)) {
548
+ return;
549
+ }
550
+ if (this.consumeTerminalColorSchemeReport(data)) {
551
+ return;
552
+ }
357
553
  if (this.inputListeners.size > 0) {
358
554
  let current = data;
359
555
  for (const listener of this.inputListeners) {
@@ -389,8 +585,23 @@ export class TUI extends Container {
389
585
  this.setFocus(topVisible.component);
390
586
  }
391
587
  else {
392
- // No visible overlays, restore to preFocus
393
- this.setFocus(focusedOverlay.preFocus);
588
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
589
+ }
590
+ }
591
+ const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
592
+ if (!focusIsOverlay) {
593
+ const restoreState = this.getVisibleOverlayFocusRestore();
594
+ if (restoreState.status === "eligible") {
595
+ this.setFocus(restoreState.overlay.component);
596
+ }
597
+ else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
598
+ if (restoreState.resume.status === "restore-overlay") {
599
+ this.setFocus(restoreState.overlay.component);
600
+ }
601
+ else {
602
+ this.clearOverlayFocusRestore();
603
+ this.setFocus(restoreState.resume.target);
604
+ }
394
605
  }
395
606
  }
396
607
  // Pass input to focused component (including Ctrl+C)
@@ -404,6 +615,37 @@ export class TUI extends Container {
404
615
  this.requestRender();
405
616
  }
406
617
  }
618
+ consumeOsc11BackgroundResponse(data) {
619
+ if (this.pendingOsc11BackgroundReplies <= 0) {
620
+ return false;
621
+ }
622
+ if (!isOsc11BackgroundColorResponse(data)) {
623
+ return false;
624
+ }
625
+ const rgb = parseOsc11BackgroundColor(data);
626
+ this.pendingOsc11BackgroundReplies -= 1;
627
+ const query = this.pendingOsc11BackgroundQueries.shift();
628
+ if (query && !query.settled) {
629
+ query.settled = true;
630
+ if (query.timer) {
631
+ clearTimeout(query.timer);
632
+ query.timer = undefined;
633
+ }
634
+ query.resolve?.(rgb);
635
+ query.resolve = undefined;
636
+ }
637
+ return true;
638
+ }
639
+ consumeTerminalColorSchemeReport(data) {
640
+ const scheme = parseTerminalColorSchemeReport(data);
641
+ if (!scheme) {
642
+ return false;
643
+ }
644
+ for (const listener of this.terminalColorSchemeListeners) {
645
+ listener(scheme);
646
+ }
647
+ return true;
648
+ }
407
649
  consumeCellSizeResponse(data) {
408
650
  // Response format: ESC [ 6 ; height ; width t
409
651
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -602,11 +844,71 @@ export class TUI extends Container {
602
844
  for (let i = 0; i < lines.length; i++) {
603
845
  const line = lines[i];
604
846
  if (!isImageLine(line)) {
605
- lines[i] = line + reset;
847
+ lines[i] = normalizeTerminalOutput(line) + reset;
606
848
  }
607
849
  }
608
850
  return lines;
609
851
  }
852
+ collectKittyImageIds(lines) {
853
+ const ids = new Set();
854
+ for (const line of lines) {
855
+ for (const id of extractKittyImageIds(line)) {
856
+ ids.add(id);
857
+ }
858
+ }
859
+ return ids;
860
+ }
861
+ deleteKittyImages(ids) {
862
+ let buffer = "";
863
+ for (const id of ids) {
864
+ buffer += deleteKittyImage(id);
865
+ }
866
+ return buffer;
867
+ }
868
+ getKittyImageReservedRows(lines, index, maxIndex = lines.length - 1) {
869
+ const rows = extractKittyImageRows(lines[index] ?? "");
870
+ if (rows <= 1)
871
+ return 1;
872
+ const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
873
+ let reservedRows = 1;
874
+ while (reservedRows < maxRows) {
875
+ const line = lines[index + reservedRows] ?? "";
876
+ if (isImageLine(line) || visibleWidth(line) > 0)
877
+ break;
878
+ reservedRows++;
879
+ }
880
+ return reservedRows;
881
+ }
882
+ expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines) {
883
+ let expandedFirstChanged = firstChanged;
884
+ let expandedLastChanged = lastChanged;
885
+ const expandForLines = (lines) => {
886
+ for (let i = 0; i < lines.length; i++) {
887
+ if (extractKittyImageIds(lines[i]).length === 0)
888
+ continue;
889
+ const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
890
+ if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
891
+ expandedFirstChanged = Math.min(expandedFirstChanged, i);
892
+ expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
893
+ }
894
+ }
895
+ };
896
+ expandForLines(this.previousLines);
897
+ expandForLines(newLines);
898
+ return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
899
+ }
900
+ deleteChangedKittyImages(firstChanged, lastChanged) {
901
+ if (firstChanged < 0 || lastChanged < firstChanged)
902
+ return "";
903
+ const ids = new Set();
904
+ const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
905
+ for (let i = firstChanged; i <= maxLine; i++) {
906
+ for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
907
+ ids.add(id);
908
+ }
909
+ }
910
+ return this.deleteKittyImages(ids);
911
+ }
610
912
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
611
913
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
612
914
  if (isImageLine(baseLine))
@@ -700,12 +1002,27 @@ export class TUI extends Container {
700
1002
  const fullRender = (clear) => {
701
1003
  this.fullRedrawCount += 1;
702
1004
  let buffer = "\x1b[?2026h"; // Begin synchronized output
703
- if (clear)
1005
+ if (clear) {
1006
+ buffer += this.deleteKittyImages(this.previousKittyImageIds);
704
1007
  buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1008
+ }
705
1009
  for (let i = 0; i < newLines.length; i++) {
706
1010
  if (i > 0)
707
1011
  buffer += "\r\n";
708
- buffer += newLines[i];
1012
+ const line = newLines[i];
1013
+ const isImage = isImageLine(line);
1014
+ const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
1015
+ if (imageReservedRows > 1 && imageReservedRows <= height) {
1016
+ for (let row = 1; row < imageReservedRows; row++) {
1017
+ buffer += "\r\n";
1018
+ }
1019
+ buffer += `\x1b[${imageReservedRows - 1}A`;
1020
+ buffer += line;
1021
+ buffer += `\x1b[${imageReservedRows - 1}B`;
1022
+ i += imageReservedRows - 1;
1023
+ continue;
1024
+ }
1025
+ buffer += line;
709
1026
  }
710
1027
  buffer += "\x1b[?2026l"; // End synchronized output
711
1028
  this.terminal.write(buffer);
@@ -722,6 +1039,7 @@ export class TUI extends Container {
722
1039
  this.previousViewportTop = Math.max(0, bufferLength - height);
723
1040
  this.positionHardwareCursor(cursorPos, newLines.length);
724
1041
  this.previousLines = newLines;
1042
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
725
1043
  this.previousWidth = width;
726
1044
  this.previousHeight = height;
727
1045
  };
@@ -782,6 +1100,11 @@ export class TUI extends Container {
782
1100
  }
783
1101
  lastChanged = newLines.length - 1;
784
1102
  }
1103
+ if (firstChanged !== -1) {
1104
+ const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
1105
+ firstChanged = expandedRange.firstChanged;
1106
+ lastChanged = expandedRange.lastChanged;
1107
+ }
785
1108
  const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
786
1109
  // No changes - but still need to update hardware cursor position if it moved
787
1110
  if (firstChanged === -1) {
@@ -794,6 +1117,7 @@ export class TUI extends Container {
794
1117
  if (firstChanged >= newLines.length) {
795
1118
  if (this.previousLines.length > newLines.length) {
796
1119
  let buffer = "\x1b[?2026h";
1120
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
797
1121
  // Move to end of new content (clamp to 0 for empty content)
798
1122
  const targetRow = Math.max(0, newLines.length - 1);
799
1123
  if (targetRow < prevViewportTop) {
@@ -814,16 +1138,18 @@ export class TUI extends Container {
814
1138
  fullRender(true);
815
1139
  return;
816
1140
  }
817
- if (extraLines > 0) {
818
- buffer += "\x1b[1B";
1141
+ const clearStartOffset = newLines.length === 0 ? 0 : 1;
1142
+ if (extraLines > 0 && clearStartOffset > 0) {
1143
+ buffer += `\x1b[${clearStartOffset}B`;
819
1144
  }
820
1145
  for (let i = 0; i < extraLines; i++) {
821
1146
  buffer += "\r\x1b[2K";
822
1147
  if (i < extraLines - 1)
823
1148
  buffer += "\x1b[1B";
824
1149
  }
825
- if (extraLines > 0) {
826
- buffer += `\x1b[${extraLines}A`;
1150
+ const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
1151
+ if (moveBack > 0) {
1152
+ buffer += `\x1b[${moveBack}A`;
827
1153
  }
828
1154
  buffer += "\x1b[?2026l";
829
1155
  this.terminal.write(buffer);
@@ -832,6 +1158,7 @@ export class TUI extends Container {
832
1158
  }
833
1159
  this.positionHardwareCursor(cursorPos, newLines.length);
834
1160
  this.previousLines = newLines;
1161
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
835
1162
  this.previousWidth = width;
836
1163
  this.previousHeight = height;
837
1164
  this.previousViewportTop = prevViewportTop;
@@ -847,6 +1174,7 @@ export class TUI extends Container {
847
1174
  // Render from first changed line to end
848
1175
  // Build buffer with all updates wrapped in synchronized output
849
1176
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1177
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
850
1178
  const prevViewportBottom = prevViewportTop + height - 1;
851
1179
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
852
1180
  if (moveTargetRow > prevViewportBottom) {
@@ -876,9 +1204,27 @@ export class TUI extends Container {
876
1204
  for (let i = firstChanged; i <= renderEnd; i++) {
877
1205
  if (i > firstChanged)
878
1206
  buffer += "\r\n";
879
- buffer += "\x1b[2K"; // Clear current line
880
1207
  const line = newLines[i];
881
1208
  const isImage = isImageLine(line);
1209
+ const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
1210
+ if (imageReservedRows > 1) {
1211
+ const imageStartScreenRow = i - viewportTop;
1212
+ if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
1213
+ logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
1214
+ fullRender(true);
1215
+ return;
1216
+ }
1217
+ buffer += "\x1b[2K";
1218
+ for (let row = 1; row < imageReservedRows; row++) {
1219
+ buffer += "\r\n\x1b[2K";
1220
+ }
1221
+ buffer += `\x1b[${imageReservedRows - 1}A`;
1222
+ buffer += line;
1223
+ buffer += `\x1b[${imageReservedRows - 1}B`;
1224
+ i += imageReservedRows - 1;
1225
+ continue;
1226
+ }
1227
+ buffer += "\x1b[2K"; // Clear current line
882
1228
  if (!isImage && visibleWidth(line) > width) {
883
1229
  // Log all lines to crash file for debugging
884
1230
  const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
@@ -966,6 +1312,7 @@ export class TUI extends Container {
966
1312
  // Position hardware cursor for IME
967
1313
  this.positionHardwareCursor(cursorPos, newLines.length);
968
1314
  this.previousLines = newLines;
1315
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
969
1316
  this.previousWidth = width;
970
1317
  this.previousHeight = height;
971
1318
  }
@@ -1004,5 +1351,57 @@ export class TUI extends Container {
1004
1351
  this.terminal.hideCursor();
1005
1352
  }
1006
1353
  }
1354
+ /**
1355
+ * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
1356
+ * @param timeoutMs Query timeout in milliseconds.
1357
+ * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
1358
+ */
1359
+ queryTerminalBackgroundColor({ timeoutMs }) {
1360
+ return new Promise((resolve) => {
1361
+ const query = {
1362
+ settled: false,
1363
+ resolve,
1364
+ timer: undefined,
1365
+ };
1366
+ query.timer = setTimeout(() => {
1367
+ if (query.settled) {
1368
+ return;
1369
+ }
1370
+ query.settled = true;
1371
+ query.timer = undefined;
1372
+ query.resolve?.(undefined);
1373
+ query.resolve = undefined;
1374
+ }, timeoutMs);
1375
+ this.pendingOsc11BackgroundQueries.push(query);
1376
+ this.pendingOsc11BackgroundReplies += 1;
1377
+ this.terminal.write("\x1b]11;?\x07");
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
1382
+ * Terminals that support the color palette notification protocol reply with
1383
+ * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
1384
+ */
1385
+ queryTerminalColorScheme({ timeoutMs }) {
1386
+ return new Promise((resolve) => {
1387
+ let settled = false;
1388
+ let timer;
1389
+ let unsubscribe = () => { };
1390
+ const settle = (scheme) => {
1391
+ if (settled)
1392
+ return;
1393
+ settled = true;
1394
+ if (timer) {
1395
+ clearTimeout(timer);
1396
+ timer = undefined;
1397
+ }
1398
+ unsubscribe();
1399
+ resolve(scheme);
1400
+ };
1401
+ unsubscribe = this.onTerminalColorSchemeChange(settle);
1402
+ timer = setTimeout(() => settle(undefined), timeoutMs);
1403
+ this.terminal.write("\x1b[?996n");
1404
+ });
1405
+ }
1007
1406
  }
1008
1407
  //# sourceMappingURL=tui.js.map