@draht/tui 2026.4.26 → 2026.6.11

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 (91) 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 -4
  12. package/dist/components/editor.d.ts.map +1 -1
  13. package/dist/components/editor.js +233 -178
  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 +8 -7
  28. package/dist/components/markdown.d.ts.map +1 -1
  29. package/dist/components/markdown.js +87 -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.map +1 -1
  50. package/dist/fuzzy.js +3 -0
  51. package/dist/fuzzy.js.map +1 -1
  52. package/dist/index.d.ts +22 -22
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/keybindings.d.ts +1 -1
  57. package/dist/keybindings.d.ts.map +1 -1
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts +19 -5
  60. package/dist/keys.d.ts.map +1 -1
  61. package/dist/keys.js +82 -33
  62. package/dist/keys.js.map +1 -1
  63. package/dist/native-modifiers.d.ts +3 -0
  64. package/dist/native-modifiers.d.ts.map +1 -0
  65. package/dist/native-modifiers.js +53 -0
  66. package/dist/native-modifiers.js.map +1 -0
  67. package/dist/stdin-buffer.d.ts +2 -0
  68. package/dist/stdin-buffer.d.ts.map +1 -1
  69. package/dist/stdin-buffer.js +48 -4
  70. package/dist/stdin-buffer.js.map +1 -1
  71. package/dist/terminal-image.d.ts +23 -1
  72. package/dist/terminal-image.d.ts.map +1 -1
  73. package/dist/terminal-image.js +94 -16
  74. package/dist/terminal-image.js.map +1 -1
  75. package/dist/terminal.d.ts +36 -10
  76. package/dist/terminal.d.ts.map +1 -1
  77. package/dist/terminal.js +212 -64
  78. package/dist/terminal.js.map +1 -1
  79. package/dist/tui.d.ts +25 -5
  80. package/dist/tui.d.ts.map +1 -1
  81. package/dist/tui.js +245 -30
  82. package/dist/tui.js.map +1 -1
  83. package/dist/utils.d.ts +8 -1
  84. package/dist/utils.d.ts.map +1 -1
  85. package/dist/utils.js +142 -42
  86. package/dist/utils.js.map +1 -1
  87. package/dist/word-navigation.d.ts +25 -0
  88. package/dist/word-navigation.d.ts.map +1 -0
  89. package/dist/word-navigation.js +96 -0
  90. package/dist/word-navigation.js.map +1 -0
  91. package/package.json +1 -1
package/dist/tui.js CHANGED
@@ -6,8 +6,29 @@ 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 { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
10
+ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
11
+ const KITTY_SEQUENCE_PREFIX = "\x1b_G";
12
+ function extractKittyImageIds(line) {
13
+ const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
14
+ if (sequenceStart === -1)
15
+ return [];
16
+ const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
17
+ const paramsEnd = line.indexOf(";", paramsStart);
18
+ if (paramsEnd === -1)
19
+ return [];
20
+ const params = line.slice(paramsStart, paramsEnd);
21
+ for (const param of params.split(",")) {
22
+ const [key, value] = param.split("=", 2);
23
+ if (key !== "i" || value === undefined)
24
+ continue;
25
+ const id = Number(value);
26
+ if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
27
+ return [id];
28
+ }
29
+ }
30
+ return [];
31
+ }
11
32
  /** Type guard to check if a component implements Focusable */
