@oh-my-pi/pi-tui 5.0.0 → 5.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -421,13 +421,15 @@ export class Markdown implements Component {
421
421
  /**
422
422
  * Render a list with proper nesting support
423
423
  */
424
- private renderList(token: Token & { items: any[]; ordered: boolean }, depth: number): string[] {
424
+ private renderList(token: Token & { items: any[]; ordered: boolean; start?: number }, depth: number): string[] {
425
425
  const lines: string[] = [];
426
426
  const indent = " ".repeat(depth);
427
+ // Use the list's start property (defaults to 1 for ordered lists)
428
+ const startNumber = token.start ?? 1;
427
429
 
428
430
  for (let i = 0; i < token.items.length; i++) {
429
431
  const item = token.items[i];
430
- const bullet = token.ordered ? `${i + 1}. ` : "- ";
432
+ const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
431
433
 
432
434
  // Process item tokens to handle nested lists
433
435
  const itemLines = this.renderListItem(item.tokens || [], depth);
package/src/index.ts CHANGED
@@ -71,6 +71,8 @@ export {
71
71
  isKeyRelease,
72
72
  isKeyRepeat,
73
73
  isKittyProtocolActive,
74
+ isPageDown,
75
+ isPageUp,
74
76
  isShiftBackspace,
75
77
  isShiftCtrlD,
76
78
  isShiftCtrlO,
@@ -115,6 +117,6 @@ export {
115
117
  setCellDimensions,
116
118
  type TerminalCapabilities,
117
119
  } from "./terminal-image";
118
- export { type Component, Container, TUI } from "./tui";
120
+ export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui";
119
121
  // Utilities
120
122
  export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils";
@@ -26,6 +26,8 @@ export type EditorAction =
26
26
  // Selection/autocomplete
27
27
  | "selectUp"
28
28
  | "selectDown"
29
+ | "selectPageUp"
30
+ | "selectPageDown"
29
31
  | "selectConfirm"
30
32
  | "selectCancel"
31
33
  // Clipboard
@@ -67,6 +69,8 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
67
69
  // Selection/autocomplete
68
70
  selectUp: "up",
69
71
  selectDown: "down",
72
+ selectPageUp: "pageUp",
73
+ selectPageDown: "pageDown",
70
74
  selectConfirm: "enter",
71
75
  selectCancel: ["escape", "ctrl+c"],
72
76
  // Clipboard
package/src/keys.ts CHANGED
@@ -114,6 +114,8 @@ type SpecialKey =
114
114
  | "delete"
115
115
  | "home"
116
116
  | "end"
117
+ | "pageUp"
118
+ | "pageDown"
117
119
  | "up"
118
120
  | "down"
119
121
  | "left"
@@ -164,6 +166,8 @@ export const Key = {
164
166
  delete: "delete" as const,
165
167
  home: "home" as const,
166
168
  end: "end" as const,
169
+ pageUp: "pageUp" as const,
170
+ pageDown: "pageDown" as const,
167
171
  up: "up" as const,
168
172
  down: "down" as const,
169
173
  left: "left" as const,
@@ -573,6 +577,18 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
573
577
  }
574
578
  return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier);
575
579
 
580
+ case "pageUp":
581
+ if (modifier === 0) {
582
+ return data === "\x1b[5~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0);
583
+ }
584
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier);
585
+
586
+ case "pageDown":
587
+ if (modifier === 0) {
588
+ return data === "\x1b[6~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0);
589
+ }
590
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier);
591
+
576
592
  case "up":
577
593
  if (modifier === 0) {
578
594
  return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0);
@@ -674,6 +690,8 @@ export function parseKey(data: string): string | undefined {
674
690
  else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
675
691
  else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
676
692
  else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
693
+ else if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
694
+ else if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
677
695
  else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up";
678
696
  else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down";
679
697
  else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left";
@@ -710,6 +728,8 @@ export function parseKey(data: string): string | undefined {
710
728
  if (data === "\x1b[H") return "home";
711
729
  if (data === "\x1b[F") return "end";
712
730
  if (data === "\x1b[3~") return "delete";
731
+ if (data === "\x1b[5~") return "pageUp";
732
+ if (data === "\x1b[6~") return "pageDown";
713
733
 
714
734
  // Raw Ctrl+letter
715
735
  if (data.length === 1) {
@@ -745,6 +765,14 @@ export function isArrowRight(data: string): boolean {
745
765
  return matchesKey(data, "right");
746
766
  }
747
767
 
768
+ export function isPageUp(data: string): boolean {
769
+ return matchesKey(data, "pageUp");
770
+ }
771
+
772
+ export function isPageDown(data: string): boolean {
773
+ return matchesKey(data, "pageDown");
774
+ }
775
+
748
776
  export function isEscape(data: string): boolean {
749
777
  return matchesKey(data, "escape") || matchesKey(data, "esc");
750
778
  }
package/src/tui.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { isKeyRelease, isShiftCtrlD } from "./keys";
8
+ import { isKeyRelease, matchesKey } from "./keys";
9
9
  import type { Terminal } from "./terminal";
10
10
  import { getCapabilities, setCellDimensions } from "./terminal-image";
11
11
  import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
@@ -46,6 +46,97 @@ export interface Component {
46
46
 
47
47
  export { visibleWidth };
48
48
 
49
+ /**
50
+ * Anchor position for overlays
51
+ */
52
+ export type OverlayAnchor =
53
+ | "center"
54
+ | "top-left"
55
+ | "top-right"
56
+ | "bottom-left"
57
+ | "bottom-right"
58
+ | "top-center"
59
+ | "bottom-center"
60
+ | "left-center"
61
+ | "right-center";
62
+
63
+ /**
64
+ * Margin configuration for overlays
65
+ */
66
+ export interface OverlayMargin {
67
+ top?: number;
68
+ right?: number;
69
+ bottom?: number;
70
+ left?: number;
71
+ }
72
+
73
+ /** Value that can be absolute (number) or percentage (string like "50%") */
74
+ export type SizeValue = number | `${number}%`;
75
+
76
+ /** Parse a SizeValue into absolute value given a reference size */
77
+ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
78
+ if (value === undefined) return undefined;
79
+ if (typeof value === "number") return value;
80
+ // Parse percentage string like "50%"
81
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
82
+ if (match) {
83
+ return Math.floor((referenceSize * parseFloat(match[1])) / 100);
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * Options for overlay positioning and sizing.
90
+ * Values can be absolute numbers or percentage strings (e.g., "50%").
91
+ */
92
+ export interface OverlayOptions {
93
+ // === Sizing ===
94
+ /** Width in columns, or percentage of terminal width (e.g., "50%") */
95
+ width?: SizeValue;
96
+ /** Minimum width in columns */
97
+ minWidth?: number;
98
+ /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
99
+ maxHeight?: SizeValue;
100
+
101
+ // === Positioning - anchor-based ===
102
+ /** Anchor point for positioning (default: 'center') */
103
+ anchor?: OverlayAnchor;
104
+ /** Horizontal offset from anchor position (positive = right) */
105
+ offsetX?: number;
106
+ /** Vertical offset from anchor position (positive = down) */
107
+ offsetY?: number;
108
+
109
+ // === Positioning - percentage or absolute ===
110
+ /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
111
+ row?: SizeValue;
112
+ /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
113
+ col?: SizeValue;
114
+
115
+ // === Margin from terminal edges ===
116
+ /** Margin from terminal edges. Number applies to all sides. */
117
+ margin?: OverlayMargin | number;
118
+
119
+ // === Visibility ===
120
+ /**
121
+ * Control overlay visibility based on terminal dimensions.
122
+ * If provided, overlay is only rendered when this returns true.
123
+ * Called each render cycle with current terminal dimensions.
124
+ */
125
+ visible?: (termWidth: number, termHeight: number) => boolean;
126
+ }
127
+
128
+ /**
129
+ * Handle returned by showOverlay for controlling the overlay
130
+ */
131
+ export interface OverlayHandle {
132
+ /** Permanently remove the overlay (cannot be shown again) */
133
+ hide(): void;
134
+ /** Temporarily hide or show the overlay */
135
+ setHidden(hidden: boolean): void;
136
+ /** Check if overlay is temporarily hidden */
137
+ isHidden(): boolean;
138
+ }
139
+
49
140
  /**
50
141
  * Container - a component that contains other components
51
142
  */
@@ -111,10 +202,13 @@ export class TUI extends Container {
111
202
  private previousCursor: { row: number; col: number } | null = null;
112
203
  private inputBuffer = ""; // Buffer for parsing terminal responses
113
204
  private cellSizeQueryPending = false;
205
+
206
+ // Overlay stack for modal components rendered on top of base content
114
207
  private overlayStack: {
115
208
  component: Component;
116
- options?: { row?: number; col?: number; width?: number };
209
+ options?: OverlayOptions;
117
210
  preFocus: Component | null;
211
+ hidden: boolean;
118
212
  }[] = [];
119
213
  private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
120
214
 
@@ -127,25 +221,90 @@ export class TUI extends Container {
127
221
  this.focusedComponent = component;
128
222
  }
129
223
 
130
- /** Show an overlay component centered (or at specified position). */
131
- showOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {
132
- this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
133
- this.setFocus(component);
224
+ /**
225
+ * Show an overlay component with configurable positioning and sizing.
226
+ * Returns a handle to control the overlay's visibility.
227
+ */
228
+ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
229
+ const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
230
+ this.overlayStack.push(entry);
231
+ // Only focus if overlay is actually visible
232
+ if (this.isOverlayVisible(entry)) {
233
+ this.setFocus(component);
234
+ }
134
235
  this.terminal.hideCursor();
135
236
  this.requestRender();
237
+
238
+ // Return handle for controlling this overlay
239
+ return {
240
+ hide: () => {
241
+ const index = this.overlayStack.indexOf(entry);
242
+ if (index !== -1) {
243
+ this.overlayStack.splice(index, 1);
244
+ // Restore focus if this overlay had focus
245
+ if (this.focusedComponent === component) {
246
+ const topVisible = this.getTopmostVisibleOverlay();
247
+ this.setFocus(topVisible?.component ?? entry.preFocus);
248
+ }
249
+ if (this.overlayStack.length === 0) this.terminal.hideCursor();
250
+ this.requestRender();
251
+ }
252
+ },
253
+ setHidden: (hidden: boolean) => {
254
+ if (entry.hidden === hidden) return;
255
+ entry.hidden = hidden;
256
+ // Update focus when hiding/showing
257
+ if (hidden) {
258
+ // If this overlay had focus, move focus to next visible or preFocus
259
+ if (this.focusedComponent === component) {
260
+ const topVisible = this.getTopmostVisibleOverlay();
261
+ this.setFocus(topVisible?.component ?? entry.preFocus);
262
+ }
263
+ } else {
264
+ // Restore focus to this overlay when showing (if it's actually visible)
265
+ if (this.isOverlayVisible(entry)) {
266
+ this.setFocus(component);
267
+ }
268
+ }
269
+ this.requestRender();
270
+ },
271
+ isHidden: () => entry.hidden,
272
+ };
136
273
  }
137
274
 
138
275
  /** Hide the topmost overlay and restore previous focus. */
139
276
  hideOverlay(): void {
140
277
  const overlay = this.overlayStack.pop();
141
278
  if (!overlay) return;
142
- this.setFocus(overlay.preFocus);
279
+ // Find topmost visible overlay, or fall back to preFocus
280
+ const topVisible = this.getTopmostVisibleOverlay();
281
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
143
282
  if (this.overlayStack.length === 0) this.terminal.hideCursor();
144
283
  this.requestRender();
145
284
  }
146
285
 
286
+ /** Check if there are any visible overlays */
147
287
  hasOverlay(): boolean {
148
- return this.overlayStack.length > 0;
288
+ return this.overlayStack.some((o) => this.isOverlayVisible(o));
289
+ }
290
+
291
+ /** Check if an overlay entry is currently visible */
292
+ private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
293
+ if (entry.hidden) return false;
294
+ if (entry.options?.visible) {
295
+ return entry.options.visible(this.terminal.columns, this.terminal.rows);
296
+ }
297
+ return true;
298
+ }
299
+
300
+ /** Find the topmost visible overlay, if any */
301
+ private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
302
+ for (let i = this.overlayStack.length - 1; i >= 0; i--) {
303
+ if (this.isOverlayVisible(this.overlayStack[i])) {
304
+ return this.overlayStack[i];
305
+ }
306
+ }
307
+ return undefined;
149
308
  }
150
309
 
151
310
  override invalidate(): void {
@@ -271,11 +430,25 @@ export class TUI extends Container {
271
430
 
272
431
  private processInput(data: string): void {
273
432
  // Global debug key handler (Shift+Ctrl+D)
274
- if (isShiftCtrlD(data) && this.onDebug) {
433
+ if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
275
434
  this.onDebug();
276
435
  return;
277
436
  }
278
437
 
438
+ // If focused component is an overlay, verify it's still visible
439
+ // (visibility can change due to terminal resize or visible() callback)
440
+ const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
441
+ if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
442
+ // Focused overlay is no longer visible, redirect to topmost visible overlay
443
+ const topVisible = this.getTopmostVisibleOverlay();
444
+ if (topVisible) {
445
+ this.setFocus(topVisible.component);
446
+ } else {
447
+ // No visible overlays, restore to preFocus
448
+ this.setFocus(focusedOverlay.preFocus);
449
+ }
450
+ }
451
+
279
452
  // Pass input to focused component (including Ctrl+C)
280
453
  // The focused component can decide how to handle Ctrl+C
281
454
  if (this.focusedComponent?.handleInput) {
@@ -334,30 +507,214 @@ export class TUI extends Container {
334
507
  return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
335
508
  }
336
509
 
510
+ /**
511
+ * Resolve overlay layout from options.
512
+ * Returns { width, row, col, maxHeight } for rendering.
513
+ */
514
+ private resolveOverlayLayout(
515
+ options: OverlayOptions | undefined,
516
+ overlayHeight: number,
517
+ termWidth: number,
518
+ termHeight: number,
519
+ ): { width: number; row: number; col: number; maxHeight: number | undefined } {
520
+ const opt = options ?? {};
521
+
522
+ // Parse margin (clamp to non-negative)
523
+ const margin =
524
+ typeof opt.margin === "number"
525
+ ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
526
+ : (opt.margin ?? {});
527
+ const marginTop = Math.max(0, margin.top ?? 0);
528
+ const marginRight = Math.max(0, margin.right ?? 0);
529
+ const marginBottom = Math.max(0, margin.bottom ?? 0);
530
+ const marginLeft = Math.max(0, margin.left ?? 0);
531
+
532
+ // Available space after margins
533
+ const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
534
+ const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
535
+
536
+ // === Resolve width ===
537
+ let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
538
+ // Apply minWidth
539
+ if (opt.minWidth !== undefined) {
540
+ width = Math.max(width, opt.minWidth);
541
+ }
542
+ // Clamp to available space
543
+ width = Math.max(1, Math.min(width, availWidth));
544
+
545
+ // === Resolve maxHeight ===
546
+ let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
547
+ // Clamp to available space
548
+ if (maxHeight !== undefined) {
549
+ maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
550
+ }
551
+
552
+ // Effective overlay height (may be clamped by maxHeight)
553
+ const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
554
+
555
+ // === Resolve position ===
556
+ let row: number;
557
+ let col: number;
558
+
559
+ if (opt.row !== undefined) {
560
+ if (typeof opt.row === "string") {
561
+ // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
562
+ const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
563
+ if (match) {
564
+ const maxRow = Math.max(0, availHeight - effectiveHeight);
565
+ const percent = parseFloat(match[1]) / 100;
566
+ row = marginTop + Math.floor(maxRow * percent);
567
+ } else {
568
+ // Invalid format, fall back to center
569
+ row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
570
+ }
571
+ } else {
572
+ // Absolute row position
573
+ row = opt.row;
574
+ }
575
+ } else {
576
+ // Anchor-based (default: center)
577
+ const anchor = opt.anchor ?? "center";
578
+ row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
579
+ }
580
+
581
+ if (opt.col !== undefined) {
582
+ if (typeof opt.col === "string") {
583
+ // Percentage: 0% = left, 100% = right (overlay stays within bounds)
584
+ const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
585
+ if (match) {
586
+ const maxCol = Math.max(0, availWidth - width);
587
+ const percent = parseFloat(match[1]) / 100;
588
+ col = marginLeft + Math.floor(maxCol * percent);
589
+ } else {
590
+ // Invalid format, fall back to center
591
+ col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
592
+ }
593
+ } else {
594
+ // Absolute column position
595
+ col = opt.col;
596
+ }
597
+ } else {
598
+ // Anchor-based (default: center)
599
+ const anchor = opt.anchor ?? "center";
600
+ col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
601
+ }
602
+
603
+ // Apply offsets
604
+ if (opt.offsetY !== undefined) row += opt.offsetY;
605
+ if (opt.offsetX !== undefined) col += opt.offsetX;
606
+
607
+ // Clamp to terminal bounds (respecting margins)
608
+ row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
609
+ col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
610
+
611
+ return { width, row, col, maxHeight };
612
+ }
613
+
614
+ private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
615
+ switch (anchor) {
616
+ case "top-left":
617
+ case "top-center":
618
+ case "top-right":
619
+ return marginTop;
620
+ case "bottom-left":
621
+ case "bottom-center":
622
+ case "bottom-right":
623
+ return marginTop + availHeight - height;
624
+ case "left-center":
625
+ case "center":
626
+ case "right-center":
627
+ return marginTop + Math.floor((availHeight - height) / 2);
628
+ }
629
+ }
630
+
631
+ private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
632
+ switch (anchor) {
633
+ case "top-left":
634
+ case "left-center":
635
+ case "bottom-left":
636
+ return marginLeft;
637
+ case "top-right":
638
+ case "right-center":
639
+ case "bottom-right":
640
+ return marginLeft + availWidth - width;
641
+ case "top-center":
642
+ case "center":
643
+ case "bottom-center":
644
+ return marginLeft + Math.floor((availWidth - width) / 2);
645
+ }
646
+ }
647
+
337
648
  /** Composite all overlays into content lines (in stack order, later = on top). */
338
649
  private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
339
650
  if (this.overlayStack.length === 0) return lines;
340
651
  const result = [...lines];
341
- const viewportStart = Math.max(0, result.length - termHeight);
342
652
 
343
- for (const { component, options } of this.overlayStack) {
344
- const w =
345
- options?.width !== undefined
346
- ? Math.max(1, Math.min(options.width, termWidth - 4))
347
- : Math.max(1, Math.min(80, termWidth - 4));
348
- const overlayLines = component.render(w);
349
- const h = overlayLines.length;
653
+ // Pre-render all visible overlays and calculate positions
654
+ const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
655
+ let minLinesNeeded = result.length;
656
+
657
+ for (const entry of this.overlayStack) {
658
+ // Skip invisible overlays (hidden or visible() returns false)
659
+ if (!this.isOverlayVisible(entry)) continue;
660
+
661
+ const { component, options } = entry;
662
+
663
+ // Get layout with height=0 first to determine width and maxHeight
664
+ // (width and maxHeight don't depend on overlay height)
665
+ const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
666
+
667
+ // Render component at calculated width
668
+ let overlayLines = component.render(width);
669
+
670
+ // Apply maxHeight if specified
671
+ if (maxHeight !== undefined && overlayLines.length > maxHeight) {
672
+ overlayLines = overlayLines.slice(0, maxHeight);
673
+ }
674
+
675
+ // Get final row/col with actual overlay height
676
+ const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
677
+
678
+ rendered.push({ overlayLines, row, col, w: width });
679
+ minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
680
+ }
681
+
682
+ // Extend result with empty lines if content is too short for overlay placement
683
+ while (result.length < minLinesNeeded) {
684
+ result.push("");
685
+ }
686
+
687
+ const viewportStart = Math.max(0, result.length - termHeight);
350
688
 
351
- const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));
352
- const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));
689
+ // Track which lines were modified for final verification
690
+ const modifiedLines = new Set<number>();
353
691
 
354
- for (let i = 0; i < h; i++) {
692
+ // Composite each overlay
693
+ for (const { overlayLines, row, col, w } of rendered) {
694
+ for (let i = 0; i < overlayLines.length; i++) {
355
695
  const idx = viewportStart + row + i;
356
696
  if (idx >= 0 && idx < result.length) {
357
- result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);
697
+ // Defensive: truncate overlay line to declared width before compositing
698
+ // (components should already respect width, but this ensures it)
699
+ const truncatedOverlayLine =
700
+ visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
701
+ result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
702
+ modifiedLines.add(idx);
358
703
  }
359
704
  }
360
705
  }
706
+
707
+ // Final verification: ensure no composited line exceeds terminal width
708
+ // This is a belt-and-suspenders safeguard - compositeLineAt should already
709
+ // guarantee this, but we verify here to prevent crashes from any edge cases
710
+ // Only check lines that were actually modified (optimization)
711
+ for (const idx of modifiedLines) {
712
+ const lineWidth = visibleWidth(result[idx]);
713
+ if (lineWidth > termWidth) {
714
+ result[idx] = sliceByColumn(result[idx], 0, termWidth, true);
715
+ }
716
+ }
717
+
361
718
  return result;
362
719
  }
363
720
 
@@ -382,8 +739,8 @@ export class TUI extends Container {
382
739
  const afterStart = startCol + overlayWidth;
383
740
  const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
384
741
 
385
- // Extract overlay with width tracking
386
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth);
742
+ // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
743
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
387
744
 
388
745
  // Pad segments to target widths
389
746
  const beforePad = Math.max(0, startCol - base.beforeWidth);
@@ -393,7 +750,7 @@ export class TUI extends Container {
393
750
  const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
394
751
  const afterPad = Math.max(0, afterTarget - base.afterWidth);
395
752
 
396
- // Compose result - widths are tracked so no final visibleWidth check needed
753
+ // Compose result
397
754
  const r = TUI.SEGMENT_RESET;
398
755
  const result =
399
756
  base.before +
@@ -405,9 +762,18 @@ export class TUI extends Container {
405
762
  base.after +
406
763
  " ".repeat(afterPad);
407
764
 
408
- // Only truncate if wide char at after boundary caused overflow (rare)
409
- const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);
410
- return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
765
+ // CRITICAL: Always verify and truncate to terminal width.
766
+ // This is the final safeguard against width overflow which would crash the TUI.
767
+ // Width tracking can drift from actual visible width due to:
768
+ // - Complex ANSI/OSC sequences (hyperlinks, colors)
769
+ // - Wide characters at segment boundaries
770
+ // - Edge cases in segment extraction
771
+ const resultWidth = visibleWidth(result);
772
+ if (resultWidth <= totalWidth) {
773
+ return result;
774
+ }
775
+ // Truncate with strict=true to ensure we don't exceed totalWidth
776
+ return sliceByColumn(result, 0, totalWidth, true);
411
777
  }
412
778
 
413
779
  private doRender(): void {
package/src/utils.ts CHANGED
@@ -664,18 +664,20 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text:
664
664
 
665
665
  /**
666
666
  * Truncate text to fit within a maximum visible width, adding ellipsis if needed.
667
+ * Optionally pad with spaces to reach exactly maxWidth.
667
668
  * Properly handles ANSI escape codes (they don't count toward width).
668
669
  *
669
670
  * @param text - Text to truncate (may contain ANSI codes)
670
671
  * @param maxWidth - Maximum visible width
671
- * @param ellipsis - Ellipsis string to append when truncating (default: "...")
672
- * @returns Truncated text with ellipsis if it exceeded maxWidth
672
+ * @param ellipsis - Ellipsis string to append when truncating (default: "")
673
+ * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
674
+ * @returns Truncated text, optionally padded to exactly maxWidth
673
675
  */
674
- export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "..."): string {
676
+ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string = "", pad: boolean = false): string {
675
677
  const textVisibleWidth = visibleWidth(text);
676
678
 
677
679
  if (textVisibleWidth <= maxWidth) {
678
- return text;
680
+ return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
679
681
  }
680
682
 
681
683
  const ellipsisWidth = visibleWidth(ellipsis);
@@ -736,7 +738,12 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
736
738
  }
737
739
 
738
740
  // Add reset code before ellipsis to prevent styling leaking into it
739
- return `${result}\x1b[0m${ellipsis}`;
741
+ const truncated = `${result}\x1b[0m${ellipsis}`;
742
+ if (pad) {
743
+ const truncatedWidth = visibleWidth(truncated);
744
+ return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
745
+ }
746
+ return truncated;
740
747
  }
741
748
 
742
749
  /**