@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.
@@ -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 theme: SelectListTheme,
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(this.theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
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(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
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 prefix = isSelected
197
- ? `${this.theme.symbols.cursor} `
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 this.theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
219
+ return theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
214
220
  }
215
221
  if (isSelected) {
216
- return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
222
+ return theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
217
223
  }
218
224
 
219
- const descText = this.theme.description(spacing + truncatedDesc);
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 this.theme.description(`${prefix}${truncatedValue}`);
233
+ return theme.description(`${prefix}${truncatedValue}`);
228
234
  }
229
235
  if (isSelected) {
230
- return this.theme.selectedText(`${prefix}${truncatedValue}`);
236
+ return theme.selectedText(`${prefix}${truncatedValue}`);
231
237
  }
232
238
 
233
239
  return prefix + truncatedValue;
@@ -278,12 +278,33 @@ function continuesAsStringTerminator(remaining: string, index: number): boolean
278
278
  return afterEsc === undefined || afterEsc === "\\";
279
279
  }
280
280
 
281
- function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
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
- const remaining = buffer.slice(pos);
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
- sequences.push(candidate);
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
- const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
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
- this.#emitDataSequence(result.remainder);
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
- const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer];
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 conhost do not implement the Kitty keyboard
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 Windows CJK/Hangul
800
- // IME composition: Alt+Enter (and other chords) bypass the IME commit, so
801
- // the syllable still being composed is never delivered to the app and the
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
- // win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
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 Windows terminal appears.
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
- return Math.floor((referenceSize * parseFloat(match[1])) / 100);
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, margin.top ?? 0);
2484
- const marginRight = Math.max(0, margin.right ?? 0);
2485
- const marginBottom = Math.max(0, margin.bottom ?? 0);
2486
- const marginLeft = Math.max(0, margin.left ?? 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
- let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
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 = marginTop + Math.floor(maxRow * percent);
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 = marginLeft + Math.floor(maxCol * percent);
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
- if (opt.offsetY !== undefined) row += opt.offsetY;
2561
- if (opt.offsetX !== undefined) col += opt.offsetX;
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 &&