@oh-my-pi/pi-tui 18.2.0 → 18.2.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.
- package/CHANGELOG.md +20 -0
- package/dist/types/autocomplete.d.ts +2 -1
- package/dist/types/components/editor.d.ts +10 -0
- package/dist/types/components/image.d.ts +49 -7
- package/dist/types/terminal-capabilities.d.ts +18 -0
- package/dist/types/terminal-multiplexer.d.ts +17 -1
- package/dist/types/terminal.d.ts +17 -2
- package/package.json +3 -3
- package/src/autocomplete.ts +48 -19
- package/src/components/editor.ts +42 -1
- package/src/components/image.ts +244 -45
- package/src/terminal-capabilities.ts +52 -4
- package/src/terminal-multiplexer.ts +31 -10
- package/src/terminal.ts +25 -2
- package/src/tui.ts +149 -44
package/src/components/image.ts
CHANGED
|
@@ -57,6 +57,48 @@ interface PlacementEmitState {
|
|
|
57
57
|
*/
|
|
58
58
|
cellsArchived: boolean;
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/** A surface the renderer paints frames on. */
|
|
62
|
+
type Surface = "screen" | "alt";
|
|
63
|
+
const SURFACES: readonly Surface[] = ["screen", "alt"];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The live/text split of one drawing surface. The normal screen and the
|
|
67
|
+
* alternate buffer hold separate frames with separate display orders, so each
|
|
68
|
+
* carries its own thresholds: a modal's split describes the modal, and applying
|
|
69
|
+
* it to the transcript would demote — and purge — images the modal never showed.
|
|
70
|
+
*/
|
|
71
|
+
interface SurfaceSplit {
|
|
72
|
+
/**
|
|
73
|
+
* Suppress threshold reflected in the frame currently on this surface: images
|
|
74
|
+
* at display indices `[0, onTerminal)` are shown as text there.
|
|
75
|
+
*/
|
|
76
|
+
onTerminal: number;
|
|
77
|
+
/** Suppress threshold the current/next render of this surface should apply. */
|
|
78
|
+
planned: number;
|
|
79
|
+
/** Images the last full pass on this surface observed. */
|
|
80
|
+
lastTotal: number;
|
|
81
|
+
/**
|
|
82
|
+
* Image ids shown as text in the frame currently on this surface: the
|
|
83
|
+
* display-order prefix [0, onTerminal) of its last full pass, snapshotted by
|
|
84
|
+
* id so a partial pass reproduces the on-screen live/text split without a
|
|
85
|
+
* full, correctly-ordered walk.
|
|
86
|
+
*/
|
|
87
|
+
suppressedIds: Set<number>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function newSurfaceSplit(): SurfaceSplit {
|
|
91
|
+
return { onTerminal: 0, planned: 0, lastTotal: 0, suppressedIds: new Set() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Return a split to "nothing has been painted on this surface yet", in place. */
|
|
95
|
+
function resetSurfaceSplit(split: SurfaceSplit): void {
|
|
96
|
+
split.onTerminal = 0;
|
|
97
|
+
split.planned = 0;
|
|
98
|
+
split.lastTotal = 0;
|
|
99
|
+
split.suppressedIds = new Set();
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
61
103
|
function nextImageIdSeed(): number {
|
|
62
104
|
nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
|
|
@@ -76,6 +118,16 @@ function nextImageIdSeed(): number {
|
|
|
76
118
|
* rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
|
|
77
119
|
* reports display order via {@link observe}; when that reveals a stricter split,
|
|
78
120
|
* the TUI repeats the pass before emitting its terminal frame.
|
|
121
|
+
* Retired frames no longer observe their images, so the resident store is also
|
|
122
|
+
* bounded across passes. Evicting retired graphics removes their scrollback
|
|
123
|
+
* placements; a later replay can render or demote those images again.
|
|
124
|
+
*
|
|
125
|
+
* `cap` bounds one surface's live images, not the terminal's whole store. A
|
|
126
|
+
* fullscreen overlay's frame and the normal screen standing behind it are both
|
|
127
|
+
* on the terminal, and neither may delete the other's graphics — see
|
|
128
|
+
* {@link limitResidentImages} — so while a modal is up the store legitimately
|
|
129
|
+
* holds up to `cap` per surface. Read `cap` as "how many images one frame shows
|
|
130
|
+
* as graphics", not as a hard residency ceiling.
|
|
79
131
|
*
|
|
80
132
|
* `cap <= 0` disables budgeting: every image stays a live graphic.
|
|
81
133
|
*/
|
|
@@ -90,19 +142,29 @@ export class ImageBudget {
|
|
|
90
142
|
/** Per-id suppression decision from the first observation in this pass. */
|
|
91
143
|
#passSuppression = new Map<number, boolean>();
|
|
92
144
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
145
|
+
* Display index each observation was decided at, so {@link #passShowsLive}
|
|
146
|
+
* can re-check it against a reconciled threshold without scanning
|
|
147
|
+
* {@link #passIds} once per image.
|
|
95
148
|
*/
|
|
96
|
-
#
|
|
97
|
-
/**
|
|
98
|
-
#
|
|
149
|
+
#passIndex = new Map<number, number>();
|
|
150
|
+
/** Live/text split of the normal screen. */
|
|
151
|
+
#screenSplit = newSurfaceSplit();
|
|
152
|
+
/** Live/text split of the alternate buffer (fullscreen overlay, resize borrow). */
|
|
153
|
+
#altSplit = newSurfaceSplit();
|
|
154
|
+
/** The split the in-flight pass reads and writes; selected by {@link beginPass}. */
|
|
155
|
+
#split = this.#screenSplit;
|
|
99
156
|
/**
|
|
100
157
|
* True while the in-flight pass applies a stricter threshold than the terminal
|
|
101
158
|
* shows — the demotion frame that must purge graphics and fully repaint.
|
|
102
159
|
*/
|
|
103
160
|
#applyingReset = false;
|
|
104
|
-
#lastTotal = 0;
|
|
105
161
|
#purgeIds: number[] = [];
|
|
162
|
+
/**
|
|
163
|
+
* Deletions that belong to a pending destructive reset, kept out of
|
|
164
|
+
* {@link #purgeIds} so only that reset's own repaint can emit them. See
|
|
165
|
+
* {@link forgetTransmitted}.
|
|
166
|
+
*/
|
|
167
|
+
#resetPurgeIds: number[] = [];
|
|
106
168
|
/** Image ids whose data is believed to be loaded in the terminal's store. */
|
|
107
169
|
#transmitted = new Set<number>();
|
|
108
170
|
/** Transmit sequences (full base64) to write once, before this frame's placements. */
|
|
@@ -112,11 +174,15 @@ export class ImageBudget {
|
|
|
112
174
|
// tail, bottom-up. Such a pass cannot derive display order from observe()
|
|
113
175
|
// call order, so its suppression decisions replay the committed split below.
|
|
114
176
|
#stablePass = false;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
177
|
+
/** The surface the in-flight pass composes for; selected by {@link beginPass}. */
|
|
178
|
+
#surface: Surface = "screen";
|
|
179
|
+
/**
|
|
180
|
+
* Image ids rendered as live graphics by the frame standing on each surface.
|
|
181
|
+
* A pass walks one surface, so the other's entry is what stops {@link #retire}
|
|
182
|
+
* from deleting a graphic that is merely out of view — the transcript behind a
|
|
183
|
+
* fullscreen overlay keeps its placements and is restored from cache on exit.
|
|
184
|
+
*/
|
|
185
|
+
#liveIds: Record<Surface, Set<number>> = { screen: new Set(), alt: new Set() };
|
|
120
186
|
/**
|
|
121
187
|
* Per-image direct-placement emit state: source pixel geometry for the
|
|
122
188
|
* renderer's clipped source rectangle, plus the placement-id epoch (see
|
|
@@ -155,7 +221,7 @@ export class ImageBudget {
|
|
|
155
221
|
const next = normalizeCap(cap);
|
|
156
222
|
if (next === this.#cap) return;
|
|
157
223
|
this.#cap = next;
|
|
158
|
-
this.#reconcile(this.#lastTotal);
|
|
224
|
+
if (!this.#reconcile(this.#split.lastTotal)) this.#requestRender();
|
|
159
225
|
}
|
|
160
226
|
|
|
161
227
|
/**
|
|
@@ -178,18 +244,51 @@ export class ImageBudget {
|
|
|
178
244
|
return id;
|
|
179
245
|
}
|
|
180
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Start an alternate-buffer lifecycle. Call once per `?1049h`, before the
|
|
249
|
+
* first pass of the fullscreen overlay or resize borrow that owns the buffer.
|
|
250
|
+
*
|
|
251
|
+
* The alt split is a claim about the frame standing on that surface, and
|
|
252
|
+
* `?1049h` hands over a cleared one: the previous occupant's threshold would
|
|
253
|
+
* suppress this buffer's leading images against a frame that no longer
|
|
254
|
+
* exists, painting them as text until a corrective render lands. Passes
|
|
255
|
+
* *within* one lifecycle must keep sharing the split — that is what lets an
|
|
256
|
+
* over-cap discovery pass converge before the frame is emitted.
|
|
257
|
+
*/
|
|
258
|
+
beginAltScreenLifecycle(): void {
|
|
259
|
+
resetSurfaceSplit(this.#altSplit);
|
|
260
|
+
this.#liveIds.alt.clear();
|
|
261
|
+
}
|
|
262
|
+
|
|
181
263
|
/**
|
|
182
264
|
* Begin a render pass. Called by the renderer before composing the frame.
|
|
183
265
|
* Pass `stable: true` for a partial/throwaway pass that does not walk the
|
|
184
266
|
* whole tree in display order (the resize viewport fast path): {@link observe}
|
|
185
267
|
* then replays the last committed per-id decision instead of one derived from
|
|
186
268
|
* call order, and the pass must NOT be closed with {@link endPass}.
|
|
269
|
+
*
|
|
270
|
+
* Pass `altScreen: true` when the frame is painted on the alternate buffer
|
|
271
|
+
* (fullscreen overlay, resize borrow). The pass then reads and writes that
|
|
272
|
+
* surface's own {@link SurfaceSplit} and its live set adds to the recorded
|
|
273
|
+
* normal-screen one instead of replacing it, so a modal's threshold never
|
|
274
|
+
* reaches the transcript standing behind it.
|
|
187
275
|
*/
|
|
188
|
-
beginPass(stable = false): void {
|
|
276
|
+
beginPass(stable = false, altScreen = false): void {
|
|
189
277
|
this.#passIds.length = 0;
|
|
190
278
|
this.#passSuppression.clear();
|
|
279
|
+
this.#passIndex.clear();
|
|
191
280
|
this.#stablePass = stable;
|
|
192
|
-
this.#
|
|
281
|
+
this.#surface = altScreen ? "alt" : "screen";
|
|
282
|
+
this.#split = altScreen ? this.#altSplit : this.#screenSplit;
|
|
283
|
+
// Composing for the screen means the alternate buffer holds no frame to
|
|
284
|
+
// protect: live renders reach the normal-screen paths only when
|
|
285
|
+
// TUI#doRender has ruled out both alt-buffer owners, and the one caller
|
|
286
|
+
// outside that dispatch — the shutdown history flush — writes `?1049l`
|
|
287
|
+
// first. Note that leaving alt mode is not the same as unstacking a
|
|
288
|
+
// fullscreen overlay: the flush must exclude one that is still stacked
|
|
289
|
+
// from the pass itself, which is that caller's job, not this line's.
|
|
290
|
+
if (!altScreen) this.#liveIds.alt.clear();
|
|
291
|
+
this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
|
|
193
292
|
}
|
|
194
293
|
|
|
195
294
|
/**
|
|
@@ -198,22 +297,23 @@ export class ImageBudget {
|
|
|
198
297
|
* on a cache hit, so the image keeps its display-order slot.
|
|
199
298
|
*
|
|
200
299
|
* During a `stable` pass ({@link beginPass}) the call order and visible subset
|
|
201
|
-
* are not authoritative, so the decision is the committed
|
|
202
|
-
*
|
|
300
|
+
* are not authoritative, so the decision is the surface's committed
|
|
301
|
+
* on-terminal split, keyed by id — order- and partiality-independent.
|
|
203
302
|
*/
|
|
204
303
|
observe(imageId: number): boolean {
|
|
205
304
|
const existing = this.#passSuppression.get(imageId);
|
|
206
305
|
if (existing !== undefined) return existing;
|
|
207
306
|
if (this.#stablePass) {
|
|
208
|
-
const suppressed = this.#cap > 0 && this.#suppressedIds.has(imageId);
|
|
307
|
+
const suppressed = this.#cap > 0 && this.#split.suppressedIds.has(imageId);
|
|
209
308
|
this.#passSuppression.set(imageId, suppressed);
|
|
210
309
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
211
310
|
return suppressed;
|
|
212
311
|
}
|
|
213
312
|
const index = this.#passIds.length;
|
|
214
313
|
this.#passIds.push(imageId);
|
|
215
|
-
const suppressed = this.#cap > 0 && index < this.#planned;
|
|
314
|
+
const suppressed = this.#cap > 0 && index < this.#split.planned;
|
|
216
315
|
this.#passSuppression.set(imageId, suppressed);
|
|
316
|
+
this.#passIndex.set(imageId, index);
|
|
217
317
|
if (suppressed) this.#forgetKeyForId(imageId);
|
|
218
318
|
return suppressed;
|
|
219
319
|
}
|
|
@@ -224,29 +324,101 @@ export class ImageBudget {
|
|
|
224
324
|
*/
|
|
225
325
|
endPass(): boolean {
|
|
226
326
|
const total = this.#passIds.length;
|
|
227
|
-
|
|
327
|
+
const split = this.#split;
|
|
328
|
+
split.lastTotal = total;
|
|
228
329
|
if (this.#applyingReset) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (!this.#pendingTransmits.delete(id)) this.#purgeIds.push(id);
|
|
234
|
-
this.#transmitted.delete(id);
|
|
235
|
-
this.#deletePlacementState(id);
|
|
236
|
-
this.#forgetKeyForId(id);
|
|
330
|
+
// This frame replaced these with their text fallback, so their graphics
|
|
331
|
+
// are retired as far as this surface is concerned.
|
|
332
|
+
for (let i = split.onTerminal; i < split.planned && i < total; i++) {
|
|
333
|
+
this.#retire(this.#passIds[i]);
|
|
237
334
|
}
|
|
238
|
-
|
|
335
|
+
split.onTerminal = split.planned;
|
|
239
336
|
this.#applyingReset = false;
|
|
240
337
|
}
|
|
241
338
|
const retry = this.#reconcile(total);
|
|
242
339
|
// Snapshot the committed display-order suppression by id: the prefix
|
|
243
|
-
// [0,
|
|
340
|
+
// [0, onTerminal) is what this surface currently shows as text. Partial
|
|
244
341
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
245
342
|
// from a reversed, tail-only walk.
|
|
246
|
-
|
|
343
|
+
split.suppressedIds = new Set(this.#passIds.slice(0, split.onTerminal));
|
|
247
344
|
return retry;
|
|
248
345
|
}
|
|
249
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Bound the terminal's image store to `cap`. Demotion ({@link endPass}) already
|
|
349
|
+
* retires the graphics this frame replaced with text; this sweeps the ones no
|
|
350
|
+
* frame shows any more — images the pass simply stopped observing.
|
|
351
|
+
*
|
|
352
|
+
* Also records what this frame leaves standing on its surface, which is how
|
|
353
|
+
* the next pass on the *other* surface knows what it may not destroy.
|
|
354
|
+
*/
|
|
355
|
+
limitResidentImages(): void {
|
|
356
|
+
this.#liveIds[this.#surface] = new Set(this.#passIds.filter(id => this.#passShowsLive(id)));
|
|
357
|
+
if (this.#cap <= 0 || this.#transmitted.size <= this.#cap) return;
|
|
358
|
+
for (const id of this.#transmitted) {
|
|
359
|
+
if (this.#transmitted.size <= this.#cap) break;
|
|
360
|
+
this.#retire(id);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether this pass leaves `imageId` on its surface as a live graphic.
|
|
366
|
+
*
|
|
367
|
+
* Not simply "was not suppressed". A pass decides suppression from the
|
|
368
|
+
* threshold standing at {@link beginPass}, and {@link endPass} may then
|
|
369
|
+
* reconcile that threshold *downwards* — the frame is emitted with a text
|
|
370
|
+
* fallback the very next frame will replace with the graphic again. Reading
|
|
371
|
+
* such a decision as retirement would delete an image the surface is about to
|
|
372
|
+
* show, and `d=I` takes placements no repaint can restore. So a suppression
|
|
373
|
+
* the reconcile has since undercut counts as live.
|
|
374
|
+
*/
|
|
375
|
+
#passShowsLive(imageId: number): boolean {
|
|
376
|
+
const suppressed = this.#passSuppression.get(imageId);
|
|
377
|
+
if (suppressed === undefined) return false;
|
|
378
|
+
if (!suppressed) return true;
|
|
379
|
+
// Absent (a `stable` pass replays the committed split rather than deriving
|
|
380
|
+
// an order) counts as live, so an unknown decision never authorises a
|
|
381
|
+
// delete.
|
|
382
|
+
const index = this.#passIndex.get(imageId);
|
|
383
|
+
return index === undefined || index >= this.#split.planned;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Drop `imageId` from the terminal's image store: queue its `d=I` (or cancel
|
|
388
|
+
* a transmit that never went out) and forget its placement ledger and key.
|
|
389
|
+
*
|
|
390
|
+
* The single gate on every destruction path. `d=I` removes an image's
|
|
391
|
+
* placements everywhere, scrollback included, and a frame diff only rewrites
|
|
392
|
+
* rows whose text changed — so a graphic some standing frame still shows
|
|
393
|
+
* cannot be repaired once deleted, and must never be a candidate. Refuses
|
|
394
|
+
* when the in-flight pass renders the image live, or when the frame on any
|
|
395
|
+
* surface this pass is not repainting does. Returns whether it was retired.
|
|
396
|
+
*/
|
|
397
|
+
#retire(imageId: number): boolean {
|
|
398
|
+
if (this.#passShowsLive(imageId)) return false;
|
|
399
|
+
for (const surface of SURFACES) {
|
|
400
|
+
if (surface !== this.#surface && this.#liveIds[surface].has(imageId)) return false;
|
|
401
|
+
}
|
|
402
|
+
// A transmit queued by a discarded discovery pass never reached the
|
|
403
|
+
// terminal, so cancel it instead of transmitting then purging.
|
|
404
|
+
if (!this.#pendingTransmits.delete(imageId)) this.#purgeIds.push(imageId);
|
|
405
|
+
this.#transmitted.delete(imageId);
|
|
406
|
+
this.#deletePlacementState(imageId);
|
|
407
|
+
this.#forgetKeyForId(imageId);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Image ids a destructive reset must delete explicitly, alongside its `d=A`.
|
|
413
|
+
* Emit only from that reset's repaint; clears the queue.
|
|
414
|
+
*/
|
|
415
|
+
takeResetPurgeIds(): readonly number[] {
|
|
416
|
+
if (this.#resetPurgeIds.length === 0) return EMPTY_IDS;
|
|
417
|
+
const ids = this.#resetPurgeIds;
|
|
418
|
+
this.#resetPurgeIds = [];
|
|
419
|
+
return ids;
|
|
420
|
+
}
|
|
421
|
+
|
|
250
422
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
251
423
|
takePurgeIds(): readonly number[] {
|
|
252
424
|
if (this.#purgeIds.length === 0) return EMPTY_IDS;
|
|
@@ -261,11 +433,13 @@ export class ImageBudget {
|
|
|
261
433
|
const ids = [...this.#transmitted];
|
|
262
434
|
this.#transmitted.clear();
|
|
263
435
|
this.#purgeIds = [];
|
|
436
|
+
this.#resetPurgeIds = [];
|
|
264
437
|
this.#pendingTransmits.clear();
|
|
265
438
|
this.#keyToId.clear();
|
|
266
439
|
this.#idToKey.clear();
|
|
267
440
|
this.#placementState.clear();
|
|
268
441
|
this.#watchedPlacements.clear();
|
|
442
|
+
for (const surface of SURFACES) this.#liveIds[surface].clear();
|
|
269
443
|
return ids;
|
|
270
444
|
}
|
|
271
445
|
|
|
@@ -407,18 +581,17 @@ export class ImageBudget {
|
|
|
407
581
|
}
|
|
408
582
|
|
|
409
583
|
/**
|
|
410
|
-
* True when the budget has nothing in flight: no live images
|
|
411
|
-
* the last pass, no queued transmits, no pending purges, and no
|
|
412
|
-
* threshold left to apply. A component-scoped frame may skip the
|
|
413
|
-
* pass only then — a partial tree walk would under-count display order.
|
|
584
|
+
* True when the budget has nothing in flight on either surface: no live images
|
|
585
|
+
* observed on the last pass, no queued transmits, no pending purges, and no
|
|
586
|
+
* stricter threshold left to apply. A component-scoped frame may skip the
|
|
587
|
+
* observe pass only then — a partial tree walk would under-count display order.
|
|
414
588
|
*/
|
|
415
589
|
get quiescent(): boolean {
|
|
416
|
-
return
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
);
|
|
590
|
+
if (this.#pendingTransmits.size > 0 || this.#purgeIds.length > 0) return false;
|
|
591
|
+
for (const split of [this.#screenSplit, this.#altSplit]) {
|
|
592
|
+
if (split.lastTotal !== 0 || split.planned !== split.onTerminal) return false;
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
422
595
|
}
|
|
423
596
|
|
|
424
597
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
@@ -439,11 +612,36 @@ export class ImageBudget {
|
|
|
439
612
|
*/
|
|
440
613
|
forgetTransmitted(): void {
|
|
441
614
|
if (this.#transmitted.size === 0 && this.#pendingTransmits.size === 0) return;
|
|
615
|
+
for (const id of this.#transmitted) {
|
|
616
|
+
if (!this.#pendingTransmits.has(id)) this.#resetPurgeIds.push(id);
|
|
617
|
+
}
|
|
618
|
+
// The ids go to #resetPurgeIds, drained only by the destructive repaint
|
|
619
|
+
// itself — never to #purgeIds, which any frame drains. That is how a
|
|
620
|
+
// deletion used to ride out on an alternate-buffer frame and leave the
|
|
621
|
+
// normal screen blank with no repaint left to restore it.
|
|
622
|
+
//
|
|
623
|
+
// `d=A` alone is not enough to skip these: Kitty excludes *virtual*
|
|
624
|
+
// placements from it, and erasing placeholder text does not remove the
|
|
625
|
+
// prototype either. Forgetting drops the id from tracking, so without an
|
|
626
|
+
// explicit `d=I` no later sweep can ever find that placement again.
|
|
442
627
|
this.#transmitted.clear();
|
|
443
628
|
this.#pendingTransmits.clear();
|
|
444
629
|
}
|
|
445
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Release `id`'s stable key so a component recreated under it gets a fresh id
|
|
633
|
+
* — but only once the terminal no longer holds `id`'s data, because a key must
|
|
634
|
+
* never resolve to an id whose graphic is gone.
|
|
635
|
+
*
|
|
636
|
+
* Key lifetime follows residency, not the live/text split. The two usually
|
|
637
|
+
* agree: an image shown as text has had its graphic purged. They diverge when
|
|
638
|
+
* {@link #retire} refuses, which leaves a suppressed image resident on another
|
|
639
|
+
* surface — and releasing a resident id's key orphans it. The recreation mints
|
|
640
|
+
* a new id, nothing observes the old one again, and the next pass retires it,
|
|
641
|
+
* deleting every placement it ever made including scrollback copies.
|
|
642
|
+
*/
|
|
446
643
|
#forgetKeyForId(id: number): void {
|
|
644
|
+
if (this.#transmitted.has(id)) return;
|
|
447
645
|
const key = this.#idToKey.get(id);
|
|
448
646
|
if (key === undefined) return;
|
|
449
647
|
this.#idToKey.delete(id);
|
|
@@ -451,21 +649,22 @@ export class ImageBudget {
|
|
|
451
649
|
}
|
|
452
650
|
|
|
453
651
|
#reconcile(total: number): boolean {
|
|
652
|
+
const split = this.#split;
|
|
454
653
|
const desired = this.#cap > 0 ? Math.max(0, total - this.#cap) : 0;
|
|
455
|
-
if (desired ===
|
|
654
|
+
if (desired === split.planned) {
|
|
456
655
|
// Budget relaxed without a stricter frame (cap raised or images
|
|
457
656
|
// removed): surviving graphics are untouched and re-exposed rows
|
|
458
657
|
// repaint normally, so just track the looser threshold.
|
|
459
|
-
if (
|
|
658
|
+
if (split.planned < split.onTerminal) split.onTerminal = split.planned;
|
|
460
659
|
return false;
|
|
461
660
|
}
|
|
462
|
-
const retry = desired >
|
|
463
|
-
|
|
661
|
+
const retry = desired > split.onTerminal;
|
|
662
|
+
split.planned = desired;
|
|
464
663
|
// More images must be demoted than the terminal shows: schedule the purge +
|
|
465
664
|
// full-redraw frame. Fewer: no ghosts to clear, so just catch the tracking
|
|
466
665
|
// up — a normal repaint re-exposes the un-demoted images. Either way a
|
|
467
666
|
// render is needed to apply the new threshold.
|
|
468
|
-
if (desired <=
|
|
667
|
+
if (desired <= split.onTerminal) split.onTerminal = desired;
|
|
469
668
|
this.#requestRender();
|
|
470
669
|
return retry;
|
|
471
670
|
}
|
|
@@ -325,9 +325,11 @@ export function isWindowsTerminalPreviewSixelSupported(
|
|
|
325
325
|
env: NodeJS.ProcessEnv = Bun.env,
|
|
326
326
|
platform: NodeJS.Platform = process.platform,
|
|
327
327
|
): boolean {
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
if (
|
|
329
|
+
platform !== "win32" ||
|
|
330
|
+
!env.WT_SESSION ||
|
|
331
|
+
(env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal")
|
|
332
|
+
) {
|
|
331
333
|
return false;
|
|
332
334
|
}
|
|
333
335
|
const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
|
|
@@ -386,7 +388,7 @@ export function shouldEnableSynchronizedOutputByDefault(
|
|
|
386
388
|
if (override !== null) return override;
|
|
387
389
|
|
|
388
390
|
if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
|
|
389
|
-
if (env.WT_SESSION) return true;
|
|
391
|
+
if (env.WT_SESSION && (!env.TERM_PROGRAM || env.TERM_PROGRAM.toLowerCase() === "windows_terminal")) return true;
|
|
390
392
|
if (isInsideHerdr(env)) return true;
|
|
391
393
|
|
|
392
394
|
// Risky multiplexers start off even when an inner terminal id leaks through:
|
|
@@ -442,6 +444,45 @@ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS.
|
|
|
442
444
|
}
|
|
443
445
|
return true;
|
|
444
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Whether the terminal implements colon-subparameter SGR styled underlines —
|
|
449
|
+
* `CSI 4 : 3 m` (curly) plus `CSI 58` / `CSI 59` underline color — as opposed to
|
|
450
|
+
* only the legacy `CSI 4 m` / `CSI 24 m` on/off underline.
|
|
451
|
+
*
|
|
452
|
+
* This is an underline-style capability, not a color depth, so it is keyed on
|
|
453
|
+
* the detected terminal, never on `TERM`/`COLORTERM`. kitty, Ghostty, WezTerm,
|
|
454
|
+
* and iTerm2 (>= 3.5) implement the full pair. Apple Terminal does NOT: it
|
|
455
|
+
* renders `CSI 4 : 0 m` (the reset half) as a solid black background that
|
|
456
|
+
* persists to end of line, and ignores SGR 58/59 — so it, along with every
|
|
457
|
+
* other unproven terminal, gets the flat underline instead. Disabled under any
|
|
458
|
+
* multiplexer: GNU screen and older tmux drop colon-form SGR, and the outer
|
|
459
|
+
* terminal's id leaks into the session env, so a proven id is not proof the
|
|
460
|
+
* bytes survive — the same reason DECCARA and synchronized output gate on it.
|
|
461
|
+
*/
|
|
462
|
+
export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
463
|
+
// A multiplexer in the path (GNU screen, older tmux) does not forward the
|
|
464
|
+
// colon-form underline, yet the outer terminal's id leaks through the session
|
|
465
|
+
// env, so the switch below would otherwise trust an unreachable capability.
|
|
466
|
+
if (isInsideTerminalMultiplexer(env)) return false;
|
|
467
|
+
switch (terminalId) {
|
|
468
|
+
case "kitty":
|
|
469
|
+
case "ghostty":
|
|
470
|
+
case "wezterm":
|
|
471
|
+
return true;
|
|
472
|
+
case "iterm2": {
|
|
473
|
+
// The full curly-and-colored pair did not ship together until iTerm2 3.5
|
|
474
|
+
// (curly first targeted 3.3.12; SGR 58/59 underline color was beta,
|
|
475
|
+
// expected for 3.5), so 3.0–3.4 would receive the colon reset they cannot
|
|
476
|
+
// render. Enable only on a confirmed major.minor >= 3.5; an absent or
|
|
477
|
+
// unparseable version keeps the flat fallback so only proven terminals
|
|
478
|
+
// get the colon form.
|
|
479
|
+
const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
|
|
480
|
+
return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
|
|
481
|
+
}
|
|
482
|
+
default:
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
445
486
|
/**
|
|
446
487
|
* Resolve an explicit user override for OSC 8 hyperlinks. Returns `false` for
|
|
447
488
|
* an opt-out, `true` for a force-on, or `null` when the user has expressed no
|
|
@@ -689,6 +730,8 @@ export interface RuntimeTerminal extends TerminalInfo {
|
|
|
689
730
|
supportsScreenToScrollback: boolean;
|
|
690
731
|
/** Whether OSC 66 text sizing is currently enabled. */
|
|
691
732
|
textSizing: boolean;
|
|
733
|
+
/** Whether the terminal implements colon-subparameter styled underlines (curly + colored). */
|
|
734
|
+
styledUnderlines: boolean;
|
|
692
735
|
}
|
|
693
736
|
|
|
694
737
|
export const TERMINAL: RuntimeTerminal = (() => {
|
|
@@ -715,6 +758,11 @@ export const TERMINAL: RuntimeTerminal = (() => {
|
|
|
715
758
|
// ignores DECCARA) exercises the padded-string fallback. Integration tests opt
|
|
716
759
|
// in explicitly through setTerminalDeccara.
|
|
717
760
|
resolved.deccara = detectRectangularSgrSupport(resolved.id, Bun.env) && !isBunTestRuntime();
|
|
761
|
+
// Styled-underline capability: colon-form curly underline + SGR 58/59 color.
|
|
762
|
+
// Keyed on the detected terminal (an underline-style capability, not a color
|
|
763
|
+
// depth), so Apple Terminal and other unproven hosts fall back to the flat
|
|
764
|
+
// CSI 4 m / CSI 24 m underline the typo renderer needs to avoid black bars.
|
|
765
|
+
resolved.styledUnderlines = detectStyledUnderlineSupport(resolved.id, Bun.env);
|
|
718
766
|
return resolved;
|
|
719
767
|
})();
|
|
720
768
|
|
|
@@ -10,15 +10,36 @@ export function isInsideHerdr(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
|
10
10
|
return false;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
/** Terminal multiplexers omp recognizes as owning the screen grid. */
|
|
14
|
+
export type TerminalMultiplexer = "herdr" | "tmux" | "screen" | "zellij" | "cmux" | "wmux";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Classify which terminal multiplexer owns the current screen grid, or `null`
|
|
18
|
+
* for a direct terminal. Single source of truth for both the render-path gate
|
|
19
|
+
* (`isInsideTerminalMultiplexer`) and the debug snapshot label.
|
|
20
|
+
*
|
|
21
|
+
* TMUX/STY/ZELLIJ, Herdr, and the CMUX/WMUX workspace/surface/remote-transport
|
|
22
|
+
* markers are authoritative session signals. TERM can also survive when those
|
|
23
|
+
* are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
|
|
24
|
+
* use CMUX_SOCKET_PATH / WMUX_CLI / WMUX_PIPE here: they are CLI socket/path
|
|
25
|
+
* overrides and can be set outside a CMUX/WMUX terminal. wmux is a Windows
|
|
26
|
+
* multiplexer (Electron + xterm.js) modeled on cmux/herdr that repaints its
|
|
27
|
+
* pane in place and exports WMUX=1 plus a native WMUX_SURFACE_ID.
|
|
28
|
+
*/
|
|
29
|
+
export function classifyTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): TerminalMultiplexer | null {
|
|
30
|
+
if (isInsideHerdr(env)) return "herdr";
|
|
31
|
+
if (env.TMUX) return "tmux";
|
|
32
|
+
if (env.STY) return "screen";
|
|
33
|
+
if (env.ZELLIJ) return "zellij";
|
|
34
|
+
if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return "cmux";
|
|
35
|
+
if (env.WMUX === "1" || env.WMUX_SURFACE_ID) return "wmux";
|
|
22
36
|
const term = env.TERM?.toLowerCase() ?? "";
|
|
23
|
-
|
|
37
|
+
if (term.startsWith("tmux")) return "tmux";
|
|
38
|
+
if (term.startsWith("screen")) return "screen";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when a terminal multiplexer owns the current screen grid. */
|
|
43
|
+
export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
44
|
+
return classifyTerminalMultiplexer(env) !== null;
|
|
24
45
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -514,6 +514,20 @@ export interface Terminal {
|
|
|
514
514
|
*/
|
|
515
515
|
readonly pendingOutputBytes?: number;
|
|
516
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Whether a pseudoconsole host owns the grid this terminal writes to, so
|
|
519
|
+
* neither the cursor nor the painted rows survive a resize under the
|
|
520
|
+
* application's own model. Measured on Windows conhost: resizing the
|
|
521
|
+
* pseudoconsole makes it re-emit its whole viewport from `CSI H` with
|
|
522
|
+
* absolute addressing while the application writes nothing, and it re-homes
|
|
523
|
+
* the cursor, so a DSR reply after a resize reports column 1 instead of the
|
|
524
|
+
* column the application parked. The renderer's resize anchor recovery needs
|
|
525
|
+
* both properties, so it takes the rebuild path instead when this is set.
|
|
526
|
+
* Optional so custom Terminals built against older pi-tui versions keep
|
|
527
|
+
* working; absent means the terminal itself owns the grid.
|
|
528
|
+
*/
|
|
529
|
+
readonly hostOwnsGridOnResize?: boolean;
|
|
530
|
+
|
|
517
531
|
// Whether Kitty keyboard protocol is active
|
|
518
532
|
get kittyProtocolActive(): boolean;
|
|
519
533
|
|
|
@@ -656,8 +670,9 @@ function isPrivateModeSupported(status: string): boolean {
|
|
|
656
670
|
export interface ProcessTerminalOptions {
|
|
657
671
|
/**
|
|
658
672
|
* Force ConPTY-hosted behavior on or off. Defaults to live detection via
|
|
659
|
-
* {@link isConPTYHosted}. Tests set this so the kitty-flag
|
|
660
|
-
*
|
|
673
|
+
* {@link isConPTYHosted}. Tests set this so the kitty-flag, write-chunking
|
|
674
|
+
* and resize-routing ({@link Terminal.hostOwnsGridOnResize}) paths stay
|
|
675
|
+
* hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
|
|
661
676
|
* `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
|
|
662
677
|
*/
|
|
663
678
|
conpty?: boolean;
|
|
@@ -1989,6 +2004,14 @@ export class ProcessTerminal implements Terminal {
|
|
|
1989
2004
|
return process.stdout.writableLength ?? 0;
|
|
1990
2005
|
}
|
|
1991
2006
|
|
|
2007
|
+
get hostOwnsGridOnResize(): boolean {
|
|
2008
|
+
// #conpty, not a fresh isConPTYHosted() call: the construction override
|
|
2009
|
+
// must gate every ConPTY-dependent path uniformly, or an injected
|
|
2010
|
+
// `conpty` value models one host for writes and kitty flags and the
|
|
2011
|
+
// opposite host for resize routing.
|
|
2012
|
+
return this.#conpty;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1992
2015
|
/**
|
|
1993
2016
|
* Reconcile the stdout backlog after a write or a poll. The watchdog runs an
|
|
1994
2017
|
* episode from the moment the backlog crosses the arm cap until it drains to
|