phi-code-tui 0.56.3 → 0.74.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 (88) hide show
  1. package/README.md +29 -11
  2. package/dist/autocomplete.d.ts +18 -14
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +151 -112
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js +6 -1
  8. package/dist/components/box.js.map +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js +6 -7
  11. package/dist/components/cancellable-loader.js.map +1 -1
  12. package/dist/components/editor.d.ts +45 -1
  13. package/dist/components/editor.d.ts.map +1 -1
  14. package/dist/components/editor.js +505 -221
  15. package/dist/components/editor.js.map +1 -1
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +22 -7
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts.map +1 -1
  20. package/dist/components/input.js +57 -74
  21. package/dist/components/input.js.map +1 -1
  22. package/dist/components/loader.d.ts +12 -2
  23. package/dist/components/loader.d.ts.map +1 -1
  24. package/dist/components/loader.js +36 -13
  25. package/dist/components/loader.js.map +1 -1
  26. package/dist/components/markdown.d.ts +0 -5
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +101 -114
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/select-list.d.ts +19 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +82 -71
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts.map +1 -1
  35. package/dist/components/settings-list.js +18 -10
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts.map +1 -1
  38. package/dist/components/spacer.js +1 -0
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts.map +1 -1
  41. package/dist/components/text.js +8 -0
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts.map +1 -1
  44. package/dist/components/truncated-text.js +3 -0
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts.map +1 -1
  47. package/dist/fuzzy.d.ts.map +1 -1
  48. package/dist/fuzzy.js +3 -0
  49. package/dist/fuzzy.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/keybindings.d.ts +187 -33
  55. package/dist/keybindings.d.ts.map +1 -1
  56. package/dist/keybindings.js +156 -95
  57. package/dist/keybindings.js.map +1 -1
  58. package/dist/keys.d.ts +21 -12
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +270 -112
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts.map +1 -1
  63. package/dist/kill-ring.js +1 -3
  64. package/dist/kill-ring.js.map +1 -1
  65. package/dist/stdin-buffer.d.ts +2 -0
  66. package/dist/stdin-buffer.d.ts.map +1 -1
  67. package/dist/stdin-buffer.js +31 -8
  68. package/dist/stdin-buffer.js.map +1 -1
  69. package/dist/terminal-image.d.ts +17 -0
  70. package/dist/terminal-image.d.ts.map +1 -1
  71. package/dist/terminal-image.js +41 -5
  72. package/dist/terminal-image.js.map +1 -1
  73. package/dist/terminal.d.ts +4 -0
  74. package/dist/terminal.d.ts.map +1 -1
  75. package/dist/terminal.js +56 -8
  76. package/dist/terminal.js.map +1 -1
  77. package/dist/tui.d.ts +21 -5
  78. package/dist/tui.d.ts.map +1 -1
  79. package/dist/tui.js +234 -118
  80. package/dist/tui.js.map +1 -1
  81. package/dist/undo-stack.d.ts.map +1 -1
  82. package/dist/undo-stack.js +1 -3
  83. package/dist/undo-stack.js.map +1 -1
  84. package/dist/utils.d.ts +1 -0
  85. package/dist/utils.d.ts.map +1 -1
  86. package/dist/utils.js +281 -81
  87. package/dist/utils.js.map +1 -1
  88. package/package.json +3 -3
package/dist/tui.js CHANGED
@@ -4,9 +4,31 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
+ import { performance } from "node:perf_hooks";
7
8
  import { isKeyRelease, matchesKey } from "./keys.js";
8
- import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
9
- 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
+ }
10
32
  /** Type guard to check if a component implements Focusable */
11
33
  export function isFocusable(component) {
12
34
  return component !== null && "focused" in component;
@@ -32,13 +54,14 @@ function parseSizeValue(value, referenceSize) {
32
54
  }
33
55
  return undefined;
34
56
  }
57
+ function isTermuxSession() {
58
+ return Boolean(process.env.TERMUX_VERSION);
59
+ }
35
60
  /**
36
61
  * Container - a component that contains other components
37
62
  */
