@oh-my-pi/pi-tui 5.0.1 → 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 +1 -1
- package/src/components/markdown.ts +4 -2
- package/src/index.ts +3 -1
- package/src/keybindings.ts +4 -0
- package/src/keys.ts +28 -0
- package/src/tui.ts +393 -27
- package/src/utils.ts +12 -5
package/package.json
CHANGED
|
@@ -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 ? `${
|
|
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";
|
package/src/keybindings.ts
CHANGED
|
@@ -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,
|
|
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?:
|
|
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
|
-
/**
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
689
|
+
// Track which lines were modified for final verification
|
|
690
|
+
const modifiedLines = new Set<number>();
|
|
353
691
|
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
* @
|
|
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 = "
|
|
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
|
-
|
|
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
|
/**
|