@xynogen/pix-pretty 1.7.26 → 1.8.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/confirm.ts +39 -31
- package/src/diff-render.ts +21 -12
- package/src/diff.test.ts +38 -12
- package/src/gate-overlay.test.ts +20 -1
- package/src/gate-overlay.ts +73 -41
- package/src/modal-frame.test.ts +441 -0
- package/src/modal-frame.ts +436 -13
- package/src/progress.ts +10 -10
package/src/modal-frame.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* Used by: gate-overlay, confirm, and (via re-export) pix-ask.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
Key,
|
|
14
|
+
matchesKey,
|
|
15
|
+
truncateToWidth,
|
|
16
|
+
visibleWidth,
|
|
17
|
+
wrapTextWithAnsi,
|
|
18
|
+
} from "@earendil-works/pi-tui";
|
|
13
19
|
|
|
14
20
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
15
21
|
|
|
@@ -21,9 +27,11 @@ const CHROME = 4;
|
|
|
21
27
|
|
|
22
28
|
// ── Width ─────────────────────────────────────────────────────────────────────
|
|
23
29
|
|
|
24
|
-
/**
|
|
30
|
+
/** Prefer a 40–96 column modal without exceeding the available render width. */
|
|
25
31
|
export function modalWidth(termWidth: number): number {
|
|
26
|
-
|
|
32
|
+
const available = Number.isFinite(termWidth) ? Math.max(1, Math.floor(termWidth)) : MIN_WIDTH;
|
|
33
|
+
const preferred = Math.max(MIN_WIDTH, available - MARGIN);
|
|
34
|
+
return Math.min(MAX_WIDTH, available, preferred);
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
// ── Frame ─────────────────────────────────────────────────────────────────────
|
|
@@ -35,8 +43,77 @@ export interface FrameOptions {
|
|
|
35
43
|
color: (s: string) => string;
|
|
36
44
|
/** Background fill function — e.g. `(s) => theme.bg("customMessageBg", s)` */
|
|
37
45
|
bg?: (s: string) => string;
|
|
46
|
+
/**
|
|
47
|
+
* Base foreground for content rows — e.g. `(s) => theme.fg("text", s)`.
|
|
48
|
+
* Establishes a readable default color so raw (unstyled) text — such as the
|
|
49
|
+
* unselected labels a pi-tui SelectList emits without any fg escape — does
|
|
50
|
+
* not fall back to the terminal default and collide with the modal `bg`.
|
|
51
|
+
* Content that carries its own fg escapes is unaffected.
|
|
52
|
+
*/
|
|
53
|
+
fg?: (s: string) => string;
|
|
38
54
|
/** Optional pre-styled string rendered as the first content row (tab bar etc.) */
|
|
39
55
|
top?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Wrap over-wide content instead of cutting its tail. Default true.
|
|
58
|
+
*
|
|
59
|
+
* Truncation is never safe for text a user acts on: a clipped command reads
|
|
60
|
+
* as a complete one, and the default `truncateToWidth` ellipsis (`...`) is
|
|
61
|
+
* indistinguishable from literal text. Wrapping is lossless for every shape
|
|
62
|
+
* we render — spaced prose, 300-char unbroken tokens, base64, long paths and
|
|
63
|
+
* ANSI-styled spans all wrap without producing an over-wide row.
|
|
64
|
+
*
|
|
65
|
+
* Set false only for rows that are already width-fitted by the caller and
|
|
66
|
+
* must stay exactly one row (fixed-column tables).
|
|
67
|
+
*/
|
|
68
|
+
wrap?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Line fitting ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Ellipsis for the wrap=false path — visibly a marker, unlike a bare "...". */
|
|
74
|
+
const ELLIPSIS = "…";
|
|
75
|
+
|
|
76
|
+
export interface FitResult {
|
|
77
|
+
rows: string[];
|
|
78
|
+
/**
|
|
79
|
+
* True when a row lost characters that are recoverable nowhere in the frame.
|
|
80
|
+
* Wrapping never sets this — only the truncating paths do.
|
|
81
|
+
*
|
|
82
|
+
* Vertical overflow is deliberately NOT reported here: paged-out body rows
|
|
83
|
+
* stay reachable via PageUp/PageDown and are described by ViewportState.
|
|
84
|
+
* Horizontal truncation is unrecoverable, so it needs its own signal.
|
|
85
|
+
*/
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Expand one logical line into the rendered rows it occupies at `inner` width.
|
|
91
|
+
* Blank lines stay a single blank row so callers keep their spacing.
|
|
92
|
+
*/
|
|
93
|
+
export function fitModalLine(line: string, inner: number, wrap = true): FitResult {
|
|
94
|
+
const width = Number.isFinite(inner) ? Math.max(1, Math.floor(inner)) : 1;
|
|
95
|
+
if (line === "") return { rows: [""], truncated: false };
|
|
96
|
+
if (visibleWidth(line) <= width) return { rows: [line], truncated: false };
|
|
97
|
+
if (!wrap) return { rows: [truncateToWidth(line, width, ELLIPSIS)], truncated: true };
|
|
98
|
+
const wrapped = wrapTextWithAnsi(line, width);
|
|
99
|
+
// Defensive: if wrapping cannot produce rows we fall back to a cut, and that
|
|
100
|
+
// IS lossy — report it instead of pretending the row survived intact.
|
|
101
|
+
if (wrapped.length === 0) {
|
|
102
|
+
return { rows: [truncateToWidth(line, width, ELLIPSIS)], truncated: true };
|
|
103
|
+
}
|
|
104
|
+
return { rows: wrapped, truncated: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Expand a list of logical lines into rendered rows. */
|
|
108
|
+
export function fitModalLines(lines: string[], inner: number, wrap = true): FitResult {
|
|
109
|
+
const rows: string[] = [];
|
|
110
|
+
let truncated = false;
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const fit = fitModalLine(line, inner, wrap);
|
|
113
|
+
rows.push(...fit.rows);
|
|
114
|
+
truncated = truncated || fit.truncated;
|
|
115
|
+
}
|
|
116
|
+
return { rows, truncated };
|
|
40
117
|
}
|
|
41
118
|
|
|
42
119
|
/**
|
|
@@ -53,26 +130,46 @@ export interface FrameOptions {
|
|
|
53
130
|
* so the background colour is re-asserted, preventing transparent holes.
|
|
54
131
|
*/
|
|
55
132
|
export function frameLines(opts: FrameOptions): string[] {
|
|
56
|
-
const {
|
|
133
|
+
const { color, top } = opts;
|
|
134
|
+
const width = Number.isFinite(opts.width) ? Math.max(1, Math.floor(opts.width)) : 1;
|
|
135
|
+
if (width < CHROME) {
|
|
136
|
+
const first = top ?? opts.lines[0] ?? "";
|
|
137
|
+
return [truncateToWidth(first, width)];
|
|
138
|
+
}
|
|
57
139
|
const bg = opts.bg ?? ((s: string) => s);
|
|
58
|
-
const
|
|
140
|
+
const fg = opts.fg;
|
|
141
|
+
const inner = width - CHROME;
|
|
59
142
|
const dashes = "─".repeat(width - 2);
|
|
143
|
+
const wrap = opts.wrap ?? true;
|
|
144
|
+
// Expand before framing so a long command wraps instead of losing its tail.
|
|
145
|
+
const { rows: lines } = fitModalLines(opts.lines, inner, wrap);
|
|
60
146
|
|
|
61
|
-
// Derive the
|
|
62
|
-
// (\x1b[0m)
|
|
147
|
+
// Derive the OPEN sequences so we can re-assert them after any embedded
|
|
148
|
+
// reset. A full reset (\x1b[0m) clears both fg and bg; \x1b[49m clears bg;
|
|
149
|
+
// \x1b[39m clears fg. Re-emitting the base opens after each keeps the modal
|
|
150
|
+
// background solid AND gives raw text a readable foreground.
|
|
63
151
|
const SENTINEL = "\x00";
|
|
64
152
|
const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
|
|
153
|
+
const fgOpen = fg ? (fg(SENTINEL).split(SENTINEL)[0] ?? "") : "";
|
|
65
154
|
const reassert = (s: string): string =>
|
|
66
|
-
bgOpen
|
|
67
|
-
? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
|
|
68
|
-
|
|
69
|
-
|
|
155
|
+
bgOpen || fgOpen
|
|
156
|
+
? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) => {
|
|
157
|
+
const parts = p.split(";");
|
|
158
|
+
const isFull = p === "0";
|
|
159
|
+
let tail = seq;
|
|
160
|
+
if (isFull || parts.includes("49")) tail += bgOpen;
|
|
161
|
+
if (isFull || parts.includes("39")) tail += fgOpen;
|
|
162
|
+
return tail;
|
|
163
|
+
})
|
|
70
164
|
: s;
|
|
71
165
|
|
|
72
166
|
const row = (content: string): string => {
|
|
73
167
|
const pad = inner - visibleWidth(content);
|
|
74
|
-
const padded = pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
|
|
75
|
-
|
|
168
|
+
const padded = pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner, ELLIPSIS);
|
|
169
|
+
// Wrap in the base fg first so unstyled text gets an explicit color, then
|
|
170
|
+
// reassert base opens after any embedded resets from theme fg/bold spans.
|
|
171
|
+
const body = fgOpen ? reassert(fg?.(padded) ?? padded) : reassert(padded);
|
|
172
|
+
return bg(`${color("│")} ${body} ${color("│")}`);
|
|
76
173
|
};
|
|
77
174
|
|
|
78
175
|
const out: string[] = [bg(color(`╭${dashes}╮`))];
|
|
@@ -82,6 +179,332 @@ export function frameLines(opts: FrameOptions): string[] {
|
|
|
82
179
|
return out;
|
|
83
180
|
}
|
|
84
181
|
|
|
182
|
+
// ── Height ────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export const DEFAULT_MODAL_HEIGHT_PERCENT = 80;
|
|
185
|
+
/** Fail-closed floor for ordinary overlays. Compare against modalHeight(), not raw rows. */
|
|
186
|
+
export const MIN_MODAL_HEIGHT = 6;
|
|
187
|
+
/** Fail-closed floor for permission overlays. Compare against modalHeight(), not raw rows. */
|
|
188
|
+
export const MIN_PERMISSION_MODAL_HEIGHT = 12;
|
|
189
|
+
|
|
190
|
+
/** Rows a modal may occupy: `percent` of the terminal, never more than it has. */
|
|
191
|
+
export function modalHeight(terminalRows: number, percent = DEFAULT_MODAL_HEIGHT_PERCENT): number {
|
|
192
|
+
const rows = Number.isFinite(terminalRows) ? Math.max(1, Math.floor(terminalRows)) : 24;
|
|
193
|
+
const ratio = Math.min(100, Math.max(1, percent)) / 100;
|
|
194
|
+
return Math.max(1, Math.min(rows, Math.floor(rows * ratio)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Current modal budget. Pass host TUI rows when available; stdout is the fallback. */
|
|
198
|
+
export function terminalModalHeight(
|
|
199
|
+
terminalRows = process.stdout.rows ?? 24,
|
|
200
|
+
percent = DEFAULT_MODAL_HEIGHT_PERCENT,
|
|
201
|
+
): number {
|
|
202
|
+
return modalHeight(terminalRows, percent);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Rows available for variable body content: total − borders − pinned rows. */
|
|
206
|
+
export function modalBodyCapacity(maxHeight: number, pinnedRows: number): number {
|
|
207
|
+
const height = Number.isFinite(maxHeight) ? Math.max(0, Math.floor(maxHeight)) : 0;
|
|
208
|
+
const pinned = Number.isFinite(pinnedRows) ? Math.max(0, Math.floor(pinnedRows)) : 0;
|
|
209
|
+
return Math.max(0, height - 2 - pinned);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Adjust a body offset so a selected rendered-row range remains visible.
|
|
214
|
+
* `selectedEnd` is exclusive, matching Array#slice and viewport state.
|
|
215
|
+
*/
|
|
216
|
+
export function ensureVisibleOffset(
|
|
217
|
+
bodyOffset: number,
|
|
218
|
+
viewportRows: number,
|
|
219
|
+
totalRows: number,
|
|
220
|
+
selectedStart: number,
|
|
221
|
+
selectedEnd: number,
|
|
222
|
+
): number {
|
|
223
|
+
const viewport = Number.isFinite(viewportRows) ? Math.max(0, Math.floor(viewportRows)) : 0;
|
|
224
|
+
const total = Number.isFinite(totalRows) ? Math.max(0, Math.floor(totalRows)) : 0;
|
|
225
|
+
if (viewport === 0 || total === 0) return 0;
|
|
226
|
+
|
|
227
|
+
const maxOffset = Math.max(0, total - viewport);
|
|
228
|
+
let offset = Number.isFinite(bodyOffset)
|
|
229
|
+
? Math.min(maxOffset, Math.max(0, Math.floor(bodyOffset)))
|
|
230
|
+
: 0;
|
|
231
|
+
const start = Number.isFinite(selectedStart)
|
|
232
|
+
? Math.min(total, Math.max(0, Math.floor(selectedStart)))
|
|
233
|
+
: 0;
|
|
234
|
+
const end = Number.isFinite(selectedEnd)
|
|
235
|
+
? Math.min(total, Math.max(start, Math.floor(selectedEnd)))
|
|
236
|
+
: start;
|
|
237
|
+
|
|
238
|
+
if (start < offset) offset = start;
|
|
239
|
+
else if (end > offset + viewport) offset = end - viewport;
|
|
240
|
+
return Math.min(maxOffset, Math.max(0, offset));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Move a modal body by one viewport while clamping to its valid range. */
|
|
244
|
+
/** Move a modal body by half a viewport (like Ctrl+D / Ctrl+U in vim). */
|
|
245
|
+
export function pageBodyOffset(
|
|
246
|
+
bodyOffset: number,
|
|
247
|
+
visibleBodyLines: number,
|
|
248
|
+
maxBodyOffset: number,
|
|
249
|
+
direction: -1 | 1,
|
|
250
|
+
): number {
|
|
251
|
+
const current = Number.isFinite(bodyOffset) ? Math.max(0, Math.floor(bodyOffset)) : 0;
|
|
252
|
+
const size = Number.isFinite(visibleBodyLines) ? Math.max(1, Math.floor(visibleBodyLines)) : 1;
|
|
253
|
+
const max = Number.isFinite(maxBodyOffset) ? Math.max(0, Math.floor(maxBodyOffset)) : 0;
|
|
254
|
+
const step = Math.max(1, Math.floor(size / 2));
|
|
255
|
+
return Math.min(max, Math.max(0, current + direction * step));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Mutable paging state shared by modal consumers. */
|
|
259
|
+
export interface ModalPageKeybindings {
|
|
260
|
+
matches(data: string, action: "tui.select.pageUp" | "tui.select.pageDown"): boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export class ModalPager {
|
|
264
|
+
bodyOffset = 0;
|
|
265
|
+
visibleBodyLines = 1;
|
|
266
|
+
maxBodyOffset = 0;
|
|
267
|
+
private inspecting = false;
|
|
268
|
+
|
|
269
|
+
sync(result: ModalFrameResult): void {
|
|
270
|
+
this.bodyOffset = result.bodyOffset;
|
|
271
|
+
this.visibleBodyLines = Math.max(1, result.visibleBodyLines);
|
|
272
|
+
this.maxBodyOffset = result.maxBodyOffset;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
page(direction: -1 | 1): boolean {
|
|
276
|
+
const next = pageBodyOffset(
|
|
277
|
+
this.bodyOffset,
|
|
278
|
+
this.visibleBodyLines,
|
|
279
|
+
this.maxBodyOffset,
|
|
280
|
+
direction,
|
|
281
|
+
);
|
|
282
|
+
if (next === this.bodyOffset) return false;
|
|
283
|
+
this.bodyOffset = next;
|
|
284
|
+
this.inspecting = true;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Resume auto-scroll to the selected row after arrows/filtering. */
|
|
289
|
+
followSelection(): void {
|
|
290
|
+
this.inspecting = false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
selectedLine(line: number): number | undefined {
|
|
294
|
+
return this.inspecting ? undefined : line;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
selectedRange(range: { start: number; end: number }): { start: number; end: number } | undefined {
|
|
298
|
+
return this.inspecting ? undefined : range;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
reset(): void {
|
|
302
|
+
this.bodyOffset = 0;
|
|
303
|
+
this.visibleBodyLines = 1;
|
|
304
|
+
this.maxBodyOffset = 0;
|
|
305
|
+
this.inspecting = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Handle paging input. Set `arrowPages` to also accept ←/→ as page up/down
|
|
310
|
+
* (only safe when the overlay doesn't use left/right for other navigation).
|
|
311
|
+
*/
|
|
312
|
+
handleInput(data: string, keybindings?: ModalPageKeybindings, arrowPages?: boolean): boolean {
|
|
313
|
+
if (
|
|
314
|
+
keybindings?.matches(data, "tui.select.pageUp") ||
|
|
315
|
+
matchesKey(data, Key.pageUp) ||
|
|
316
|
+
(arrowPages && matchesKey(data, Key.left))
|
|
317
|
+
) {
|
|
318
|
+
return this.page(-1);
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
keybindings?.matches(data, "tui.select.pageDown") ||
|
|
322
|
+
matchesKey(data, Key.pageDown) ||
|
|
323
|
+
(arrowPages && matchesKey(data, Key.right))
|
|
324
|
+
) {
|
|
325
|
+
return this.page(1);
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Sectioned frame ───────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
export interface ViewportState {
|
|
334
|
+
start: number;
|
|
335
|
+
end: number;
|
|
336
|
+
total: number;
|
|
337
|
+
hiddenBefore: number;
|
|
338
|
+
hiddenAfter: number;
|
|
339
|
+
/** Current page number (1-based). */
|
|
340
|
+
page: number;
|
|
341
|
+
/** Total number of pages. */
|
|
342
|
+
totalPages: number;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface ModalFrameOptions extends Omit<FrameOptions, "lines"> {
|
|
346
|
+
/** Hard cap on returned rows, borders included. */
|
|
347
|
+
maxHeight: number;
|
|
348
|
+
/** Fail closed below this total height. */
|
|
349
|
+
minHeight?: number;
|
|
350
|
+
/** Pinned rows above the body (title etc.). */
|
|
351
|
+
header?: string[];
|
|
352
|
+
/** Variable rows — the only region that pages. */
|
|
353
|
+
body: string[];
|
|
354
|
+
/** Pinned rows below the body (controls, help). */
|
|
355
|
+
footer?: string[];
|
|
356
|
+
bodyOffset?: number;
|
|
357
|
+
/** Logical body-line index that must remain visible after wrapping. */
|
|
358
|
+
selectedBodyLine?: number;
|
|
359
|
+
/** Rendered body-row range that must remain visible (end is exclusive). */
|
|
360
|
+
selectedBodyRange?: { start: number; end: number };
|
|
361
|
+
/** Styled by the caller; shown directly above the body when rows are hidden. */
|
|
362
|
+
overflowLine?: (state: ViewportState) => string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface ModalFrameResult {
|
|
366
|
+
lines: string[];
|
|
367
|
+
bodyOffset: number;
|
|
368
|
+
maxBodyOffset: number;
|
|
369
|
+
visibleBodyLines: number;
|
|
370
|
+
/** True when body rows are hidden but remain reachable through paging. */
|
|
371
|
+
bodyOverflowed: boolean;
|
|
372
|
+
/** True only when characters were irreversibly removed from rendered text. */
|
|
373
|
+
textTruncated: boolean;
|
|
374
|
+
/** False when pinned rows plus one body row cannot fit — callers must fail closed. */
|
|
375
|
+
pinnedRowsFit: boolean;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const defaultOverflowLine = ({ page, totalPages }: ViewportState) =>
|
|
379
|
+
`PageUp/PageDown inspect • ${page}/${totalPages}`;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Render a modal whose pinned header/footer rows always survive and whose body
|
|
383
|
+
* is paged. Never returns more than `maxHeight` rows, and delegates all styling
|
|
384
|
+
* to frameLines() so ANSI/fg/bg handling has exactly one implementation.
|
|
385
|
+
*/
|
|
386
|
+
export function frameModal(opts: ModalFrameOptions): ModalFrameResult {
|
|
387
|
+
const { width, maxHeight, color, bg, fg, top } = opts;
|
|
388
|
+
const overflowLine = opts.overflowLine ?? defaultOverflowLine;
|
|
389
|
+
const wrap = opts.wrap ?? true;
|
|
390
|
+
|
|
391
|
+
// Page over RENDERED rows, not logical lines. With wrapping enabled a single
|
|
392
|
+
// long command becomes several rows, so budgets computed from logical length
|
|
393
|
+
// would under-count and let the frame exceed maxHeight.
|
|
394
|
+
const inner = Math.max(1, width - CHROME);
|
|
395
|
+
const headerFit = fitModalLines(opts.header ?? [], inner, wrap);
|
|
396
|
+
const footerFit = fitModalLines(opts.footer ?? [], inner, wrap);
|
|
397
|
+
const bodyFit = fitModalLines(opts.body, inner, wrap);
|
|
398
|
+
let selectedBodyRange = opts.selectedBodyRange;
|
|
399
|
+
if (Number.isFinite(opts.selectedBodyLine)) {
|
|
400
|
+
const index = Math.min(
|
|
401
|
+
Math.max(0, Math.floor(opts.selectedBodyLine ?? 0)),
|
|
402
|
+
Math.max(0, opts.body.length - 1),
|
|
403
|
+
);
|
|
404
|
+
const start = fitModalLines(opts.body.slice(0, index), inner, wrap).rows.length;
|
|
405
|
+
const length = fitModalLine(opts.body[index] ?? "", inner, wrap).rows.length;
|
|
406
|
+
selectedBodyRange = { start, end: start + Math.max(1, length) };
|
|
407
|
+
}
|
|
408
|
+
const header = headerFit.rows;
|
|
409
|
+
const footer = footerFit.rows;
|
|
410
|
+
const body = bodyFit.rows;
|
|
411
|
+
// `top` is a deliberately fixed single row (tabs etc.), so an over-wide top
|
|
412
|
+
// row is the only default-wrap path that may still lose characters.
|
|
413
|
+
const topTruncated = top !== undefined && visibleWidth(top) > inner;
|
|
414
|
+
const textTruncated =
|
|
415
|
+
topTruncated || headerFit.truncated || footerFit.truncated || bodyFit.truncated;
|
|
416
|
+
|
|
417
|
+
const cap = Number.isFinite(maxHeight) ? Math.max(1, Math.floor(maxHeight)) : 1;
|
|
418
|
+
const minHeight = Number.isFinite(opts.minHeight)
|
|
419
|
+
? Math.max(1, Math.floor(opts.minHeight ?? 1))
|
|
420
|
+
: 1;
|
|
421
|
+
const diagnostic = "Terminal too short — resize or press esc to cancel";
|
|
422
|
+
if (cap < Math.max(3, minHeight)) {
|
|
423
|
+
return {
|
|
424
|
+
lines: [truncateToWidth(diagnostic, Math.max(1, width))].slice(0, cap),
|
|
425
|
+
bodyOffset: 0,
|
|
426
|
+
maxBodyOffset: 0,
|
|
427
|
+
visibleBodyLines: 0,
|
|
428
|
+
bodyOverflowed: body.length > 0,
|
|
429
|
+
textTruncated: true,
|
|
430
|
+
pinnedRowsFit: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const chrome = 2 + (top !== undefined ? 1 : 0);
|
|
435
|
+
const contentBudget = cap - chrome;
|
|
436
|
+
const pinned = header.length + footer.length;
|
|
437
|
+
const needed = pinned + (body.length > 0 ? 1 : 0);
|
|
438
|
+
const bodyBudget = contentBudget - pinned;
|
|
439
|
+
const overflows = body.length > Math.max(0, bodyBudget);
|
|
440
|
+
const canShowOverflow = !overflows || bodyBudget >= 2;
|
|
441
|
+
|
|
442
|
+
// Fail closed: hidden content needs both an indicator and one inspectable row.
|
|
443
|
+
// Compact the line list *before* framing so borders are never sliced off.
|
|
444
|
+
if (contentBudget < needed || !canShowOverflow) {
|
|
445
|
+
// Wrap first, THEN slice. Slicing logical lines and letting frameLines wrap
|
|
446
|
+
// afterwards re-expands the list and pushes the closing border past `cap`.
|
|
447
|
+
const diag = fitModalLines(
|
|
448
|
+
[...(header.length > 0 ? [header[0] as string] : []), diagnostic],
|
|
449
|
+
inner,
|
|
450
|
+
wrap,
|
|
451
|
+
).rows.slice(0, Math.max(1, cap - 2));
|
|
452
|
+
return {
|
|
453
|
+
lines: frameLines({ width, lines: diag, color, bg, fg, wrap: false }),
|
|
454
|
+
bodyOffset: 0,
|
|
455
|
+
maxBodyOffset: 0,
|
|
456
|
+
visibleBodyLines: 0,
|
|
457
|
+
bodyOverflowed: body.length > 0,
|
|
458
|
+
textTruncated,
|
|
459
|
+
pinnedRowsFit: false,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const visibleBodyLines = overflows ? bodyBudget - 1 : bodyBudget;
|
|
464
|
+
const maxBodyOffset = Math.max(0, body.length - visibleBodyLines);
|
|
465
|
+
let offset = Math.min(Math.max(0, Math.floor(opts.bodyOffset ?? 0)), maxBodyOffset);
|
|
466
|
+
if (selectedBodyRange) {
|
|
467
|
+
offset = ensureVisibleOffset(
|
|
468
|
+
offset,
|
|
469
|
+
visibleBodyLines,
|
|
470
|
+
body.length,
|
|
471
|
+
selectedBodyRange.start,
|
|
472
|
+
selectedBodyRange.end,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const end = Math.min(body.length, offset + visibleBodyLines);
|
|
477
|
+
const lines = [...header];
|
|
478
|
+
if (overflows) {
|
|
479
|
+
const step = Math.max(1, Math.floor(visibleBodyLines / 2));
|
|
480
|
+
const totalPages = Math.max(1, Math.ceil(maxBodyOffset / step) + 1);
|
|
481
|
+
const page = offset >= maxBodyOffset ? totalPages : Math.floor(offset / step) + 1;
|
|
482
|
+
lines.push(
|
|
483
|
+
overflowLine({
|
|
484
|
+
start: offset,
|
|
485
|
+
end,
|
|
486
|
+
total: body.length,
|
|
487
|
+
hiddenBefore: offset,
|
|
488
|
+
hiddenAfter: body.length - end,
|
|
489
|
+
page,
|
|
490
|
+
totalPages,
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
lines.push(...body.slice(offset, end), ...footer);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
// Already fitted above — pass wrap:false so rows are not re-expanded.
|
|
498
|
+
lines: frameLines({ width, lines, color, bg, fg, top, wrap: false }),
|
|
499
|
+
bodyOffset: offset,
|
|
500
|
+
maxBodyOffset,
|
|
501
|
+
visibleBodyLines,
|
|
502
|
+
bodyOverflowed: overflows,
|
|
503
|
+
textTruncated,
|
|
504
|
+
pinnedRowsFit: true,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
85
508
|
// ── SelectList theme ──────────────────────────────────────────────────────────
|
|
86
509
|
|
|
87
510
|
export interface SelectListThemeConfig {
|
package/src/progress.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* p.close(); // releases input, removes overlay
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { frameModal, MIN_MODAL_HEIGHT, modalWidth, terminalModalHeight } from "./modal-frame.js";
|
|
17
17
|
|
|
18
18
|
interface ProgressTheme {
|
|
19
19
|
fg(color: string, text: string): string;
|
|
@@ -30,12 +30,12 @@ interface ProgressComponent {
|
|
|
30
30
|
export interface ProgressUI {
|
|
31
31
|
custom<T>(
|
|
32
32
|
cb: (
|
|
33
|
-
tui: { requestRender(): void },
|
|
33
|
+
tui: { requestRender(): void; terminal?: { rows?: number } },
|
|
34
34
|
theme: ProgressTheme,
|
|
35
35
|
kb: unknown,
|
|
36
36
|
done: (v: T) => void,
|
|
37
37
|
) => ProgressComponent,
|
|
38
|
-
opts?: { overlay?: boolean },
|
|
38
|
+
opts?: { overlay?: boolean; overlayOptions?: { maxHeight?: number | `${number}%` } },
|
|
39
39
|
): Promise<T | undefined>;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -81,15 +81,15 @@ export function openProgress(ui: ProgressUI, title: string, accent = "accent"):
|
|
|
81
81
|
return {
|
|
82
82
|
render: (w: number) => {
|
|
83
83
|
const mw = modalWidth(w);
|
|
84
|
-
return
|
|
84
|
+
return frameModal({
|
|
85
85
|
width: mw,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
],
|
|
86
|
+
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
87
|
+
minHeight: MIN_MODAL_HEIGHT,
|
|
88
|
+
header: [theme.fg(accent, theme.bold(title))],
|
|
89
|
+
body: [`${theme.fg(accent, SPINNER[frame] ?? "")} ${theme.fg("muted", labelValue)}`],
|
|
90
90
|
color: (s) => theme.fg(accent, s),
|
|
91
91
|
bg: (s) => theme.bg("customMessageBg", s),
|
|
92
|
-
});
|
|
92
|
+
}).lines;
|
|
93
93
|
},
|
|
94
94
|
invalidate: () => {},
|
|
95
95
|
// Swallow every keystroke: a focused overlay owns input, so nothing
|
|
@@ -97,7 +97,7 @@ export function openProgress(ui: ProgressUI, title: string, accent = "accent"):
|
|
|
97
97
|
handleInput: () => {},
|
|
98
98
|
};
|
|
99
99
|
},
|
|
100
|
-
{ overlay: true },
|
|
100
|
+
{ overlay: true, overlayOptions: { maxHeight: "80%" } },
|
|
101
101
|
);
|
|
102
102
|
|
|
103
103
|
return {
|