@draht/tui 2026.5.12 → 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.
- package/dist/autocomplete.d.ts +5 -1
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +16 -7
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts +1 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +14 -4
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +233 -178
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts +2 -2
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +31 -11
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +7 -55
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +17 -7
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +34 -14
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +8 -7
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +87 -102
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +1 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/text.d.ts +1 -1
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/editor-component.d.ts +2 -2
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/editor-component.js.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +3 -0
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +22 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +19 -5
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +82 -33
- package/dist/keys.js.map +1 -1
- package/dist/native-modifiers.d.ts +3 -0
- package/dist/native-modifiers.d.ts.map +1 -0
- package/dist/native-modifiers.js +53 -0
- package/dist/native-modifiers.js.map +1 -0
- package/dist/stdin-buffer.d.ts +2 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +48 -4
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +23 -1
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +94 -16
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +36 -10
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +212 -64
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +25 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +245 -30
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +8 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +142 -42
- package/dist/utils.js.map +1 -1
- package/dist/word-navigation.d.ts +25 -0
- package/dist/word-navigation.d.ts.map +1 -0
- package/dist/word-navigation.js +96 -0
- package/dist/word-navigation.js.map +1 -0
- 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
|
-
|
|
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 =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
this.
|
|
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.
|
|
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
|
|
396
|
+
/** Find the visual-frontmost visible capturing overlay, if any */
|
|
247
397
|
getTopmostVisibleOverlay() {
|
|
248
|
-
|
|
249
|
-
|
|
398
|
+
let topmost;
|
|
399
|
+
for (const overlay of this.overlayStack) {
|
|
400
|
+
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
|
|
250
401
|
continue;
|
|
251
|
-
if (
|
|
252
|
-
|
|
402
|
+
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
|
403
|
+
topmost = overlay;
|
|
253
404
|
}
|
|
254
405
|
}
|
|
255
|
-
return
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
|
|
826
|
-
|
|
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
|
}
|