12
33
  export function isFocusable(component) {
13
34
  return component !== null && "focused" in component;
@@ -75,6 +96,7 @@ export class Container {
75
96
  export class TUI extends Container {
76
97
  terminal;
77
98
  previousLines = [];
99
+ previousKittyImageIds = new Set();
78
100
  previousWidth = 0;
79
101
  previousHeight = 0;
80
102
  focusedComponent = null;
@@ -96,6 +118,7 @@ export class TUI extends Container {
96
118
  // Overlay stack for modal components rendered on top of base content
97
119
  focusOrderCounter = 0;
98
120
  overlayStack = [];
121
+ overlayFocusRestore = { status: "inactive" };
99
122
  constructor(terminal, showHardwareCursor) {
100
123
  super();
101
124
  this.terminal = terminal;
@@ -130,16 +153,115 @@ export class TUI extends Container {
130
153
  this.clearOnShrink = enabled;
131
154
  }
132
155
  setFocus(component) {
133
- // Clear focused flag on old component
156
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
157
+ }
158
+ setFocusInternal({ component, overlayFocusRestore, }) {
159
+ const previousFocus = this.focusedComponent;
160
+ let nextFocus = component;
161
+ const previousFocusedOverlay = previousFocus
162
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
163
+ : undefined;
164
+ const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
165
+ const restoreState = this.getVisibleOverlayFocusRestore();
166
+ if (nextFocus && !nextFocusIsOverlay) {
167
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
168
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
169
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
170
+ }
171
+ else {
172
+ this.overlayFocusRestore = {
173
+ status: "blocked",
174
+ overlay: restoreState.overlay,
175
+ blockedBy: nextFocus,
176
+ resume: restoreState.resume,
177
+ };
178
+ }
179
+ }
180
+ else if (previousFocusedOverlay &&
181
+ restoreState.status !== "inactive" &&
182
+ restoreState.overlay === previousFocusedOverlay &&
183
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
184
+ this.overlayFocusRestore = {
185
+ status: "blocked",
186
+ overlay: previousFocusedOverlay,
187
+ blockedBy: nextFocus,
188
+ resume: { status: "restore-overlay" },
189
+ };
190
+ }
191
+ }
192
+ else if (nextFocus === null) {
193
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
194
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
195
+ }
196
+ else if (overlayFocusRestore === "clear") {
197
+ this.clearOverlayFocusRestore();
198
+ }
199
+ }
134
200
  if (isFocusable(this.focusedComponent)) {
135
201
  this.focusedComponent.focused = false;
136
202
  }
137
- this.focusedComponent = component;
138
- // Set focused flag on new component
139
- if (isFocusable(component)) {
140
- component.focused = true;
203
+ this.focusedComponent = nextFocus;
204
+ if (isFocusable(nextFocus)) {
205
+ nextFocus.focused = true;
206
+ }
207
+ const focusedOverlay = nextFocus
208
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
209
+ : undefined;
210
+ if (focusedOverlay) {
211
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
141
212
  }
142
213
  }
214
+ clearOverlayFocusRestore() {
215
+ this.overlayFocusRestore = { status: "inactive" };
216
+ }
217
+ clearOverlayFocusRestoreFor(overlay) {
218
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
219
+ this.clearOverlayFocusRestore();
220
+ }
221
+ }
222
+ resolveBlockedOverlayFocusResume(restoreState) {
223
+ if (restoreState.resume.status === "restore-overlay")
224
+ return restoreState.overlay.component;
225
+ this.clearOverlayFocusRestore();
226
+ return restoreState.resume.target;
227
+ }
228
+ getVisibleOverlayFocusRestore() {
229
+ const restoreState = this.overlayFocusRestore;
230
+ if (restoreState.status === "inactive")
231
+ return restoreState;
232
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
233
+ return { status: "inactive" };
234
+ }
235
+ return restoreState;
236
+ }
237
+ isOverlayFocusAncestor(entry, component) {
238
+ const visited = new Set();
239
+ let current = entry.preFocus;
240
+ while (current && !visited.has(current)) {
241
+ visited.add(current);
242
+ if (current === component)
243
+ return true;
244
+ current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
245
+ }
246
+ return false;
247
+ }
248
+ retargetOverlayPreFocus(removed) {
249
+ for (const overlay of this.overlayStack) {
250
+ if (overlay !== removed && overlay.preFocus === removed.component) {
251
+ overlay.preFocus = removed.preFocus;
252
+ }
253
+ }
254
+ }
255
+ isComponentMounted(component) {
256
+ return this.children.some((child) => this.containsComponent(child, component));
257
+ }
258
+ containsComponent(root, target) {
259
+ if (root === target)
260
+ return true;
261
+ if (!(root instanceof Container))
262
+ return false;
263
+ return root.children.some((child) => this.containsComponent(child, target));
264
+ }
143
265
  /**
144
266
  * Show an overlay component with configurable positioning and sizing.
145
267
  * Returns a handle to control the overlay's visibility.
@@ -147,7 +269,7 @@ export class TUI extends Container {
147
269
  showOverlay(component, options) {
148
270
  const entry = {
149
271
  component,
150
- options,
272
+ ...(options === undefined ? {} : { options }),
151
273
  preFocus: this.focusedComponent,
152
274
  hidden: false,
153
275
  focusOrder: ++this.focusOrderCounter,
@@ -164,6 +286,8 @@ export class TUI extends Container {
164
286
  hide: () => {
165
287
  const index = this.overlayStack.indexOf(entry);
166
288
  if (index !== -1) {
289
+ this.clearOverlayFocusRestoreFor(entry);
290
+ this.retargetOverlayPreFocus(entry);
167
291
  this.overlayStack.splice(index, 1);
168
292
  // Restore focus if this overlay had focus
169
293
  if (this.focusedComponent === component) {
@@ -181,6 +305,7 @@ export class TUI extends Container {
181
305
  entry.hidden = hidden;
182
306
  // Update focus when hiding/showing
183
307
  if (hidden) {
308
+ this.clearOverlayFocusRestoreFor(entry);
184
309
  // If this overlay had focus, move focus to next visible or preFocus
185
310
  if (this.focusedComponent === component) {
186
311
  const topVisible = this.getTopmostVisibleOverlay();
@@ -200,17 +325,39 @@ export class TUI extends Container {
200
325
  focus: () => {
201
326
  if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
202
327
  return;
203
- if (this.focusedComponent !== component) {
204
- this.setFocus(component);
205
- }
206
328
  entry.focusOrder = ++this.focusOrderCounter;
329
+ this.setFocus(component);
207
330
  this.requestRender();
208
331
  },
209
- unfocus: () => {
210
- if (this.focusedComponent !== component)
332
+ unfocus: (unfocusOptions) => {
333
+ const isFocused = this.focusedComponent === component;
334
+ const restoreState = this.overlayFocusRestore;
335
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
336
+ if (!isFocused && !hasPendingRestore)
337
+ return;
338
+ if (restoreState.status === "blocked" &&
339
+ restoreState.overlay === entry &&
340
+ this.focusedComponent === restoreState.blockedBy) {
341
+ if (unfocusOptions) {
342
+ this.overlayFocusRestore = {
343
+ status: "blocked",
344
+ overlay: entry,
345
+ blockedBy: restoreState.blockedBy,
346
+ resume: { status: "focus-target", target: unfocusOptions.target },
347
+ };
348
+ }
349
+ else {
350
+ this.clearOverlayFocusRestore();
351
+ }
352
+ this.requestRender();
211
353
  return;
212
- const topVisible = this.getTopmostVisibleOverlay();
213
- this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
354
+ }
355
+ this.clearOverlayFocusRestoreFor(entry);
356
+ if (isFocused || unfocusOptions) {
357
+ const topVisible = this.getTopmostVisibleOverlay();
358
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
359
+ this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
360
+ }
214
361
  this.requestRender();
215
362
  },
216
363
  isFocused: () => this.focusedComponent === component,
@@ -218,9 +365,12 @@ export class TUI extends Container {
218
365
  }
219
366
  /** Hide the topmost overlay and restore previous focus. */
220
367
  hideOverlay() {
221
- const overlay = this.overlayStack.pop();
368
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
222
369
  if (!overlay)
223
370
  return;
371
+ this.clearOverlayFocusRestoreFor(overlay);
372
+ this.retargetOverlayPreFocus(overlay);
373
+ this.overlayStack.pop();
224
374
  if (this.focusedComponent === overlay.component) {
225
375
  // Find topmost visible overlay, or fall back to preFocus
226
376
  const topVisible = this.getTopmostVisibleOverlay();
@@ -243,16 +393,17 @@ export class TUI extends Container {
243
393
  }
244
394
  return true;
245
395
  }
246
- /** Find the topmost visible capturing overlay, if any */
396
+ /** Find the visual-frontmost visible capturing overlay, if any */
247
397
  getTopmostVisibleOverlay() {
248
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
249
- if (this.overlayStack[i].options?.nonCapturing)
398
+ let topmost;
399
+ for (const overlay of this.overlayStack) {
400
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
250
401
  continue;
251
- if (this.isOverlayVisible(this.overlayStack[i])) {
252
- return this.overlayStack[i];
402
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
403
+ topmost = overlay;
253
404
  }
254
405
  }
255
- return undefined;
406
+ return topmost;
256
407
  }
257
408
  invalidate() {
258
409
  super.invalidate();
@@ -389,8 +540,23 @@ export class TUI extends Container {
389
540
  this.setFocus(topVisible.component);
390
541
  }
391
542
  else {
392
- // No visible overlays, restore to preFocus
393
- this.setFocus(focusedOverlay.preFocus);
543
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
544
+ }
545
+ }
546
+ const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
547
+ if (!focusIsOverlay) {
548
+ const restoreState = this.getVisibleOverlayFocusRestore();
549
+ if (restoreState.status === "eligible") {
550
+ this.setFocus(restoreState.overlay.component);
551
+ }
552
+ else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
553
+ if (restoreState.resume.status === "restore-overlay") {
554
+ this.setFocus(restoreState.overlay.component);
555
+ }
556
+ else {
557
+ this.clearOverlayFocusRestore();
558
+ this.setFocus(restoreState.resume.target);
559
+ }
394
560
  }
395
561
  }
396
562
  // Pass input to focused component (including Ctrl+C)
@@ -602,11 +768,48 @@ export class TUI extends Container {
602
768
  for (let i = 0; i < lines.length; i++) {
603
769
  const line = lines[i];
604
770
  if (!isImageLine(line)) {
605
- lines[i] = line + reset;
771
+ lines[i] = normalizeTerminalOutput(line) + reset;
606
772
  }
607
773
  }
608
774
  return lines;
609
775
  }
776
+ collectKittyImageIds(lines) {
777
+ const ids = new Set();
778
+ for (const line of lines) {
779
+ for (const id of extractKittyImageIds(line)) {
780
+ ids.add(id);
781
+ }
782
+ }
783
+ return ids;
784
+ }
785
+ deleteKittyImages(ids) {
786
+ let buffer = "";
787
+ for (const id of ids) {
788
+ buffer += deleteKittyImage(id);
789
+ }
790
+ return buffer;
791
+ }
792
+ expandLastChangedForKittyImages(firstChanged, lastChanged) {
793
+ let expandedLastChanged = lastChanged;
794
+ for (let i = firstChanged; i < this.previousLines.length; i++) {
795
+ if (extractKittyImageIds(this.previousLines[i]).length > 0) {
796
+ expandedLastChanged = Math.max(expandedLastChanged, i);
797
+ }
798
+ }
799
+ return expandedLastChanged;
800
+ }
801
+ deleteChangedKittyImages(firstChanged, lastChanged) {
802
+ if (firstChanged < 0 || lastChanged < firstChanged)
803
+ return "";
804
+ const ids = new Set();
805
+ const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
806
+ for (let i = firstChanged; i <= maxLine; i++) {
807
+ for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
808
+ ids.add(id);
809
+ }
810
+ }
811
+ return this.deleteKittyImages(ids);
812
+ }
610
813
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
611
814
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
612
815
  if (isImageLine(baseLine))