38
63
  export class Container {
39
- constructor() {
40
- this.children = [];
41
- }
64
+ children = [];
42
65
  addChild(component) {
43
66
  this.children.push(component);
44
67
  }
@@ -59,7 +82,10 @@ export class Container {
59
82
  render(width) {
60
83
  const lines = [];
61
84
  for (const child of this.children) {
62
- lines.push(...child.render(width));
85
+ const childLines = child.render(width);
86
+ for (const line of childLines) {
87
+ lines.push(line);
88
+ }
63
89
  }
64
90
  return lines;
65
91
  }
@@ -68,26 +94,32 @@ export class Container {
68
94
  * TUI - Main class for managing terminal UI with differential rendering
69
95
  */
70
96
  export class TUI extends Container {
97
+ terminal;
98
+ previousLines = [];
99
+ previousKittyImageIds = new Set();
100
+ previousWidth = 0;
101
+ previousHeight = 0;
102
+ focusedComponent = null;
103
+ inputListeners = new Set();
104
+ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
105
+ onDebug;
106
+ renderRequested = false;
107
+ renderTimer;
108
+ lastRenderAt = 0;
109
+ static MIN_RENDER_INTERVAL_MS = 16;
110
+ cursorRow = 0; // Logical cursor row (end of rendered content)
111
+ hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
112
+ showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
113
+ clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
114
+ maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
115
+ previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
116
+ fullRedrawCount = 0;
117
+ stopped = false;
118
+ // Overlay stack for modal components rendered on top of base content
119
+ focusOrderCounter = 0;
120
+ overlayStack = [];
71
121
  constructor(terminal, showHardwareCursor) {
72
122
  super();
73
- this.previousLines = [];
74
- this.previousWidth = 0;
75
- this.previousHeight = 0;
76
- this.focusedComponent = null;
77
- this.inputListeners = new Set();
78
- this.renderRequested = false;
79
- this.cursorRow = 0; // Logical cursor row (end of rendered content)
80
- this.hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
81
- this.inputBuffer = ""; // Buffer for parsing terminal responses
82
- this.cellSizeQueryPending = false;
83
- this.showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
84
- this.clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
85
- this.maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
86
- this.previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
87
- this.fullRedrawCount = 0;
88
- this.stopped = false;
89
- // Overlay stack for modal components rendered on top of base content
90
- this.overlayStack = [];
91
123
  this.terminal = terminal;
92
124
  if (showHardwareCursor !== undefined) {
93
125
  this.showHardwareCursor = showHardwareCursor;
@@ -135,10 +167,16 @@ export class TUI extends Container {
135
167
  * Returns a handle to control the overlay's visibility.
136
168
  */
137
169
  showOverlay(component, options) {
138
- const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
170
+ const entry = {
171
+ component,
172
+ options,
173
+ preFocus: this.focusedComponent,
174
+ hidden: false,
175
+ focusOrder: ++this.focusOrderCounter,
176
+ };
139
177
  this.overlayStack.push(entry);
140
178
  // Only focus if overlay is actually visible
141
- if (this.isOverlayVisible(entry)) {
179
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
142
180
  this.setFocus(component);
143
181
  }
144
182
  this.terminal.hideCursor();
@@ -173,13 +211,31 @@ export class TUI extends Container {
173
211
  }
174
212
  else {
175
213
  // Restore focus to this overlay when showing (if it's actually visible)
176
- if (this.isOverlayVisible(entry)) {
214
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
215
+ entry.focusOrder = ++this.focusOrderCounter;
177
216
  this.setFocus(component);
178
217
  }
179
218
  }
180
219
  this.requestRender();
181
220
  },
182
221
  isHidden: () => entry.hidden,
222
+ focus: () => {
223
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
224
+ return;
225
+ if (this.focusedComponent !== component) {
226
+ this.setFocus(component);
227
+ }
228
+ entry.focusOrder = ++this.focusOrderCounter;
229
+ this.requestRender();
230
+ },
231
+ unfocus: () => {
232
+ if (this.focusedComponent !== component)
233
+ return;
234
+ const topVisible = this.getTopmostVisibleOverlay();
235
+ this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
236
+ this.requestRender();
237
+ },
238
+ isFocused: () => this.focusedComponent === component,
183
239
  };
184
240
  }
185
241
  /** Hide the topmost overlay and restore previous focus. */
@@ -187,9 +243,11 @@ export class TUI extends Container {
187
243
  const overlay = this.overlayStack.pop();
188
244
  if (!overlay)
189
245
  return;
190
- // Find topmost visible overlay, or fall back to preFocus
191
- const topVisible = this.getTopmostVisibleOverlay();
192
- this.setFocus(topVisible?.component ?? overlay.preFocus);
246
+ if (this.focusedComponent === overlay.component) {
247
+ // Find topmost visible overlay, or fall back to preFocus
248
+ const topVisible = this.getTopmostVisibleOverlay();
249
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
250
+ }
193
251
  if (this.overlayStack.length === 0)
194
252
  this.terminal.hideCursor();
195
253
  this.requestRender();
@@ -207,9 +265,11 @@ export class TUI extends Container {
207
265
  }
208
266
  return true;
209
267
  }
210
- /** Find the topmost visible overlay, if any */
268
+ /** Find the topmost visible capturing overlay, if any */
211
269
  getTopmostVisibleOverlay() {
212
270
  for (let i = this.overlayStack.length - 1; i >= 0; i--) {
271
+ if (this.overlayStack[i].options?.nonCapturing)
272
+ continue;
213
273
  if (this.isOverlayVisible(this.overlayStack[i])) {
214
274
  return this.overlayStack[i];
215
275
  }
@@ -244,11 +304,14 @@ export class TUI extends Container {
244
304
  }
245
305
  // Query terminal for cell size in pixels: CSI 16 t
246
306
  // Response format: CSI 6 ; height ; width t
247
- this.cellSizeQueryPending = true;
248
307
  this.terminal.write("\x1b[16t");
249
308
  }
250
309
  stop() {
251
310
  this.stopped = true;
311
+ if (this.renderTimer) {
312
+ clearTimeout(this.renderTimer);
313
+ this.renderTimer = undefined;
314
+ }
252
315
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
253
316
  if (this.previousLines.length > 0) {
254
317
  const targetRow = this.previousLines.length; // Line after the last content
@@ -273,14 +336,44 @@ export class TUI extends Container {
273
336
  this.hardwareCursorRow = 0;
274
337
  this.maxLinesRendered = 0;
275
338
  this.previousViewportTop = 0;
339
+ if (this.renderTimer) {
340
+ clearTimeout(this.renderTimer);
341
+ this.renderTimer = undefined;
342
+ }
343
+ this.renderRequested = true;
344
+ process.nextTick(() => {
345
+ if (this.stopped || !this.renderRequested) {
346
+ return;
347
+ }
348
+ this.renderRequested = false;
349
+ this.lastRenderAt = performance.now();
350
+ this.doRender();
351
+ });
352
+ return;
276
353
  }
277
354
  if (this.renderRequested)
278
355
  return;
279
356
  this.renderRequested = true;
280
- process.nextTick(() => {
357
+ process.nextTick(() => this.scheduleRender());
358
+ }
359
+ scheduleRender() {
360
+ if (this.stopped || this.renderTimer || !this.renderRequested) {
361
+ return;
362
+ }
363
+ const elapsed = performance.now() - this.lastRenderAt;
364
+ const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
365
+ this.renderTimer = setTimeout(() => {
366
+ this.renderTimer = undefined;
367
+ if (this.stopped || !this.renderRequested) {
368
+ return;
369
+ }
281
370
  this.renderRequested = false;
371
+ this.lastRenderAt = performance.now();
282
372
  this.doRender();
283
- });
373
+ if (this.renderRequested) {
374
+ this.scheduleRender();
375
+ }
376
+ }, delay);
284
377
  }
285
378
  handleInput(data) {
286
379
  if (this.inputListeners.size > 0) {
@@ -299,13 +392,9 @@ export class TUI extends Container {
299
392
  }
300
393
  data = current;
301
394
  }
302
- // If we're waiting for cell size response, buffer input and parse
303
- if (this.cellSizeQueryPending) {
304
- this.inputBuffer += data;
305
- const filtered = this.parseCellSizeResponse();
306
- if (filtered.length === 0)
307
- return;
308
- data = filtered;
395
+ // Consume terminal cell size responses without blocking unrelated input.
396
+ if (this.consumeCellSizeResponse(data)) {
397
+ return;
309
398
  }
310
399
  // Global debug key handler (Shift+Ctrl+D)
311
400
  if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
@@ -337,41 +426,22 @@ export class TUI extends Container {
337
426
  this.requestRender();
338
427
  }
339
428
  }
340
- parseCellSizeResponse() {
429
+ consumeCellSizeResponse(data) {
341
430
  // Response format: ESC [ 6 ; height ; width t
342
- // Match the response pattern
343
- const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
344
- const match = this.inputBuffer.match(responsePattern);
345
- if (match) {
346
- const heightPx = parseInt(match[1], 10);
347
- const widthPx = parseInt(match[2], 10);
348
- if (heightPx > 0 && widthPx > 0) {
349
- setCellDimensions({ widthPx, heightPx });
350
- // Invalidate all components so images re-render with correct dimensions
351
- this.invalidate();
352
- this.requestRender();
353
- }
354
- // Remove the response from buffer
355
- this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
356
- this.cellSizeQueryPending = false;
357
- }
358
- // Check if we have a partial cell size response starting (wait for more data)
359
- // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
360
- const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
361
- if (partialCellSizePattern.test(this.inputBuffer)) {
362
- // Check if it's actually a complete different escape sequence (ends with a letter)
363
- // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
364
- const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
365
- if (!/[a-zA-Z~]/.test(lastChar)) {
366
- // Doesn't end with a terminator, might be incomplete - wait for more
367
- return "";
368
- }
369
- }
370
- // No cell size response found, return buffered data as user input
371
- const result = this.inputBuffer;
372
- this.inputBuffer = "";
373
- this.cellSizeQueryPending = false; // Give up waiting
374
- return result;
431
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
432
+ if (!match) {
433
+ return false;
434
+ }
435
+ const heightPx = parseInt(match[1], 10);
436
+ const widthPx = parseInt(match[2], 10);
437
+ if (heightPx <= 0 || widthPx <= 0) {
438
+ return true;
439
+ }
440
+ setCellDimensions({ widthPx, heightPx });
441
+ // Invalidate all components so images re-render with correct dimensions.
442
+ this.invalidate();
443
+ this.requestRender();
444
+ return true;
375
445
  }
376
446
  /**
377
447
  * Resolve overlay layout from options.
@@ -499,7 +569,7 @@ export class TUI extends Container {
499
569
  return marginLeft + Math.floor((availWidth - width) / 2);
500
570
  }
501
571
  }
502
- /** Composite all overlays into content lines (in stack order, later = on top). */
572
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
503
573
  compositeOverlays(lines, termWidth, termHeight) {
504
574
  if (this.overlayStack.length === 0)
505
575
  return lines;
@@ -507,10 +577,9 @@ export class TUI extends Container {
507
577
  // Pre-render all visible overlays and calculate positions
508
578
  const rendered = [];
509
579
  let minLinesNeeded = result.length;
510
- for (const entry of this.overlayStack) {
511
- // Skip invisible overlays (hidden or visible() returns false)
512
- if (!this.isOverlayVisible(entry))
513
- continue;
580
+ const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
581
+ visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
582
+ for (const entry of visibleEntries) {
514
583
  const { component, options } = entry;
515
584
  // Get layout with height=0 first to determine width and maxHeight
516
585
  // (width and maxHeight don't depend on overlay height)
@@ -526,16 +595,15 @@ export class TUI extends Container {
526
595
  rendered.push({ overlayLines, row, col, w: width });
527
596
  minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
528
597
  }
529
- // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
530
- // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
531
- const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
598
+ // Pad to at least terminal height so overlays have screen-relative positions.
599
+ // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
600
+ // inflation that pushed content into scrollback on terminal widen.
601
+ const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
532
602
  // Extend result with empty lines if content is too short for overlay placement or working area
533
603
  while (result.length < workingHeight) {
534
604
  result.push("");
535
605
  }
536
606
  const viewportStart = Math.max(0, workingHeight - termHeight);
537
- // Track which lines were modified for final verification
538
- const modifiedLines = new Set();
539
607
  // Composite each overlay
540
608
  for (const { overlayLines, row, col, w } of rendered) {
541
609
  for (let i = 0; i < overlayLines.length; i++) {
@@ -545,33 +613,59 @@ export class TUI extends Container {
545
613
  // (components should already respect width, but this ensures it)
546
614
  const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
547
615
  result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
548
- modifiedLines.add(idx);
549
616
  }
550
617
  }
551
618
  }
552
- // Final verification: ensure no composited line exceeds terminal width
553
- // This is a belt-and-suspenders safeguard - compositeLineAt should already
554
- // guarantee this, but we verify here to prevent crashes from any edge cases
555
- // Only check lines that were actually modified (optimization)
556
- for (const idx of modifiedLines) {
557
- const lineWidth = visibleWidth(result[idx]);
558
- if (lineWidth > termWidth) {
559
- result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
560
- }
561
- }
562
619
  return result;
563
620
  }
564
- static { this.SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; }
621
+ static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
565
622
  applyLineResets(lines) {
566
623
  const reset = TUI.SEGMENT_RESET;
567
624
  for (let i = 0; i < lines.length; i++) {
568
625
  const line = lines[i];
569
626
  if (!isImageLine(line)) {
570
- lines[i] = line + reset;
627
+ lines[i] = normalizeTerminalOutput(line) + reset;
571
628
  }
572
629
  }
573
630
  return lines;
574
631
  }
632
+ collectKittyImageIds(lines) {
633
+ const ids = new Set();
634
+ for (const line of lines) {
635
+ for (const id of extractKittyImageIds(line)) {
636
+ ids.add(id);
637
+ }
638
+ }
639
+ return ids;
640
+ }
641
+ deleteKittyImages(ids) {
642
+ let buffer = "";
643
+ for (const id of ids) {
644
+ buffer += deleteKittyImage(id);
645
+ }
646
+ return buffer;
647
+ }
648
+ expandLastChangedForKittyImages(firstChanged, lastChanged) {
649
+ let expandedLastChanged = lastChanged;
650
+ for (let i = firstChanged; i < this.previousLines.length; i++) {
651
+ if (extractKittyImageIds(this.previousLines[i]).length > 0) {
652
+ expandedLastChanged = Math.max(expandedLastChanged, i);
653
+ }
654
+ }
655
+ return expandedLastChanged;
656
+ }
657
+ deleteChangedKittyImages(firstChanged, lastChanged) {
658
+ if (firstChanged < 0 || lastChanged < firstChanged)
659
+ return "";
660
+ const ids = new Set();
661
+ const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
662
+ for (let i = firstChanged; i <= maxLine; i++) {
663
+ for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
664
+ ids.add(id);
665
+ }
666
+ }
667
+ return this.deleteKittyImages(ids);
668
+ }
575
669
  /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
576
670
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
577
671
  if (isImageLine(baseLine))
@@ -641,8 +735,11 @@ export class TUI extends Container {
641
735
  return;
642
736
  const width = this.terminal.columns;
643
737
  const height = this.terminal.rows;
644
- let viewportTop = Math.max(0, this.maxLinesRendered - height);
645
- let prevViewportTop = this.previousViewportTop;
738
+ const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
739
+ const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
740
+ const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
741
+ let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
742
+ let viewportTop = prevViewportTop;
646
743
  let hardwareCursorRow = this.hardwareCursorRow;
647
744
  const computeLineDiff = (targetRow) => {
648
745
  const currentScreenRow = hardwareCursorRow - prevViewportTop;
@@ -658,15 +755,14 @@ export class TUI extends Container {
658
755
  // Extract cursor position before applying line resets (marker must be found first)
659
756
  const cursorPos = this.extractCursorPosition(newLines, height);
660
757
  newLines = this.applyLineResets(newLines);
661
- // Width or height changed - need full re-render
662
- const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
663
- const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
664
758
  // Helper to clear scrollback and viewport and render all new lines
665
759
  const fullRender = (clear) => {
666
760
  this.fullRedrawCount += 1;
667
761
  let buffer = "\x1b[?2026h"; // Begin synchronized output
668
- if (clear)
669
- buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
762
+ if (clear) {
763
+ buffer += this.deleteKittyImages(this.previousKittyImageIds);
764
+ buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
765
+ }
670
766
  for (let i = 0; i < newLines.length; i++) {
671
767
  if (i > 0)
672
768
  buffer += "\r\n";
@@ -683,9 +779,11 @@ export class TUI extends Container {
683
779
  else {
684
780
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
685
781
  }
686
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
782
+ const bufferLength = Math.max(height, newLines.length);
783
+ this.previousViewportTop = Math.max(0, bufferLength - height);
687
784
  this.positionHardwareCursor(cursorPos, newLines.length);
688
785
  this.previousLines = newLines;
786
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
689
787
  this.previousWidth = width;
690
788
  this.previousHeight = height;
691
789
  };
@@ -703,9 +801,17 @@ export class TUI extends Container {
703
801
  fullRender(false);
704
802
  return;
705
803
  }
706
- // Width or height changed - full re-render
707
- if (widthChanged || heightChanged) {
708
- logRedraw(`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`);
804
+ // Width changes always need a full re-render because wrapping changes.
805
+ if (widthChanged) {
806
+ logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
807
+ fullRender(true);
808
+ return;
809
+ }
810
+ // Height changes normally need a full re-render to keep the visible viewport aligned,
811
+ // but Termux changes height when the software keyboard shows or hides.
812
+ // In that environment, a full redraw causes the entire history to replay on every toggle.
813
+ if (heightChanged && !isTermuxSession()) {
814
+ logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
709
815
  fullRender(true);
710
816
  return;
711
817
  }
@@ -738,11 +844,14 @@ export class TUI extends Container {
738
844
  }
739
845
  lastChanged = newLines.length - 1;
740
846
  }
847
+ if (firstChanged !== -1) {
848
+ lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
849
+ }
741
850
  const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
742
851
  // No changes - but still need to update hardware cursor position if it moved
743
852
  if (firstChanged === -1) {
744
853
  this.positionHardwareCursor(cursorPos, newLines.length);
745
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
854
+ this.previousViewportTop = prevViewportTop;
746
855
  this.previousHeight = height;
747
856
  return;
748
857
  }
@@ -750,8 +859,14 @@ export class TUI extends Container {
750
859
  if (firstChanged >= newLines.length) {
751
860
  if (this.previousLines.length > newLines.length) {
752
861
  let buffer = "\x1b[?2026h";
862
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
753
863
  // Move to end of new content (clamp to 0 for empty content)
754
864
  const targetRow = Math.max(0, newLines.length - 1);
865
+ if (targetRow < prevViewportTop) {
866
+ logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
867
+ fullRender(true);
868
+ return;
869
+ }
755
870
  const lineDiff = computeLineDiff(targetRow);
756
871
  if (lineDiff > 0)
757
872
  buffer += `\x1b[${lineDiff}B`;
@@ -783,23 +898,23 @@ export class TUI extends Container {
783
898
  }
784
899
  this.positionHardwareCursor(cursorPos, newLines.length);
785
900
  this.previousLines = newLines;
901
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
786
902
  this.previousWidth = width;
787
903
  this.previousHeight = height;
788
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
904
+ this.previousViewportTop = prevViewportTop;
789
905
  return;
790
906
  }
791
- // Check if firstChanged is above what was previously visible
792
- // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
793
- const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
794
- if (firstChanged < previousContentViewportTop) {
795
- // First change is above previous viewport - need full re-render
796
- logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
907
+ // Differential rendering can only touch what was actually visible.
908
+ // If the first changed line is above the previous viewport, we need a full redraw.
909
+ if (firstChanged < prevViewportTop) {
910
+ logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
797
911
  fullRender(true);
798
912
  return;
799
913
  }
800
914
  // Render from first changed line to end
801
915
  // Build buffer with all updates wrapped in synchronized output
802
916
  let buffer = "\x1b[?2026h"; // Begin synchronized output
917
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
803
918
  const prevViewportBottom = prevViewportTop + height - 1;
804
919
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
805
920
  if (moveTargetRow > prevViewportBottom) {
@@ -915,10 +1030,11 @@ export class TUI extends Container {
915
1030
  this.hardwareCursorRow = finalCursorRow;
916
1031
  // Track terminal's working area (grows but doesn't shrink unless cleared)
917
1032
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
918
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1033
+ this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
919
1034
  // Position hardware cursor for IME
920
1035
  this.positionHardwareCursor(cursorPos, newLines.length);
921
1036
  this.previousLines = newLines;
1037
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
922
1038
  this.previousWidth = width;
923
1039
  this.previousHeight = height;
924
1040
  }