@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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { setKittyProtocolActive, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
ensureVisibleOffset,
|
|
5
|
+
fitModalLine,
|
|
6
|
+
frameLines,
|
|
7
|
+
frameModal,
|
|
8
|
+
MIN_PERMISSION_MODAL_HEIGHT,
|
|
9
|
+
type ModalFrameResult,
|
|
10
|
+
ModalPager,
|
|
11
|
+
modalBodyCapacity,
|
|
12
|
+
modalHeight,
|
|
13
|
+
modalWidth,
|
|
14
|
+
} from "./modal-frame.ts";
|
|
15
|
+
|
|
16
|
+
const plain = (text: string) => text;
|
|
17
|
+
|
|
18
|
+
function render(offset = 0): ModalFrameResult {
|
|
19
|
+
return frameModal({
|
|
20
|
+
width: 40,
|
|
21
|
+
maxHeight: 10,
|
|
22
|
+
header: ["title"],
|
|
23
|
+
body: Array.from({ length: 12 }, (_, index) => `body ${index + 1}`),
|
|
24
|
+
footer: ["divider", "Allow", "Deny", "help"],
|
|
25
|
+
bodyOffset: offset,
|
|
26
|
+
color: plain,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("modalHeight uses 80% without exceeding the terminal", () => {
|
|
31
|
+
expect(modalHeight(40)).toBe(32);
|
|
32
|
+
expect(modalHeight(10)).toBe(8);
|
|
33
|
+
expect(modalHeight(1)).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("modalHeight is defensive about junk row counts", () => {
|
|
37
|
+
expect(modalHeight(Number.NaN)).toBe(19); // falls back to 24 rows
|
|
38
|
+
expect(modalHeight(Number.POSITIVE_INFINITY)).toBe(19);
|
|
39
|
+
expect(modalHeight(0)).toBe(1);
|
|
40
|
+
expect(modalHeight(-5)).toBe(1);
|
|
41
|
+
expect(modalHeight(40, 100)).toBe(40);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("modalWidth never exceeds the available render width", () => {
|
|
45
|
+
expect(modalWidth(200)).toBe(96);
|
|
46
|
+
expect(modalWidth(50)).toBe(46);
|
|
47
|
+
expect(modalWidth(10)).toBe(10);
|
|
48
|
+
expect(modalWidth(1)).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("fitModalLine distinguishes lossless wrapping from irreversible truncation", () => {
|
|
52
|
+
const long = "A".repeat(300);
|
|
53
|
+
const wrapped = fitModalLine(long, 20);
|
|
54
|
+
expect(wrapped.truncated).toBe(false);
|
|
55
|
+
expect(wrapped.rows.length).toBeGreaterThan(1);
|
|
56
|
+
expect(wrapped.rows.every((line) => visibleWidth(line) <= 20)).toBe(true);
|
|
57
|
+
|
|
58
|
+
const cut = fitModalLine(long, 20, false);
|
|
59
|
+
expect(cut.truncated).toBe(true);
|
|
60
|
+
expect(cut.rows).toHaveLength(1);
|
|
61
|
+
expect(cut.rows[0]).toContain("…");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("frameLines uses an unframed fallback when border chrome cannot fit", () => {
|
|
65
|
+
for (const width of [1, 2, 3]) {
|
|
66
|
+
const lines = frameLines({ width, lines: ["content"], color: plain });
|
|
67
|
+
expect(lines).toHaveLength(1);
|
|
68
|
+
expect(visibleWidth(lines[0] ?? "")).toBeLessThanOrEqual(width);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function pagerWithViewport(): ModalPager {
|
|
73
|
+
const pager = new ModalPager();
|
|
74
|
+
pager.sync({
|
|
75
|
+
lines: [],
|
|
76
|
+
bodyOffset: 0,
|
|
77
|
+
maxBodyOffset: 12,
|
|
78
|
+
visibleBodyLines: 6,
|
|
79
|
+
bodyOverflowed: true,
|
|
80
|
+
textTruncated: false,
|
|
81
|
+
pinnedRowsFit: true,
|
|
82
|
+
});
|
|
83
|
+
const pageDown = "\x1b[6~";
|
|
84
|
+
const pageUp = "\x1b[5~";
|
|
85
|
+
// Half-page scroll: step = floor(6/2) = 3
|
|
86
|
+
expect(pager.handleInput(pageDown)).toBe(true);
|
|
87
|
+
expect(pager.bodyOffset).toBe(3);
|
|
88
|
+
expect(pager.handleInput(pageDown)).toBe(true);
|
|
89
|
+
expect(pager.bodyOffset).toBe(6);
|
|
90
|
+
expect(pager.handleInput(pageDown)).toBe(true);
|
|
91
|
+
expect(pager.bodyOffset).toBe(9);
|
|
92
|
+
expect(pager.handleInput(pageDown)).toBe(true);
|
|
93
|
+
expect(pager.bodyOffset).toBe(12);
|
|
94
|
+
expect(pager.handleInput(pageDown)).toBe(false);
|
|
95
|
+
expect(pager.handleInput(pageUp)).toBe(true);
|
|
96
|
+
expect(pager.bodyOffset).toBe(9);
|
|
97
|
+
return pager;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("ModalPager handles legacy PageUp/PageDown through pi-tui", () => {
|
|
101
|
+
pagerWithViewport();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("ModalPager handles Kitty functional PageUp/PageDown through pi-tui", () => {
|
|
105
|
+
setKittyProtocolActive(true);
|
|
106
|
+
try {
|
|
107
|
+
const pager = new ModalPager();
|
|
108
|
+
pager.sync({
|
|
109
|
+
lines: [],
|
|
110
|
+
bodyOffset: 0,
|
|
111
|
+
maxBodyOffset: 12,
|
|
112
|
+
visibleBodyLines: 6,
|
|
113
|
+
bodyOverflowed: true,
|
|
114
|
+
textTruncated: false,
|
|
115
|
+
pinnedRowsFit: true,
|
|
116
|
+
});
|
|
117
|
+
// Kitty functional key codepoints from pi-tui's own key table.
|
|
118
|
+
// Half-page scroll: step = floor(6/2) = 3
|
|
119
|
+
expect(pager.handleInput("\x1b[57422u")).toBe(true);
|
|
120
|
+
expect(pager.bodyOffset).toBe(3);
|
|
121
|
+
expect(pager.handleInput("\x1b[57421u")).toBe(true);
|
|
122
|
+
expect(pager.bodyOffset).toBe(0);
|
|
123
|
+
} finally {
|
|
124
|
+
setKittyProtocolActive(false);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ModalPager accepts arrow left/right as page up/down when arrowPages is true", () => {
|
|
129
|
+
const pager = new ModalPager();
|
|
130
|
+
pager.sync({
|
|
131
|
+
lines: [],
|
|
132
|
+
bodyOffset: 0,
|
|
133
|
+
maxBodyOffset: 12,
|
|
134
|
+
visibleBodyLines: 6,
|
|
135
|
+
bodyOverflowed: true,
|
|
136
|
+
textTruncated: false,
|
|
137
|
+
pinnedRowsFit: true,
|
|
138
|
+
});
|
|
139
|
+
const arrowRight = "\x1b[C";
|
|
140
|
+
const arrowLeft = "\x1b[D";
|
|
141
|
+
// Without arrowPages, arrows are ignored
|
|
142
|
+
expect(pager.handleInput(arrowRight)).toBe(false);
|
|
143
|
+
expect(pager.bodyOffset).toBe(0);
|
|
144
|
+
// With arrowPages enabled — half-page scroll: step = floor(6/2) = 3
|
|
145
|
+
expect(pager.handleInput(arrowRight, undefined, true)).toBe(true);
|
|
146
|
+
expect(pager.bodyOffset).toBe(3);
|
|
147
|
+
expect(pager.handleInput(arrowLeft, undefined, true)).toBe(true);
|
|
148
|
+
expect(pager.bodyOffset).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("ModalPager arrow paging works under Kitty protocol", () => {
|
|
152
|
+
setKittyProtocolActive(true);
|
|
153
|
+
try {
|
|
154
|
+
const pager = new ModalPager();
|
|
155
|
+
pager.sync({
|
|
156
|
+
lines: [],
|
|
157
|
+
bodyOffset: 0,
|
|
158
|
+
maxBodyOffset: 10,
|
|
159
|
+
visibleBodyLines: 6,
|
|
160
|
+
bodyOverflowed: true,
|
|
161
|
+
textTruncated: false,
|
|
162
|
+
pinnedRowsFit: true,
|
|
163
|
+
});
|
|
164
|
+
const arrowRight = "\x1b[C";
|
|
165
|
+
const arrowLeft = "\x1b[D";
|
|
166
|
+
// Half-page scroll: step = floor(6/2) = 3
|
|
167
|
+
expect(pager.handleInput(arrowRight, undefined, true)).toBe(true);
|
|
168
|
+
expect(pager.bodyOffset).toBe(3);
|
|
169
|
+
expect(pager.handleInput(arrowLeft, undefined, true)).toBe(true);
|
|
170
|
+
expect(pager.bodyOffset).toBe(0);
|
|
171
|
+
} finally {
|
|
172
|
+
setKittyProtocolActive(false);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("modalBodyCapacity subtracts borders and pinned rows", () => {
|
|
177
|
+
expect(modalBodyCapacity(16, 6)).toBe(8);
|
|
178
|
+
expect(modalBodyCapacity(10, 20)).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("ensureVisibleOffset keeps a selected rendered-row range in view", () => {
|
|
182
|
+
expect(ensureVisibleOffset(0, 5, 20, 0, 2)).toBe(0);
|
|
183
|
+
expect(ensureVisibleOffset(0, 5, 20, 7, 9)).toBe(4);
|
|
184
|
+
expect(ensureVisibleOffset(10, 5, 20, 7, 9)).toBe(7);
|
|
185
|
+
expect(ensureVisibleOffset(99, 5, 20, 19, 20)).toBe(15);
|
|
186
|
+
expect(ensureVisibleOffset(0, 0, 20, 7, 9)).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("frameModal stays within maxHeight and pins footer controls", () => {
|
|
190
|
+
const result = render();
|
|
191
|
+
expect(result.lines).toHaveLength(10);
|
|
192
|
+
expect(result.lines.join("\n")).toContain("title");
|
|
193
|
+
expect(result.lines.join("\n")).toContain("Allow");
|
|
194
|
+
expect(result.lines.join("\n")).toContain("Deny");
|
|
195
|
+
expect(result.lines.join("\n")).toContain("help");
|
|
196
|
+
expect(result.maxBodyOffset).toBeGreaterThan(0);
|
|
197
|
+
expect(result.bodyOverflowed).toBe(true);
|
|
198
|
+
expect(result.textTruncated).toBe(false);
|
|
199
|
+
for (const line of result.lines) expect(visibleWidth(line)).toBe(40);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("frameModal reports intentional fixed-row truncation separately from paging", () => {
|
|
203
|
+
const result = frameModal({
|
|
204
|
+
width: 40,
|
|
205
|
+
maxHeight: 10,
|
|
206
|
+
body: ["X".repeat(100)],
|
|
207
|
+
wrap: false,
|
|
208
|
+
color: plain,
|
|
209
|
+
});
|
|
210
|
+
expect(result.textTruncated).toBe(true);
|
|
211
|
+
expect(result.lines.join("\n")).toContain("…");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("frameModal wraps long text before applying the height budget", () => {
|
|
215
|
+
const result = frameModal({
|
|
216
|
+
width: 40,
|
|
217
|
+
maxHeight: 8,
|
|
218
|
+
body: ["command ".repeat(60)],
|
|
219
|
+
color: plain,
|
|
220
|
+
});
|
|
221
|
+
expect(result.bodyOverflowed).toBe(true);
|
|
222
|
+
expect(result.textTruncated).toBe(false);
|
|
223
|
+
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("frameModal pages the body without losing pinned rows", () => {
|
|
227
|
+
const first = render(0);
|
|
228
|
+
const last = render(first.maxBodyOffset);
|
|
229
|
+
expect(first.lines.join("\n")).toContain("body 1");
|
|
230
|
+
expect(first.lines.join("\n")).not.toContain("body 12");
|
|
231
|
+
expect(last.lines.join("\n")).toContain("body 12");
|
|
232
|
+
expect(last.lines.join("\n")).toContain("Allow");
|
|
233
|
+
expect(last.bodyOffset).toBe(last.maxBodyOffset);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("frameModal clamps an out-of-range bodyOffset", () => {
|
|
237
|
+
const high = render(9999);
|
|
238
|
+
expect(high.bodyOffset).toBe(high.maxBodyOffset);
|
|
239
|
+
const low = frameModal({
|
|
240
|
+
width: 40,
|
|
241
|
+
maxHeight: 10,
|
|
242
|
+
body: ["a", "b"],
|
|
243
|
+
bodyOffset: -20,
|
|
244
|
+
color: plain,
|
|
245
|
+
});
|
|
246
|
+
expect(low.bodyOffset).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("frameModal reports hidden rows via the overflow indicator", () => {
|
|
250
|
+
const seen: string[] = [];
|
|
251
|
+
frameModal({
|
|
252
|
+
width: 40,
|
|
253
|
+
maxHeight: 10,
|
|
254
|
+
header: ["title"],
|
|
255
|
+
body: Array.from({ length: 12 }, (_, i) => `body ${i + 1}`),
|
|
256
|
+
footer: ["divider", "Allow", "Deny", "help"],
|
|
257
|
+
color: plain,
|
|
258
|
+
overflowLine: (state) => {
|
|
259
|
+
seen.push(`${state.start}-${state.end}/${state.total}+${state.hiddenAfter}`);
|
|
260
|
+
return "overflow";
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
expect(seen).toEqual(["0-2/12+10"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("frameModal overflow page number reaches totalPages on last page", () => {
|
|
267
|
+
const pages: string[] = [];
|
|
268
|
+
const body = Array.from({ length: 20 }, (_, i) => `line ${i}`);
|
|
269
|
+
// maxHeight=12: 2 border + 1 header + 1 footer + 1 overflow = 5 chrome → 7 visible body
|
|
270
|
+
// maxBodyOffset = 20-7 = 13, step = floor(7/2) = 3
|
|
271
|
+
// totalPages = ceil(13/3)+1 = 5+1 = 6 (but actually ceil(13/3)=5, +1=6)
|
|
272
|
+
// Half-page offsets: 0, 3, 6, 9, 12, 13(max)
|
|
273
|
+
const offsets = [0, 3, 6, 9, 12, 13];
|
|
274
|
+
for (const offset of offsets) {
|
|
275
|
+
frameModal({
|
|
276
|
+
width: 40,
|
|
277
|
+
maxHeight: 12,
|
|
278
|
+
header: ["title"],
|
|
279
|
+
body,
|
|
280
|
+
footer: ["help"],
|
|
281
|
+
bodyOffset: offset,
|
|
282
|
+
color: plain,
|
|
283
|
+
overflowLine: (state) => {
|
|
284
|
+
pages.push(`${state.page}/${state.totalPages}`);
|
|
285
|
+
return `page ${state.page}/${state.totalPages}`;
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// page must reach totalPages at maxBodyOffset
|
|
290
|
+
expect(pages[pages.length - 1]).toMatch(/^\d+\/\d+$/);
|
|
291
|
+
const last = pages[pages.length - 1]!.split("/");
|
|
292
|
+
expect(last[0]).toBe(last[1]); // last page == totalPages
|
|
293
|
+
// pages must be monotonically non-decreasing
|
|
294
|
+
const nums = pages.map((p) => Number(p.split("/")[0]));
|
|
295
|
+
for (let i = 1; i < nums.length; i++) {
|
|
296
|
+
expect(nums[i]).toBeGreaterThanOrEqual(nums[i - 1]!);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("frameModal omits the overflow row when everything fits", () => {
|
|
301
|
+
const result = frameModal({
|
|
302
|
+
width: 40,
|
|
303
|
+
maxHeight: 12,
|
|
304
|
+
header: ["title"],
|
|
305
|
+
body: ["only"],
|
|
306
|
+
footer: ["help"],
|
|
307
|
+
color: plain,
|
|
308
|
+
});
|
|
309
|
+
expect(result.lines.join("\n")).not.toContain("PageUp/PageDown");
|
|
310
|
+
expect(result.maxBodyOffset).toBe(0);
|
|
311
|
+
expect(result.pinnedRowsFit).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("frameModal honors an explicit fail-closed minimum height", () => {
|
|
315
|
+
const result = frameModal({
|
|
316
|
+
width: 40,
|
|
317
|
+
maxHeight: MIN_PERMISSION_MODAL_HEIGHT - 1,
|
|
318
|
+
minHeight: MIN_PERMISSION_MODAL_HEIGHT,
|
|
319
|
+
header: ["DANGEROUS"],
|
|
320
|
+
body: ["command"],
|
|
321
|
+
footer: ["Allow", "Deny"],
|
|
322
|
+
color: plain,
|
|
323
|
+
});
|
|
324
|
+
expect(result.pinnedRowsFit).toBe(false);
|
|
325
|
+
expect(result.lines.join("\n")).toContain("Terminal too short");
|
|
326
|
+
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("frameModal reports when pinned rows cannot fit", () => {
|
|
330
|
+
const result = frameModal({
|
|
331
|
+
width: 40,
|
|
332
|
+
maxHeight: 5,
|
|
333
|
+
header: ["title"],
|
|
334
|
+
body: ["command"],
|
|
335
|
+
footer: ["one", "two", "three"],
|
|
336
|
+
color: plain,
|
|
337
|
+
});
|
|
338
|
+
expect(result.pinnedRowsFit).toBe(false);
|
|
339
|
+
expect(result.lines.length).toBeLessThanOrEqual(5);
|
|
340
|
+
// Fail-closed frame keeps its borders and offers no approval control.
|
|
341
|
+
expect(result.lines.at(0)).toContain("╭");
|
|
342
|
+
expect(result.lines.at(-1)).toContain("╰");
|
|
343
|
+
expect(result.lines.join("\n")).toContain("Terminal too short");
|
|
344
|
+
expect(result.lines.join("\n")).not.toContain("two");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("frameModal uses an unframed fail-closed diagnostic below border capacity", () => {
|
|
348
|
+
for (const maxHeight of [1, 2]) {
|
|
349
|
+
const result = frameModal({
|
|
350
|
+
width: 20,
|
|
351
|
+
maxHeight,
|
|
352
|
+
header: ["DANGEROUS"],
|
|
353
|
+
body: ["command"],
|
|
354
|
+
footer: ["Allow", "Deny"],
|
|
355
|
+
color: plain,
|
|
356
|
+
});
|
|
357
|
+
expect(result.pinnedRowsFit).toBe(false);
|
|
358
|
+
expect(result.lines.length).toBeLessThanOrEqual(maxHeight);
|
|
359
|
+
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
360
|
+
for (const line of result.lines) expect(visibleWidth(line)).toBeLessThanOrEqual(20);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("frameModal fails closed when overflow cannot fit an indicator and body row", () => {
|
|
365
|
+
const result = frameModal({
|
|
366
|
+
width: 40,
|
|
367
|
+
maxHeight: 8,
|
|
368
|
+
header: ["title"],
|
|
369
|
+
body: Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
|
370
|
+
footer: ["divider", "Allow", "Deny", "help"],
|
|
371
|
+
color: plain,
|
|
372
|
+
});
|
|
373
|
+
expect(result.pinnedRowsFit).toBe(false);
|
|
374
|
+
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
375
|
+
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("frameModal never exceeds maxHeight across a range of budgets", () => {
|
|
379
|
+
for (const maxHeight of [1, 2, 3, 4, 5, 6, 8, 12, 16, 24, 32]) {
|
|
380
|
+
const result = frameModal({
|
|
381
|
+
width: 40,
|
|
382
|
+
maxHeight,
|
|
383
|
+
header: ["title"],
|
|
384
|
+
body: Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
|
385
|
+
footer: ["divider", "Allow", "Deny", "help"],
|
|
386
|
+
color: plain,
|
|
387
|
+
});
|
|
388
|
+
expect(result.lines.length).toBeLessThanOrEqual(maxHeight);
|
|
389
|
+
for (const line of result.lines) expect(visibleWidth(line)).toBe(40);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a permission-shaped modal fits its controls at MIN_PERMISSION_MODAL_HEIGHT", () => {
|
|
394
|
+
// divider + countdown + 2 choices + blank + help = 6 pinned footer rows.
|
|
395
|
+
const footer = ["divider", "auto-deny in 5s", "Allow", "Deny", "", "help"];
|
|
396
|
+
const result = frameModal({
|
|
397
|
+
width: 40,
|
|
398
|
+
maxHeight: MIN_PERMISSION_MODAL_HEIGHT,
|
|
399
|
+
header: ["DANGEROUS"],
|
|
400
|
+
body: Array.from({ length: 30 }, (_, i) => `arg-${i}`),
|
|
401
|
+
footer,
|
|
402
|
+
color: plain,
|
|
403
|
+
});
|
|
404
|
+
expect(result.pinnedRowsFit).toBe(true);
|
|
405
|
+
expect(result.lines.length).toBeLessThanOrEqual(MIN_PERMISSION_MODAL_HEIGHT);
|
|
406
|
+
expect(result.lines.join("\n")).toContain("Allow");
|
|
407
|
+
expect(result.lines.join("\n")).toContain("Deny");
|
|
408
|
+
expect(result.visibleBodyLines).toBeGreaterThanOrEqual(1);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("frameModal delegates styling to frameLines (ANSI width + bg reassertion)", () => {
|
|
412
|
+
const result = frameModal({
|
|
413
|
+
width: 30,
|
|
414
|
+
maxHeight: 8,
|
|
415
|
+
header: ["\x1b[1mbold title\x1b[0m"],
|
|
416
|
+
body: ["plain body"],
|
|
417
|
+
footer: ["help"],
|
|
418
|
+
color: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
419
|
+
bg: (s) => `\x1b[44m${s}\x1b[49m`,
|
|
420
|
+
fg: (s) => `\x1b[37m${s}\x1b[39m`,
|
|
421
|
+
});
|
|
422
|
+
for (const line of result.lines) expect(visibleWidth(line)).toBe(30);
|
|
423
|
+
// The embedded \x1b[0m from the bold header must not punch a hole in the bg.
|
|
424
|
+
const header = result.lines[1] as string;
|
|
425
|
+
expect(header).toContain("\x1b[0m\x1b[44m");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("frameModal supports a pinned top row and still respects maxHeight", () => {
|
|
429
|
+
const result = frameModal({
|
|
430
|
+
width: 40,
|
|
431
|
+
maxHeight: 8,
|
|
432
|
+
top: "tab bar",
|
|
433
|
+
header: ["title"],
|
|
434
|
+
body: Array.from({ length: 20 }, (_, i) => `row ${i}`),
|
|
435
|
+
footer: ["help"],
|
|
436
|
+
color: plain,
|
|
437
|
+
});
|
|
438
|
+
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
439
|
+
expect(result.lines.join("\n")).toContain("tab bar");
|
|
440
|
+
expect(result.lines.join("\n")).toContain("help");
|
|
441
|
+
});
|