@@ -700,8 +903,10 @@ export class TUI extends Container {
700
903
  const fullRender = (clear) => {
701
904
  this.fullRedrawCount += 1;
702
905
  let buffer = "\x1b[?2026h"; // Begin synchronized output
703
- if (clear)
906
+ if (clear) {
907
+ buffer += this.deleteKittyImages(this.previousKittyImageIds);
704
908
  buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
909
+ }
705
910
  for (let i = 0; i < newLines.length; i++) {
706
911
  if (i > 0)
707
912
  buffer += "\r\n";
@@ -722,6 +927,7 @@ export class TUI extends Container {
722
927
  this.previousViewportTop = Math.max(0, bufferLength - height);
723
928
  this.positionHardwareCursor(cursorPos, newLines.length);
724
929
  this.previousLines = newLines;
930
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
725
931
  this.previousWidth = width;
726
932
  this.previousHeight = height;
727
933
  };
@@ -782,6 +988,9 @@ export class TUI extends Container {
782
988
  }
783
989
  lastChanged = newLines.length - 1;
784
990
  }
991
+ if (firstChanged !== -1) {
992
+ lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
993
+ }
785
994
  const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
786
995
  // No changes - but still need to update hardware cursor position if it moved
787
996
  if (firstChanged === -1) {
@@ -794,6 +1003,7 @@ export class TUI extends Container {
794
1003
  if (firstChanged >= newLines.length) {
795
1004
  if (this.previousLines.length > newLines.length) {
796
1005
  let buffer = "\x1b[?2026h";
1006
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
797
1007
  // Move to end of new content (clamp to 0 for empty content)
798
1008
  const targetRow = Math.max(0, newLines.length - 1);
799
1009
  if (targetRow < prevViewportTop) {
@@ -814,16 +1024,18 @@ export class TUI extends Container {
814
1024
  fullRender(true);
815
1025
  return;
816
1026
  }
817
- if (extraLines > 0) {
818
- buffer += "\x1b[1B";
1027
+ const clearStartOffset = newLines.length === 0 ? 0 : 1;
1028
+ if (extraLines > 0 && clearStartOffset > 0) {
1029
+ buffer += `\x1b[${clearStartOffset}B`;
819
1030
  }
820
1031
  for (let i = 0; i < extraLines; i++) {
821
1032
  buffer += "\r\x1b[2K";
822
1033
  if (i < extraLines - 1)
823
1034
  buffer += "\x1b[1B";
824
1035
  }
825
- if (extraLines > 0) {
826
- buffer += `\x1b[${extraLines}A`;
1036
+ const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
1037
+ if (moveBack > 0) {
1038
+ buffer += `\x1b[${moveBack}A`;
827
1039
  }
828
1040
  buffer += "\x1b[?2026l";
829
1041
  this.terminal.write(buffer);
@@ -832,6 +1044,7 @@ export class TUI extends Container {
832
1044
  }
833
1045
  this.positionHardwareCursor(cursorPos, newLines.length);
834
1046
  this.previousLines = newLines;
1047
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
835
1048
  this.previousWidth = width;
836
1049
  this.previousHeight = height;
837
1050
  this.previousViewportTop = prevViewportTop;
@@ -847,6 +1060,7 @@ export class TUI extends Container {
847
1060
  // Render from first changed line to end
848
1061
  // Build buffer with all updates wrapped in synchronized output
849
1062
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1063
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
850
1064
  const prevViewportBottom = prevViewportTop + height - 1;
851
1065
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
852
1066
  if (moveTargetRow > prevViewportBottom) {
@@ -966,6 +1180,7 @@ export class TUI extends Container {
966
1180
  // Position hardware cursor for IME
967
1181
  this.positionHardwareCursor(cursorPos, newLines.length);
968
1182
  this.previousLines = newLines;
1183
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
969
1184
  this.previousWidth = width;
970
1185
  this.previousHeight = height;
971
1186
  }