@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.
@@ -9,7 +9,13 @@
9
9
  * Used by: gate-overlay, confirm, and (via re-export) pix-ask.
10
10
  */
11
11
 
12
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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
- /** Clamp terminal width to a sane modal width (40–96 cols). */
30
+ /** Prefer a 40–96 column modal without exceeding the available render width. */
25
31
  export function modalWidth(termWidth: number): number {
26
- return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, termWidth - MARGIN));
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 { width, lines, color, top } = opts;
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 inner = Math.max(1, width - CHROME);
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 bg OPEN sequence so we can re-assert it after any full reset
62
- // (\x1b[0m) or bg reset (\x1b[49m) embedded in content.
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
- p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
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
- return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
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 { frameLines, modalWidth } from "./modal-frame.js";
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 frameLines({
84
+ return frameModal({
85
85
  width: mw,
86
- lines: [
87
- theme.fg(accent, theme.bold(title)),
88
- `${theme.fg(accent, SPINNER[frame] ?? "")} ${theme.fg("muted", labelValue)}`,
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 {