@gajae-code/tui 0.13.1 → 0.13.3
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 +19 -0
- package/dist/types/components/gajae-pet.d.ts +39 -21
- package/dist/types/components/ouroboros-pet.d.ts +23 -0
- package/dist/types/components/select-list.d.ts +3 -2
- package/package.json +3 -3
- package/src/components/gajae-pet.ts +284 -43
- package/src/components/ouroboros-pet-frames.json +488 -0
- package/src/components/ouroboros-pet.ts +150 -0
- package/src/components/select-list.ts +17 -11
- package/src/stdin-buffer.ts +108 -10
- package/src/terminal.ts +12 -8
- package/src/tui.ts +37 -29
|
@@ -39,6 +39,8 @@ export interface SelectListTheme {
|
|
|
39
39
|
symbols: SymbolTheme;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export type SelectListThemeSource = SelectListTheme | (() => SelectListTheme);
|
|
43
|
+
|
|
42
44
|
export interface SelectListTruncatePrimaryContext {
|
|
43
45
|
text: string;
|
|
44
46
|
maxWidth: number;
|
|
@@ -67,7 +69,7 @@ export class SelectList implements Component {
|
|
|
67
69
|
constructor(
|
|
68
70
|
private readonly items: ReadonlyArray<SelectItem>,
|
|
69
71
|
private readonly maxVisible: number,
|
|
70
|
-
private readonly
|
|
72
|
+
private readonly themeSource: SelectListThemeSource,
|
|
71
73
|
private readonly layout: SelectListLayoutOptions = {},
|
|
72
74
|
) {
|
|
73
75
|
this.#filteredItems = items;
|
|
@@ -114,11 +116,12 @@ export class SelectList implements Component {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
render(width: number): string[] {
|
|
119
|
+
const theme = this.#theme();
|
|
117
120
|
const lines: string[] = [];
|
|
118
121
|
|
|
119
122
|
// If no items match filter, show message
|
|
120
123
|
if (this.#filteredItems.length === 0) {
|
|
121
|
-
lines.push(truncateToWidth(
|
|
124
|
+
lines.push(truncateToWidth(theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
|
|
122
125
|
return lines;
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -146,12 +149,16 @@ export class SelectList implements Component {
|
|
|
146
149
|
const position = this.#selectedIndex >= 0 ? `${this.#selectedIndex + 1}` : "-";
|
|
147
150
|
const scrollText = ` (${position}/${this.#filteredItems.length})`;
|
|
148
151
|
// Truncate if too long for terminal
|
|
149
|
-
lines.push(
|
|
152
|
+
lines.push(theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
return lines;
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
#theme(): SelectListTheme {
|
|
159
|
+
return typeof this.themeSource === "function" ? this.themeSource() : this.themeSource;
|
|
160
|
+
}
|
|
161
|
+
|
|
155
162
|
handleInput(keyData: string): void {
|
|
156
163
|
const kb = getKeybindings();
|
|
157
164
|
if (this.#filteredItems.length === 0) {
|
|
@@ -193,9 +200,8 @@ export class SelectList implements Component {
|
|
|
193
200
|
descriptionSingleLine: string | undefined,
|
|
194
201
|
primaryColumnWidth: number,
|
|
195
202
|
): string {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
|
|
203
|
+
const theme = this.#theme();
|
|
204
|
+
const prefix = isSelected ? `${theme.symbols.cursor} ` : padding(visibleWidth(theme.symbols.cursor) + 1);
|
|
199
205
|
const prefixWidth = visibleWidth(prefix);
|
|
200
206
|
|
|
201
207
|
if (descriptionSingleLine && width > 40) {
|
|
@@ -210,13 +216,13 @@ export class SelectList implements Component {
|
|
|
210
216
|
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
|
|
211
217
|
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
|
|
212
218
|
if (item.disabled) {
|
|
213
|
-
return
|
|
219
|
+
return theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
|
214
220
|
}
|
|
215
221
|
if (isSelected) {
|
|
216
|
-
return
|
|
222
|
+
return theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
const descText =
|
|
225
|
+
const descText = theme.description(spacing + truncatedDesc);
|
|
220
226
|
return prefix + truncatedValue + descText;
|
|
221
227
|
}
|
|
222
228
|
}
|
|
@@ -224,10 +230,10 @@ export class SelectList implements Component {
|
|
|
224
230
|
const maxWidth = width - prefixWidth - 2;
|
|
225
231
|
const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
|
|
226
232
|
if (item.disabled) {
|
|
227
|
-
return
|
|
233
|
+
return theme.description(`${prefix}${truncatedValue}`);
|
|
228
234
|
}
|
|
229
235
|
if (isSelected) {
|
|
230
|
-
return
|
|
236
|
+
return theme.selectedText(`${prefix}${truncatedValue}`);
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
return prefix + truncatedValue;
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -278,12 +278,33 @@ function continuesAsStringTerminator(remaining: string, index: number): boolean
|
|
|
278
278
|
return afterEsc === undefined || afterEsc === "\\";
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
|
|
281
|
+
/**
|
|
282
|
+
* A buffered run of nothing but ESC bytes is N real Escape key presses, not an
|
|
283
|
+
* Option-as-Meta prefix. Emitting the run as one sequence parses as the unbound
|
|
284
|
+
* `alt+escape` and silently swallows every press, so any path that gives up on a
|
|
285
|
+
* continuation must split the run first.
|
|
286
|
+
*/
|
|
287
|
+
function splitResolvedEscapeRun(buffer: string): string[] {
|
|
288
|
+
return /^\x1b{2,}$/.test(buffer) ? buffer.split("") : [buffer];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `knownEscapeRunLength` is the number of leading ESC bytes a previous call
|
|
293
|
+
* already measured and returned as an all-Escape remainder. Resuming the scan
|
|
294
|
+
* there keeps a run delivered across many small reads linear overall instead of
|
|
295
|
+
* re-walking the whole accumulated prefix on every chunk.
|
|
296
|
+
*/
|
|
297
|
+
function extractCompleteSequences(
|
|
298
|
+
buffer: string,
|
|
299
|
+
knownEscapeRunLength = 0,
|
|
300
|
+
): { sequences: string[]; remainder: string; escapeRunRemainder: number } {
|
|
282
301
|
const sequences: string[] = [];
|
|
283
302
|
let pos = 0;
|
|
284
303
|
|
|
285
304
|
while (pos < buffer.length) {
|
|
286
|
-
|
|
305
|
+
// Slicing at 0 would copy the whole buffer on every call, which is the
|
|
306
|
+
// dominant cost when a long Escape run arrives as many single-byte reads.
|
|
307
|
+
const remaining = pos === 0 ? buffer : buffer.slice(pos);
|
|
287
308
|
|
|
288
309
|
// Try to extract a sequence starting at this position
|
|
289
310
|
if (remaining.startsWith(ESC)) {
|
|
@@ -299,6 +320,37 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
299
320
|
pos += 2;
|
|
300
321
|
continue;
|
|
301
322
|
}
|
|
323
|
+
// Measure the ESC run once. Testing the whole suffix on every iteration
|
|
324
|
+
// while the cut below advances only two bytes made a long run quadratic.
|
|
325
|
+
let runLength = pos === 0 ? Math.max(knownEscapeRunLength, 1) : 1;
|
|
326
|
+
while (runLength < remaining.length && remaining[runLength] === ESC) runLength++;
|
|
327
|
+
// A trailing run of nothing but ESC bytes is ambiguous: the next chunk
|
|
328
|
+
// may still deliver the continuation that turns its last ESC into a Meta
|
|
329
|
+
// prefix (ESC ESC ESC + "[A" is bare Escape then Option+Up). Splitting it
|
|
330
|
+
// now would emit an extra Escape and downgrade the wrapped key to a plain
|
|
331
|
+
// one, firing the destructive double-Escape gesture. Keep the whole run
|
|
332
|
+
// buffered; the flush timeout emits it as individual Escape presses.
|
|
333
|
+
if (runLength === remaining.length) {
|
|
334
|
+
// Only the last two bytes of the run are still ambiguous: a Meta prefix
|
|
335
|
+
// is at most ESC ESC, so any earlier ESC is already a settled press.
|
|
336
|
+
// Emitting them now keeps the retained buffer bounded; holding the whole
|
|
337
|
+
// run made every later read rescan it, which is quadratic for a long run
|
|
338
|
+
// delivered as many small chunks. Order of emitted presses is unchanged.
|
|
339
|
+
const settled = runLength - 2;
|
|
340
|
+
if (settled > 0) {
|
|
341
|
+
for (let index = 0; index < settled; index++) sequences.push(ESC);
|
|
342
|
+
return { sequences, remainder: remaining.slice(settled), escapeRunRemainder: 2 };
|
|
343
|
+
}
|
|
344
|
+
return { sequences, remainder: remaining, escapeRunRemainder: runLength };
|
|
345
|
+
}
|
|
346
|
+
// Only the final two ESC bytes can still form a Meta prefix for the
|
|
347
|
+
// continuation that follows the run; everything before them is a settled
|
|
348
|
+
// Escape press. Emitting them in one step keeps the walk linear.
|
|
349
|
+
if (runLength > 2) {
|
|
350
|
+
for (let index = 0; index < runLength - 2; index++) sequences.push(ESC);
|
|
351
|
+
pos += runLength - 2;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
302
354
|
// Find the end of this escape sequence
|
|
303
355
|
let seqEnd = 1;
|
|
304
356
|
while (seqEnd <= remaining.length) {
|
|
@@ -315,7 +367,23 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
315
367
|
// here keeps an unterminated sequence from swallowing the next key.
|
|
316
368
|
// seqEnd === 1 is excluded so Meta sequences (ESC ESC) still parse.
|
|
317
369
|
if (remaining[seqEnd] === ESC && seqEnd >= 2 && !continuesAsStringTerminator(remaining, seqEnd)) {
|
|
318
|
-
|
|
370
|
+
// A bare Escape may be followed in the same read by a Meta-wrapped
|
|
371
|
+
// sequence (ESC ESC ESC [ A). Keep the final Meta prefix intact;
|
|
372
|
+
// splitting all three ESC bytes would turn the wrapped arrow into a
|
|
373
|
+
// plain arrow after a destructive double-Escape gesture.
|
|
374
|
+
const trailing = remaining.slice(seqEnd);
|
|
375
|
+
if (/^\x1b+$/.test(candidate) && /^\x1b[^\x1b]/.test(trailing)) {
|
|
376
|
+
sequences.push(...candidate.slice(0, -1).split(""));
|
|
377
|
+
pos += seqEnd - 1;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
// A cut candidate of nothing but ESC bytes is real Escape key
|
|
381
|
+
// presses, not an Option-as-Meta prefix: a following ESC proves
|
|
382
|
+
// no continuation (like "[A") belongs to it. Emitting the pair
|
|
383
|
+
// as one sequence would parse as the unbound "alt+escape" and
|
|
384
|
+
// silently swallow both presses.
|
|
385
|
+
if (/^\x1b+$/.test(candidate)) sequences.push(...candidate.split(""));
|
|
386
|
+
else sequences.push(candidate);
|
|
319
387
|
pos += seqEnd;
|
|
320
388
|
break;
|
|
321
389
|
}
|
|
@@ -329,7 +397,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
329
397
|
}
|
|
330
398
|
|
|
331
399
|
if (seqEnd > remaining.length) {
|
|
332
|
-
return { sequences, remainder: remaining };
|
|
400
|
+
return { sequences, remainder: remaining, escapeRunRemainder: 0 };
|
|
333
401
|
}
|
|
334
402
|
} else {
|
|
335
403
|
// Not an escape sequence - take a single Unicode code point. Keep a
|
|
@@ -337,7 +405,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
337
405
|
// complete it.
|
|
338
406
|
const firstCodeUnit = remaining.charCodeAt(0);
|
|
339
407
|
if (isHighSurrogate(firstCodeUnit)) {
|
|
340
|
-
if (remaining.length === 1) return { sequences, remainder: remaining };
|
|
408
|
+
if (remaining.length === 1) return { sequences, remainder: remaining, escapeRunRemainder: 0 };
|
|
341
409
|
const secondCodeUnit = remaining.charCodeAt(1);
|
|
342
410
|
if (isLowSurrogate(secondCodeUnit)) {
|
|
343
411
|
sequences.push(remaining.slice(0, 2));
|
|
@@ -350,7 +418,7 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
350
418
|
}
|
|
351
419
|
}
|
|
352
420
|
|
|
353
|
-
return { sequences, remainder: "" };
|
|
421
|
+
return { sequences, remainder: "", escapeRunRemainder: 0 };
|
|
354
422
|
}
|
|
355
423
|
|
|
356
424
|
export type StdinBufferOptions = {
|
|
@@ -379,6 +447,9 @@ export type StdinBufferEventMap = {
|
|
|
379
447
|
*/
|
|
380
448
|
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
381
449
|
#buffer: string = "";
|
|
450
|
+
// Length of the leading all-Escape run already measured in #buffer, so a run
|
|
451
|
+
// arriving as many small reads is scanned once overall instead of per chunk.
|
|
452
|
+
#bufferedEscapeRunLength = 0;
|
|
382
453
|
#timeout?: NodeJS.Timeout;
|
|
383
454
|
readonly #timeoutMs: number;
|
|
384
455
|
#pasteMode: boolean = false;
|
|
@@ -486,6 +557,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
486
557
|
if (this.#pasteMode) {
|
|
487
558
|
this.#pasteBuffer += this.#buffer;
|
|
488
559
|
this.#buffer = "";
|
|
560
|
+
this.#bufferedEscapeRunLength = 0;
|
|
489
561
|
|
|
490
562
|
const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
491
563
|
if (endIndex !== -1) {
|
|
@@ -505,7 +577,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
505
577
|
return;
|
|
506
578
|
}
|
|
507
579
|
|
|
508
|
-
|
|
580
|
+
// A known all-Escape prefix cannot contain the paste introducer, so start
|
|
581
|
+
// the scan just far enough back to catch a marker straddling the boundary.
|
|
582
|
+
// Rescanning the whole retained run on every read made a long run quadratic.
|
|
583
|
+
const pasteScanFrom = Math.max(0, this.#bufferedEscapeRunLength - BRACKETED_PASTE_START.length);
|
|
584
|
+
const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START, pasteScanFrom);
|
|
509
585
|
if (startIndex !== -1) {
|
|
510
586
|
if (startIndex > 0) {
|
|
511
587
|
const beforePaste = this.#buffer.slice(0, startIndex);
|
|
@@ -513,8 +589,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
513
589
|
for (const sequence of result.sequences) {
|
|
514
590
|
this.#emitDataSequence(sequence);
|
|
515
591
|
}
|
|
592
|
+
// A bracketed paste start proves no Meta continuation is coming for a
|
|
593
|
+
// buffered Escape run, so resolve it into individual presses here too.
|
|
516
594
|
if (result.remainder.length > 0) {
|
|
517
|
-
|
|
595
|
+
for (const sequence of splitResolvedEscapeRun(result.remainder)) {
|
|
596
|
+
this.#emitDataSequence(sequence);
|
|
597
|
+
}
|
|
518
598
|
}
|
|
519
599
|
}
|
|
520
600
|
|
|
@@ -523,6 +603,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
523
603
|
this.#pasteMode = true;
|
|
524
604
|
this.#pasteBuffer = this.#buffer;
|
|
525
605
|
this.#buffer = "";
|
|
606
|
+
this.#bufferedEscapeRunLength = 0;
|
|
526
607
|
|
|
527
608
|
const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
528
609
|
if (endIndex !== -1) {
|
|
@@ -542,8 +623,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
542
623
|
return;
|
|
543
624
|
}
|
|
544
625
|
|
|
545
|
-
const result = extractCompleteSequences(this.#buffer);
|
|
626
|
+
const result = extractCompleteSequences(this.#buffer, this.#bufferedEscapeRunLength);
|
|
546
627
|
this.#buffer = result.remainder;
|
|
628
|
+
// Remember an all-Escape remainder so the next chunk resumes the run scan
|
|
629
|
+
// at its end rather than re-walking every byte received so far.
|
|
630
|
+
this.#bufferedEscapeRunLength = result.escapeRunRemainder;
|
|
547
631
|
|
|
548
632
|
for (const sequence of result.sequences) {
|
|
549
633
|
if (isSgrMousePrefix(sequence) && !isSgrMouseSequence(sequence)) continue;
|
|
@@ -574,11 +658,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
574
658
|
}
|
|
575
659
|
const remainder = suffix.slice(index);
|
|
576
660
|
this.#buffer = "";
|
|
661
|
+
this.#bufferedEscapeRunLength = 0;
|
|
577
662
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
578
663
|
if (remainder) this.process(remainder);
|
|
579
664
|
return;
|
|
580
665
|
}
|
|
581
666
|
this.#buffer = "";
|
|
667
|
+
this.#bufferedEscapeRunLength = 0;
|
|
582
668
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
583
669
|
this.#sgrQuarantine = true;
|
|
584
670
|
this.#sgrQuarantineBytes = suffix.length;
|
|
@@ -709,12 +795,23 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
709
795
|
|
|
710
796
|
if (isSgrMousePrefix(this.#buffer)) {
|
|
711
797
|
this.#buffer = "";
|
|
798
|
+
this.#bufferedEscapeRunLength = 0;
|
|
712
799
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
713
800
|
return pendingMeta === undefined ? [] : [pendingMeta];
|
|
714
801
|
}
|
|
715
802
|
|
|
716
|
-
|
|
803
|
+
// A buffer of nothing but ESC bytes at flush time is N real Escape key
|
|
804
|
+
// presses that arrived faster than the flush window (tmux forwards a
|
|
805
|
+
// quick double-Esc as one "\x1b\x1b" chunk within escape-time). Keeping
|
|
806
|
+
// the pair atomic is only correct while a continuation can still turn it
|
|
807
|
+
// into an Option-as-Meta sequence (ESC ESC [ A); once the flush timeout
|
|
808
|
+
// fires, no continuation is coming, and emitting the pair as one
|
|
809
|
+
// sequence parses as the unbound "alt+escape" — silently swallowing
|
|
810
|
+
// both presses and breaking the double-Esc draft-clear gesture.
|
|
811
|
+
const flushedBuffer = splitResolvedEscapeRun(this.#buffer);
|
|
812
|
+
const sequences = pendingMeta === undefined ? flushedBuffer : [pendingMeta, ...flushedBuffer];
|
|
717
813
|
this.#buffer = "";
|
|
814
|
+
this.#bufferedEscapeRunLength = 0;
|
|
718
815
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
719
816
|
return sequences;
|
|
720
817
|
}
|
|
@@ -725,6 +822,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
725
822
|
this.#timeout = undefined;
|
|
726
823
|
}
|
|
727
824
|
this.#buffer = "";
|
|
825
|
+
this.#bufferedEscapeRunLength = 0;
|
|
728
826
|
this.#pasteMode = false;
|
|
729
827
|
this.#pasteBuffer = "";
|
|
730
828
|
this.#pendingKittyPrintableCodepoint = undefined;
|
package/src/terminal.ts
CHANGED
|
@@ -51,6 +51,10 @@ export function keyboardEnhancementEnabled(): boolean {
|
|
|
51
51
|
return $flag("GJC_TUI_KEYBOARD_PROTOCOL", true);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function isAppleTerminal(): boolean {
|
|
55
|
+
return $env.TERM_PROGRAM === "Apple_Terminal";
|
|
56
|
+
}
|
|
57
|
+
|
|
54
58
|
/**
|
|
55
59
|
* Minimal terminal interface for TUI
|
|
56
60
|
*/
|
|
@@ -794,18 +798,18 @@ export class ProcessTerminal implements Terminal {
|
|
|
794
798
|
}
|
|
795
799
|
this.#safeWrite("\x1b[?u");
|
|
796
800
|
this.#stdinBuffer?.noteProbeIssued();
|
|
797
|
-
// Windows Terminal and
|
|
801
|
+
// Windows Terminal and Apple Terminal do not implement the Kitty keyboard
|
|
798
802
|
// protocol, so the query above never activates it there. They do honor the
|
|
799
|
-
// modifyOtherKeys fallback below — but that mode breaks
|
|
800
|
-
//
|
|
801
|
-
//
|
|
803
|
+
// modifyOtherKeys fallback below — but that mode breaks CJK/Hangul IME
|
|
804
|
+
// composition: Alt+Enter (and other chords) bypass the IME commit, so the
|
|
805
|
+
// syllable still being composed is never delivered to the app and the
|
|
802
806
|
// action fires on empty text (e.g. queue-message no-ops unless the user
|
|
803
807
|
// types a trailing space to force a commit first). Skip the fallback on
|
|
804
|
-
//
|
|
805
|
-
// chords, and IME composition works again. Opt back in with
|
|
808
|
+
// these terminals; legacy encodings still deliver Alt+Enter (ESC CR) and
|
|
809
|
+
// the newline chords, and IME composition works again. Opt back in with
|
|
806
810
|
// GJC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
|
|
807
|
-
// elsewhere if a Kitty-capable
|
|
808
|
-
if (process.platform === "win32") {
|
|
811
|
+
// elsewhere if a Kitty-capable terminal appears.
|
|
812
|
+
if (process.platform === "win32" || isAppleTerminal()) {
|
|
809
813
|
return;
|
|
810
814
|
}
|
|
811
815
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
package/src/tui.ts
CHANGED
|
@@ -349,15 +349,26 @@ export type SizeValue = number | `${number}%`;
|
|
|
349
349
|
/** Parse a SizeValue into absolute value given a reference size */
|
|
350
350
|
function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
|
|
351
351
|
if (value === undefined) return undefined;
|
|
352
|
-
if (typeof value === "number") return value;
|
|
352
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
353
353
|
// Parse percentage string like "50%"
|
|
354
354
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
355
355
|
if (match) {
|
|
356
|
-
|
|
356
|
+
const percent = Number.parseFloat(match[1]);
|
|
357
|
+
if (!Number.isFinite(percent)) return undefined;
|
|
358
|
+
const parsed = Math.floor((referenceSize * percent) / 100);
|
|
359
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
357
360
|
}
|
|
358
361
|
return undefined;
|
|
359
362
|
}
|
|
360
363
|
|
|
364
|
+
function finiteNumber(value: number | undefined, fallback: number): number {
|
|
365
|
+
return value !== undefined && Number.isFinite(value) ? value : fallback;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function finiteNonNegative(value: number | undefined, fallback = 0): number {
|
|
369
|
+
return Math.max(0, finiteNumber(value, fallback));
|
|
370
|
+
}
|
|
371
|
+
|
|
361
372
|
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
362
373
|
|
|
363
374
|
function envIsEnabled(value: string | undefined): boolean {
|
|
@@ -2480,10 +2491,10 @@ export class TUI extends Container {
|
|
|
2480
2491
|
typeof opt.margin === "number"
|
|
2481
2492
|
? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
|
|
2482
2493
|
: (opt.margin ?? {});
|
|
2483
|
-
const marginTop = Math.max(0,
|
|
2484
|
-
const marginRight = Math.max(0,
|
|
2485
|
-
const marginBottom = Math.max(0,
|
|
2486
|
-
const marginLeft = Math.max(0,
|
|
2494
|
+
const marginTop = Math.min(finiteNonNegative(margin.top), Math.max(0, termHeight - 1));
|
|
2495
|
+
const marginRight = Math.min(finiteNonNegative(margin.right), Math.max(0, termWidth - 1));
|
|
2496
|
+
const marginBottom = Math.min(finiteNonNegative(margin.bottom), Math.max(0, termHeight - 1 - marginTop));
|
|
2497
|
+
const marginLeft = Math.min(finiteNonNegative(margin.left), Math.max(0, termWidth - 1 - marginRight));
|
|
2487
2498
|
|
|
2488
2499
|
// Available space after margins
|
|
2489
2500
|
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
|
|
@@ -2492,14 +2503,15 @@ export class TUI extends Container {
|
|
|
2492
2503
|
// === Resolve width ===
|
|
2493
2504
|
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
|
2494
2505
|
// Apply minWidth
|
|
2495
|
-
if (opt.minWidth !== undefined) {
|
|
2506
|
+
if (opt.minWidth !== undefined && Number.isFinite(opt.minWidth)) {
|
|
2496
2507
|
width = Math.max(width, opt.minWidth);
|
|
2497
2508
|
}
|
|
2498
2509
|
// Clamp to available space
|
|
2499
2510
|
width = Math.max(1, Math.min(width, availWidth));
|
|
2500
2511
|
|
|
2501
2512
|
// === Resolve maxHeight ===
|
|
2502
|
-
|
|
2513
|
+
const parsedMaxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
|
2514
|
+
let maxHeight = opt.maxHeight !== undefined && parsedMaxHeight === undefined ? availHeight : parsedMaxHeight;
|
|
2503
2515
|
// Clamp to available space
|
|
2504
2516
|
if (maxHeight !== undefined) {
|
|
2505
2517
|
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
|
@@ -2519,14 +2531,18 @@ export class TUI extends Container {
|
|
|
2519
2531
|
if (match) {
|
|
2520
2532
|
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
|
2521
2533
|
const percent = parseFloat(match[1]) / 100;
|
|
2522
|
-
row =
|
|
2534
|
+
row = Number.isFinite(percent)
|
|
2535
|
+
? marginTop + Math.floor(maxRow * percent)
|
|
2536
|
+
: this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
|
|
2523
2537
|
} else {
|
|
2524
2538
|
// Invalid format, fall back to center
|
|
2525
2539
|
row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
|
2526
2540
|
}
|
|
2527
|
-
} else {
|
|
2541
|
+
} else if (Number.isFinite(opt.row)) {
|
|
2528
2542
|
// Absolute row position
|
|
2529
2543
|
row = opt.row;
|
|
2544
|
+
} else {
|
|
2545
|
+
row = this.#resolveAnchorRow(opt.anchor ?? "center", effectiveHeight, availHeight, marginTop);
|
|
2530
2546
|
}
|
|
2531
2547
|
} else {
|
|
2532
2548
|
// Anchor-based (default: center)
|
|
@@ -2541,14 +2557,18 @@ export class TUI extends Container {
|
|
|
2541
2557
|
if (match) {
|
|
2542
2558
|
const maxCol = Math.max(0, availWidth - width);
|
|
2543
2559
|
const percent = parseFloat(match[1]) / 100;
|
|
2544
|
-
col =
|
|
2560
|
+
col = Number.isFinite(percent)
|
|
2561
|
+
? marginLeft + Math.floor(maxCol * percent)
|
|
2562
|
+
: this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
|
|
2545
2563
|
} else {
|
|
2546
2564
|
// Invalid format, fall back to center
|
|
2547
2565
|
col = this.#resolveAnchorCol("center", width, availWidth, marginLeft);
|
|
2548
2566
|
}
|
|
2549
|
-
} else {
|
|
2567
|
+
} else if (Number.isFinite(opt.col)) {
|
|
2550
2568
|
// Absolute column position
|
|
2551
2569
|
col = opt.col;
|
|
2570
|
+
} else {
|
|
2571
|
+
col = this.#resolveAnchorCol(opt.anchor ?? "center", width, availWidth, marginLeft);
|
|
2552
2572
|
}
|
|
2553
2573
|
} else {
|
|
2554
2574
|
// Anchor-based (default: center)
|
|
@@ -2557,8 +2577,8 @@ export class TUI extends Container {
|
|
|
2557
2577
|
}
|
|
2558
2578
|
|
|
2559
2579
|
// Apply offsets
|
|
2560
|
-
|
|
2561
|
-
|
|
2580
|
+
row += finiteNumber(opt.offsetY, 0);
|
|
2581
|
+
col += finiteNumber(opt.offsetX, 0);
|
|
2562
2582
|
|
|
2563
2583
|
// Clamp to terminal bounds (respecting margins)
|
|
2564
2584
|
row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
|
|
@@ -2648,6 +2668,9 @@ export class TUI extends Container {
|
|
|
2648
2668
|
// than the current content. Padding to it can cause the renderer to output hundreds/thousands of blank
|
|
2649
2669
|
// lines, effectively scrolling the terminal when an overlay is shown.
|
|
2650
2670
|
const workingHeight = Math.max(result.length, minLinesNeeded);
|
|
2671
|
+
if (!Number.isFinite(workingHeight)) {
|
|
2672
|
+
throw new Error("Overlay layout produced a non-finite working height");
|
|
2673
|
+
}
|
|
2651
2674
|
|
|
2652
2675
|
// Extend result with empty lines if content is too short for overlay placement
|
|
2653
2676
|
while (result.length < workingHeight) {
|
|
@@ -4040,21 +4063,6 @@ export class TUI extends Container {
|
|
|
4040
4063
|
viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`);
|
|
4041
4064
|
return;
|
|
4042
4065
|
}
|
|
4043
|
-
if (
|
|
4044
|
-
appendedLines &&
|
|
4045
|
-
nextLiveViewportTop > prevViewportTop &&
|
|
4046
|
-
previousKittyPlacementSpans.some(placement =>
|
|
4047
|
-
this.#kittyPlacementIntersectsRegion(placement, {
|
|
4048
|
-
top: prevViewportTop,
|
|
4049
|
-
bottom: prevViewportTop + height,
|
|
4050
|
-
}),
|
|
4051
|
-
)
|
|
4052
|
-
) {
|
|
4053
|
-
viewportRepaint(
|
|
4054
|
-
`content append moved a Kitty placement viewport (${prevViewportTop} -> ${nextLiveViewportTop})`,
|
|
4055
|
-
);
|
|
4056
|
-
return;
|
|
4057
|
-
}
|
|
4058
4066
|
if (distinctPostContractionRows) this.#scrollbackResumeViewportTop = undefined;
|
|
4059
4067
|
if (
|
|
4060
4068
|
appendedLines &&
|