@xynogen/pix-pretty 1.19.0 → 1.20.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 +4 -2
- package/src/test-utils.ts +100 -0
- package/src/utils.ts +7 -2
- package/src/ansi.test.ts +0 -12
- package/src/dependency-security.test.ts +0 -32
- package/src/diff.test.ts +0 -117
- package/src/gate-overlay.test.ts +0 -382
- package/src/highlight.test.ts +0 -36
- package/src/icon-catalog.test.ts +0 -97
- package/src/icon-persist.test.ts +0 -59
- package/src/icons.test.ts +0 -35
- package/src/index.test.ts +0 -13
- package/src/modal-frame.test.ts +0 -445
- package/src/utils.test.ts +0 -576
- package/src/widget-format.test.ts +0 -127
package/src/highlight.test.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { _cache, clearHighlightCache, hlBlock } from "./highlight.ts";
|
|
3
|
-
|
|
4
|
-
function theme(color: string) {
|
|
5
|
-
return {
|
|
6
|
-
fg: (key: string, text: string) => `\x1b[38;2;${color}m${key}:${text}\x1b[0m`,
|
|
7
|
-
getFgAnsi: (key: string) => `\x1b[38;2;${color}m:${key}`,
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe("active-theme syntax highlighting", () => {
|
|
12
|
-
beforeEach(() => clearHighlightCache());
|
|
13
|
-
|
|
14
|
-
test("maps JSON scopes to semantic Pi syntax roles", async () => {
|
|
15
|
-
const out = (await hlBlock('{"name":"pix","count":2}', "json", theme("10;20;30"))).join("\n");
|
|
16
|
-
expect(out).toContain("syntaxVariable");
|
|
17
|
-
expect(out).toContain("syntaxString");
|
|
18
|
-
expect(out).toContain("syntaxNumber");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("separates cached output by active theme colors", async () => {
|
|
22
|
-
await hlBlock("const value = 1", "typescript", theme("10;20;30"));
|
|
23
|
-
await hlBlock("const value = 1", "typescript", theme("30;40;50"));
|
|
24
|
-
expect(_cache.size).toBe(2);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("bails to plain (never highlights) when any line exceeds the per-line guard", async () => {
|
|
28
|
-
// Regression: a single multi-KB JSON string value made cli-highlight's
|
|
29
|
-
// tokenizer backtrack and froze the render thread. The guard returns the
|
|
30
|
-
// block unhighlighted (no ANSI, not cached) instead of tokenizing it.
|
|
31
|
-
const mega = JSON.stringify({ blurb: "x".repeat(5000) });
|
|
32
|
-
const out = await hlBlock(mega, "json", theme("10;20;30"));
|
|
33
|
-
expect(out.join("\n")).toBe(mega); // untouched, no ANSI escapes injected
|
|
34
|
-
expect(_cache.size).toBe(0); // not cached — it never went through highlight()
|
|
35
|
-
});
|
|
36
|
-
});
|
package/src/icon-catalog.test.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
getIconMode,
|
|
4
|
-
ICON_KEYS,
|
|
5
|
-
ICON_MODES,
|
|
6
|
-
icon,
|
|
7
|
-
iconFor,
|
|
8
|
-
onIconModeChange,
|
|
9
|
-
setIconMode,
|
|
10
|
-
} from "./icon-catalog.ts";
|
|
11
|
-
|
|
12
|
-
describe("icon-catalog", () => {
|
|
13
|
-
afterEach(() => setIconMode("nerd")); // restore default for other suites
|
|
14
|
-
|
|
15
|
-
it("exposes nerd/unicode/ascii in cycle order", () => {
|
|
16
|
-
expect([...ICON_MODES]).toEqual(["nerd", "unicode", "ascii"]);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("resolves a key against the active mode", () => {
|
|
20
|
-
setIconMode("ascii");
|
|
21
|
-
expect(icon("cwd")).toBe("~");
|
|
22
|
-
setIconMode("unicode");
|
|
23
|
-
expect(icon("cwd")).toBe("\u2302\uFE0E");
|
|
24
|
-
setIconMode("nerd");
|
|
25
|
-
expect(icon("cwd")).toBe("\u{F024B}");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("iconFor resolves without touching the active mode", () => {
|
|
29
|
-
setIconMode("nerd");
|
|
30
|
-
expect(iconFor("opt.caveman", "ascii")).toBe("Cv");
|
|
31
|
-
expect(getIconMode()).toBe("nerd"); // unchanged
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("provides AFK keyboard fallbacks for every icon mode", () => {
|
|
35
|
-
expect(iconFor("afk", "nerd")).toBe("\u{F0310}");
|
|
36
|
-
expect(iconFor("afk", "unicode")).toBe("\u2328\uFE0E");
|
|
37
|
-
expect(iconFor("afk", "ascii")).toBe("kbd");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("every catalog key has a non-empty glyph in every mode", () => {
|
|
41
|
-
for (const mode of ICON_MODES) {
|
|
42
|
-
for (const key of ICON_KEYS) {
|
|
43
|
-
expect(iconFor(key, mode).length).toBeGreaterThan(0);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("status family keeps historical nerd glyphs and gains ascii tokens", () => {
|
|
49
|
-
// nerd mode must equal the pre-catalog literals so mixed-glyph rows and
|
|
50
|
-
// existing snapshot assertions stay aligned.
|
|
51
|
-
expect(iconFor("status.ok", "nerd")).toBe("\u2713");
|
|
52
|
-
expect(iconFor("status.error", "nerd")).toBe("\u2717");
|
|
53
|
-
expect(iconFor("status.warn", "nerd")).toBe("\u26A0");
|
|
54
|
-
expect(iconFor("status.pending", "nerd")).toBe("\u25CB");
|
|
55
|
-
expect(iconFor("status.running", "nerd")).toBe("\u25D0");
|
|
56
|
-
expect(iconFor("status.active", "nerd")).toBe("\u25CF");
|
|
57
|
-
expect(iconFor("status.done", "nerd")).toBe("\u25CF");
|
|
58
|
-
expect(iconFor("status.blocked", "nerd")).toBe("\u2298");
|
|
59
|
-
// ascii mode must be tofu-free (letters/punctuation only).
|
|
60
|
-
for (const key of [
|
|
61
|
-
"status.ok",
|
|
62
|
-
"status.error",
|
|
63
|
-
"status.warn",
|
|
64
|
-
"status.pending",
|
|
65
|
-
"status.running",
|
|
66
|
-
"status.active",
|
|
67
|
-
"status.done",
|
|
68
|
-
"status.blocked",
|
|
69
|
-
] as const) {
|
|
70
|
-
expect(iconFor(key, "ascii")).toMatch(/^[\x20-\x7e]+$/);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("unknown key fails soft to empty string", () => {
|
|
75
|
-
// @ts-expect-error exercising the runtime guard
|
|
76
|
-
expect(icon("does.not.exist")).toBe("");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("setIconMode ignores an invalid mode", () => {
|
|
80
|
-
setIconMode("unicode");
|
|
81
|
-
// @ts-expect-error invalid mode must be rejected, leaving prior value
|
|
82
|
-
setIconMode("bogus");
|
|
83
|
-
expect(getIconMode()).toBe("unicode");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("notifies subscribers on an actual change, not on no-ops", () => {
|
|
87
|
-
setIconMode("nerd");
|
|
88
|
-
const seen: string[] = [];
|
|
89
|
-
const off = onIconModeChange((m) => seen.push(m));
|
|
90
|
-
setIconMode("nerd"); // no-op — must NOT fire
|
|
91
|
-
setIconMode("ascii"); // change — fires
|
|
92
|
-
setIconMode("ascii"); // no-op — must NOT fire
|
|
93
|
-
off();
|
|
94
|
-
setIconMode("unicode"); // after unsubscribe — must NOT fire
|
|
95
|
-
expect(seen).toEqual(["ascii"]);
|
|
96
|
-
});
|
|
97
|
-
});
|
package/src/icon-persist.test.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { reloadConfig } from "@xynogen/pix-runtime/config";
|
|
6
|
-
import { getIconMode, setIconMode } from "./icon-catalog.ts";
|
|
7
|
-
import { initIconMode, loadIconMode, saveIconMode } from "./icon-persist.ts";
|
|
8
|
-
|
|
9
|
-
let tmpAgentDir: string;
|
|
10
|
-
let origHome: string | undefined;
|
|
11
|
-
|
|
12
|
-
beforeAll(async () => {
|
|
13
|
-
tmpAgentDir = mkdtempSync(join(tmpdir(), "pretty-persist-test-"));
|
|
14
|
-
origHome = process.env.HOME;
|
|
15
|
-
// Point HOME at the temp dir so the runtime reads from there, not the real ~/.pi/agent/pix.json
|
|
16
|
-
process.env.HOME = tmpAgentDir;
|
|
17
|
-
process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
|
|
18
|
-
// Drop any singleton created by earlier test files (it is bound to the old
|
|
19
|
-
// agent dir); the next accessor call lazily recreates it under the temp HOME.
|
|
20
|
-
delete (globalThis as Record<symbol, unknown>)[Symbol.for("@xynogen/pix-runtime")];
|
|
21
|
-
await reloadConfig();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterAll(() => {
|
|
25
|
-
process.env.HOME = origHome;
|
|
26
|
-
delete process.env.PI_CODING_AGENT_DIR;
|
|
27
|
-
// Drop the temp-HOME-bound singleton so later test files get a fresh one.
|
|
28
|
-
delete (globalThis as Record<symbol, unknown>)[Symbol.for("@xynogen/pix-runtime")];
|
|
29
|
-
try {
|
|
30
|
-
rmSync(tmpAgentDir, { recursive: true });
|
|
31
|
-
} catch {
|
|
32
|
-
// already gone — ignore
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("icon-persist", () => {
|
|
37
|
-
afterEach(() => setIconMode("nerd"));
|
|
38
|
-
|
|
39
|
-
it("returns default (nerd) in a fresh config", () => {
|
|
40
|
-
expect(loadIconMode()).toBe("nerd");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("round-trips a mode across save/load (new-session sim)", async () => {
|
|
44
|
-
await saveIconMode("unicode");
|
|
45
|
-
expect(loadIconMode()).toBe("unicode");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("rejects an invalid persisted mode", async () => {
|
|
49
|
-
await saveIconMode("ascii");
|
|
50
|
-
expect(loadIconMode()).toBe("ascii");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("initIconMode applies the persisted choice to the catalog", async () => {
|
|
54
|
-
await saveIconMode("ascii");
|
|
55
|
-
setIconMode("nerd"); // pretend env default
|
|
56
|
-
initIconMode();
|
|
57
|
-
expect(getIconMode()).toBe("ascii");
|
|
58
|
-
});
|
|
59
|
-
});
|
package/src/icons.test.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { dirIcon, fileColor, fileIcon } from "./icons.ts";
|
|
3
|
-
|
|
4
|
-
const theme = {
|
|
5
|
-
fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
describe("theme-derived file icons", () => {
|
|
9
|
-
test("uses semantic theme roles instead of embedded ANSI colors", () => {
|
|
10
|
-
expect(fileIcon("example.ts", theme)).toContain("<syntaxType>");
|
|
11
|
-
expect(fileIcon("data.json", theme)).toContain("<syntaxNumber>");
|
|
12
|
-
expect(fileIcon("unknown.zzz", theme)).toContain("<muted>");
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("themes directory icons with the active accent", () => {
|
|
16
|
-
expect(dirIcon(theme)).toContain("<accent>");
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("theme-derived file name color", () => {
|
|
21
|
-
test("colors a filename with the same role as its icon", () => {
|
|
22
|
-
expect(fileColor("example.ts", "example.ts", theme)).toBe(
|
|
23
|
-
"<syntaxType>example.ts</syntaxType>",
|
|
24
|
-
);
|
|
25
|
-
expect(fileColor("package.json", "package.json", theme)).toContain("<syntaxString>");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("falls back to the text role for an unknown extension", () => {
|
|
29
|
-
expect(fileColor("notes.zzz", "notes.zzz", theme)).toBe("<text>notes.zzz</text>");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("passes the name through unchanged when no theme is supplied", () => {
|
|
33
|
-
expect(fileColor("example.ts", "example.ts")).toBe("example.ts");
|
|
34
|
-
});
|
|
35
|
-
});
|
package/src/index.test.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smoke tests for pix-pretty (pure lib).
|
|
3
|
-
* UI extension tests moved to pix-display.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, expect, it } from "bun:test";
|
|
7
|
-
|
|
8
|
-
describe("pix-pretty", () => {
|
|
9
|
-
it("main module exports a function", async () => {
|
|
10
|
-
const mod = await import("./index");
|
|
11
|
-
expect(mod.default).toBeFunction();
|
|
12
|
-
});
|
|
13
|
-
});
|
package/src/modal-frame.test.ts
DELETED
|
@@ -1,445 +0,0 @@
|
|
|
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 resolves configured percentages and rows without exceeding the terminal", () => {
|
|
31
|
-
expect(modalHeight(40)).toBe(32);
|
|
32
|
-
expect(modalHeight(40, "50%")).toBe(20);
|
|
33
|
-
expect(modalHeight(40, 12)).toBe(12);
|
|
34
|
-
expect(modalHeight(10)).toBe(8);
|
|
35
|
-
expect(modalHeight(1)).toBe(1);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("modalHeight is defensive about junk row counts", () => {
|
|
39
|
-
expect(modalHeight(Number.NaN)).toBe(19); // falls back to 24 rows
|
|
40
|
-
expect(modalHeight(Number.POSITIVE_INFINITY)).toBe(19);
|
|
41
|
-
expect(modalHeight(0)).toBe(1);
|
|
42
|
-
expect(modalHeight(-5)).toBe(1);
|
|
43
|
-
expect(modalHeight(40, "100%")).toBe(40);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("modalWidth resolves configured percentages and columns without exceeding available width", () => {
|
|
47
|
-
expect(modalWidth(200)).toBe(200);
|
|
48
|
-
expect(modalWidth(200, "50%")).toBe(100);
|
|
49
|
-
expect(modalWidth(200, 88)).toBe(88);
|
|
50
|
-
expect(modalWidth(50)).toBe(50);
|
|
51
|
-
expect(modalWidth(10)).toBe(10);
|
|
52
|
-
expect(modalWidth(1)).toBe(1);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("fitModalLine distinguishes lossless wrapping from irreversible truncation", () => {
|
|
56
|
-
const long = "A".repeat(300);
|
|
57
|
-
const wrapped = fitModalLine(long, 20);
|
|
58
|
-
expect(wrapped.truncated).toBe(false);
|
|
59
|
-
expect(wrapped.rows.length).toBeGreaterThan(1);
|
|
60
|
-
expect(wrapped.rows.every((line) => visibleWidth(line) <= 20)).toBe(true);
|
|
61
|
-
|
|
62
|
-
const cut = fitModalLine(long, 20, false);
|
|
63
|
-
expect(cut.truncated).toBe(true);
|
|
64
|
-
expect(cut.rows).toHaveLength(1);
|
|
65
|
-
expect(cut.rows[0]).toContain("…");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("frameLines uses an unframed fallback when border chrome cannot fit", () => {
|
|
69
|
-
for (const width of [1, 2, 3]) {
|
|
70
|
-
const lines = frameLines({ width, lines: ["content"], color: plain });
|
|
71
|
-
expect(lines).toHaveLength(1);
|
|
72
|
-
expect(visibleWidth(lines[0] ?? "")).toBeLessThanOrEqual(width);
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
function pagerWithViewport(): ModalPager {
|
|
77
|
-
const pager = new ModalPager();
|
|
78
|
-
pager.sync({
|
|
79
|
-
lines: [],
|
|
80
|
-
bodyOffset: 0,
|
|
81
|
-
maxBodyOffset: 12,
|
|
82
|
-
visibleBodyLines: 6,
|
|
83
|
-
bodyOverflowed: true,
|
|
84
|
-
textTruncated: false,
|
|
85
|
-
pinnedRowsFit: true,
|
|
86
|
-
});
|
|
87
|
-
const pageDown = "\x1b[6~";
|
|
88
|
-
const pageUp = "\x1b[5~";
|
|
89
|
-
// Half-page scroll: step = floor(6/2) = 3
|
|
90
|
-
expect(pager.handleInput(pageDown)).toBe(true);
|
|
91
|
-
expect(pager.bodyOffset).toBe(3);
|
|
92
|
-
expect(pager.handleInput(pageDown)).toBe(true);
|
|
93
|
-
expect(pager.bodyOffset).toBe(6);
|
|
94
|
-
expect(pager.handleInput(pageDown)).toBe(true);
|
|
95
|
-
expect(pager.bodyOffset).toBe(9);
|
|
96
|
-
expect(pager.handleInput(pageDown)).toBe(true);
|
|
97
|
-
expect(pager.bodyOffset).toBe(12);
|
|
98
|
-
expect(pager.handleInput(pageDown)).toBe(false);
|
|
99
|
-
expect(pager.handleInput(pageUp)).toBe(true);
|
|
100
|
-
expect(pager.bodyOffset).toBe(9);
|
|
101
|
-
return pager;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
test("ModalPager handles legacy PageUp/PageDown through pi-tui", () => {
|
|
105
|
-
pagerWithViewport();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("ModalPager handles Kitty functional PageUp/PageDown through pi-tui", () => {
|
|
109
|
-
setKittyProtocolActive(true);
|
|
110
|
-
try {
|
|
111
|
-
const pager = new ModalPager();
|
|
112
|
-
pager.sync({
|
|
113
|
-
lines: [],
|
|
114
|
-
bodyOffset: 0,
|
|
115
|
-
maxBodyOffset: 12,
|
|
116
|
-
visibleBodyLines: 6,
|
|
117
|
-
bodyOverflowed: true,
|
|
118
|
-
textTruncated: false,
|
|
119
|
-
pinnedRowsFit: true,
|
|
120
|
-
});
|
|
121
|
-
// Kitty functional key codepoints from pi-tui's own key table.
|
|
122
|
-
// Half-page scroll: step = floor(6/2) = 3
|
|
123
|
-
expect(pager.handleInput("\x1b[57422u")).toBe(true);
|
|
124
|
-
expect(pager.bodyOffset).toBe(3);
|
|
125
|
-
expect(pager.handleInput("\x1b[57421u")).toBe(true);
|
|
126
|
-
expect(pager.bodyOffset).toBe(0);
|
|
127
|
-
} finally {
|
|
128
|
-
setKittyProtocolActive(false);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("ModalPager accepts arrow left/right as page up/down when arrowPages is true", () => {
|
|
133
|
-
const pager = new ModalPager();
|
|
134
|
-
pager.sync({
|
|
135
|
-
lines: [],
|
|
136
|
-
bodyOffset: 0,
|
|
137
|
-
maxBodyOffset: 12,
|
|
138
|
-
visibleBodyLines: 6,
|
|
139
|
-
bodyOverflowed: true,
|
|
140
|
-
textTruncated: false,
|
|
141
|
-
pinnedRowsFit: true,
|
|
142
|
-
});
|
|
143
|
-
const arrowRight = "\x1b[C";
|
|
144
|
-
const arrowLeft = "\x1b[D";
|
|
145
|
-
// Without arrowPages, arrows are ignored
|
|
146
|
-
expect(pager.handleInput(arrowRight)).toBe(false);
|
|
147
|
-
expect(pager.bodyOffset).toBe(0);
|
|
148
|
-
// With arrowPages enabled — half-page scroll: step = floor(6/2) = 3
|
|
149
|
-
expect(pager.handleInput(arrowRight, undefined, true)).toBe(true);
|
|
150
|
-
expect(pager.bodyOffset).toBe(3);
|
|
151
|
-
expect(pager.handleInput(arrowLeft, undefined, true)).toBe(true);
|
|
152
|
-
expect(pager.bodyOffset).toBe(0);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("ModalPager arrow paging works under Kitty protocol", () => {
|
|
156
|
-
setKittyProtocolActive(true);
|
|
157
|
-
try {
|
|
158
|
-
const pager = new ModalPager();
|
|
159
|
-
pager.sync({
|
|
160
|
-
lines: [],
|
|
161
|
-
bodyOffset: 0,
|
|
162
|
-
maxBodyOffset: 10,
|
|
163
|
-
visibleBodyLines: 6,
|
|
164
|
-
bodyOverflowed: true,
|
|
165
|
-
textTruncated: false,
|
|
166
|
-
pinnedRowsFit: true,
|
|
167
|
-
});
|
|
168
|
-
const arrowRight = "\x1b[C";
|
|
169
|
-
const arrowLeft = "\x1b[D";
|
|
170
|
-
// Half-page scroll: step = floor(6/2) = 3
|
|
171
|
-
expect(pager.handleInput(arrowRight, undefined, true)).toBe(true);
|
|
172
|
-
expect(pager.bodyOffset).toBe(3);
|
|
173
|
-
expect(pager.handleInput(arrowLeft, undefined, true)).toBe(true);
|
|
174
|
-
expect(pager.bodyOffset).toBe(0);
|
|
175
|
-
} finally {
|
|
176
|
-
setKittyProtocolActive(false);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("modalBodyCapacity subtracts borders and pinned rows", () => {
|
|
181
|
-
expect(modalBodyCapacity(16, 6)).toBe(8);
|
|
182
|
-
expect(modalBodyCapacity(10, 20)).toBe(0);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("ensureVisibleOffset keeps a selected rendered-row range in view", () => {
|
|
186
|
-
expect(ensureVisibleOffset(0, 5, 20, 0, 2)).toBe(0);
|
|
187
|
-
expect(ensureVisibleOffset(0, 5, 20, 7, 9)).toBe(4);
|
|
188
|
-
expect(ensureVisibleOffset(10, 5, 20, 7, 9)).toBe(7);
|
|
189
|
-
expect(ensureVisibleOffset(99, 5, 20, 19, 20)).toBe(15);
|
|
190
|
-
expect(ensureVisibleOffset(0, 0, 20, 7, 9)).toBe(0);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("frameModal stays within maxHeight and pins footer controls", () => {
|
|
194
|
-
const result = render();
|
|
195
|
-
expect(result.lines).toHaveLength(10);
|
|
196
|
-
expect(result.lines.join("\n")).toContain("title");
|
|
197
|
-
expect(result.lines.join("\n")).toContain("Allow");
|
|
198
|
-
expect(result.lines.join("\n")).toContain("Deny");
|
|
199
|
-
expect(result.lines.join("\n")).toContain("help");
|
|
200
|
-
expect(result.maxBodyOffset).toBeGreaterThan(0);
|
|
201
|
-
expect(result.bodyOverflowed).toBe(true);
|
|
202
|
-
expect(result.textTruncated).toBe(false);
|
|
203
|
-
for (const line of result.lines) expect(visibleWidth(line)).toBe(40);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("frameModal reports intentional fixed-row truncation separately from paging", () => {
|
|
207
|
-
const result = frameModal({
|
|
208
|
-
width: 40,
|
|
209
|
-
maxHeight: 10,
|
|
210
|
-
body: ["X".repeat(100)],
|
|
211
|
-
wrap: false,
|
|
212
|
-
color: plain,
|
|
213
|
-
});
|
|
214
|
-
expect(result.textTruncated).toBe(true);
|
|
215
|
-
expect(result.lines.join("\n")).toContain("…");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("frameModal wraps long text before applying the height budget", () => {
|
|
219
|
-
const result = frameModal({
|
|
220
|
-
width: 40,
|
|
221
|
-
maxHeight: 8,
|
|
222
|
-
body: ["command ".repeat(60)],
|
|
223
|
-
color: plain,
|
|
224
|
-
});
|
|
225
|
-
expect(result.bodyOverflowed).toBe(true);
|
|
226
|
-
expect(result.textTruncated).toBe(false);
|
|
227
|
-
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("frameModal pages the body without losing pinned rows", () => {
|
|
231
|
-
const first = render(0);
|
|
232
|
-
const last = render(first.maxBodyOffset);
|
|
233
|
-
expect(first.lines.join("\n")).toContain("body 1");
|
|
234
|
-
expect(first.lines.join("\n")).not.toContain("body 12");
|
|
235
|
-
expect(last.lines.join("\n")).toContain("body 12");
|
|
236
|
-
expect(last.lines.join("\n")).toContain("Allow");
|
|
237
|
-
expect(last.bodyOffset).toBe(last.maxBodyOffset);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test("frameModal clamps an out-of-range bodyOffset", () => {
|
|
241
|
-
const high = render(9999);
|
|
242
|
-
expect(high.bodyOffset).toBe(high.maxBodyOffset);
|
|
243
|
-
const low = frameModal({
|
|
244
|
-
width: 40,
|
|
245
|
-
maxHeight: 10,
|
|
246
|
-
body: ["a", "b"],
|
|
247
|
-
bodyOffset: -20,
|
|
248
|
-
color: plain,
|
|
249
|
-
});
|
|
250
|
-
expect(low.bodyOffset).toBe(0);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("frameModal reports hidden rows via the overflow indicator", () => {
|
|
254
|
-
const seen: string[] = [];
|
|
255
|
-
frameModal({
|
|
256
|
-
width: 40,
|
|
257
|
-
maxHeight: 10,
|
|
258
|
-
header: ["title"],
|
|
259
|
-
body: Array.from({ length: 12 }, (_, i) => `body ${i + 1}`),
|
|
260
|
-
footer: ["divider", "Allow", "Deny", "help"],
|
|
261
|
-
color: plain,
|
|
262
|
-
overflowLine: (state) => {
|
|
263
|
-
seen.push(`${state.start}-${state.end}/${state.total}+${state.hiddenAfter}`);
|
|
264
|
-
return "overflow";
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
expect(seen).toEqual(["0-2/12+10"]);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("frameModal overflow page number reaches totalPages on last page", () => {
|
|
271
|
-
const pages: string[] = [];
|
|
272
|
-
const body = Array.from({ length: 20 }, (_, i) => `line ${i}`);
|
|
273
|
-
// maxHeight=12: 2 border + 1 header + 1 footer + 1 overflow = 5 chrome → 7 visible body
|
|
274
|
-
// maxBodyOffset = 20-7 = 13, step = floor(7/2) = 3
|
|
275
|
-
// totalPages = ceil(13/3)+1 = 5+1 = 6 (but actually ceil(13/3)=5, +1=6)
|
|
276
|
-
// Half-page offsets: 0, 3, 6, 9, 12, 13(max)
|
|
277
|
-
const offsets = [0, 3, 6, 9, 12, 13];
|
|
278
|
-
for (const offset of offsets) {
|
|
279
|
-
frameModal({
|
|
280
|
-
width: 40,
|
|
281
|
-
maxHeight: 12,
|
|
282
|
-
header: ["title"],
|
|
283
|
-
body,
|
|
284
|
-
footer: ["help"],
|
|
285
|
-
bodyOffset: offset,
|
|
286
|
-
color: plain,
|
|
287
|
-
overflowLine: (state) => {
|
|
288
|
-
pages.push(`${state.page}/${state.totalPages}`);
|
|
289
|
-
return `page ${state.page}/${state.totalPages}`;
|
|
290
|
-
},
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
// page must reach totalPages at maxBodyOffset
|
|
294
|
-
expect(pages[pages.length - 1]).toMatch(/^\d+\/\d+$/);
|
|
295
|
-
const last = pages[pages.length - 1]!.split("/");
|
|
296
|
-
expect(last[0]).toBe(last[1]); // last page == totalPages
|
|
297
|
-
// pages must be monotonically non-decreasing
|
|
298
|
-
const nums = pages.map((p) => Number(p.split("/")[0]));
|
|
299
|
-
for (let i = 1; i < nums.length; i++) {
|
|
300
|
-
expect(nums[i]).toBeGreaterThanOrEqual(nums[i - 1]!);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("frameModal omits the overflow row when everything fits", () => {
|
|
305
|
-
const result = frameModal({
|
|
306
|
-
width: 40,
|
|
307
|
-
maxHeight: 12,
|
|
308
|
-
header: ["title"],
|
|
309
|
-
body: ["only"],
|
|
310
|
-
footer: ["help"],
|
|
311
|
-
color: plain,
|
|
312
|
-
});
|
|
313
|
-
expect(result.lines.join("\n")).not.toContain("PageUp/PageDown");
|
|
314
|
-
expect(result.maxBodyOffset).toBe(0);
|
|
315
|
-
expect(result.pinnedRowsFit).toBe(true);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
test("frameModal honors an explicit fail-closed minimum height", () => {
|
|
319
|
-
const result = frameModal({
|
|
320
|
-
width: 40,
|
|
321
|
-
maxHeight: MIN_PERMISSION_MODAL_HEIGHT - 1,
|
|
322
|
-
minHeight: MIN_PERMISSION_MODAL_HEIGHT,
|
|
323
|
-
header: ["DANGEROUS"],
|
|
324
|
-
body: ["command"],
|
|
325
|
-
footer: ["Allow", "Deny"],
|
|
326
|
-
color: plain,
|
|
327
|
-
});
|
|
328
|
-
expect(result.pinnedRowsFit).toBe(false);
|
|
329
|
-
expect(result.lines.join("\n")).toContain("Terminal too short");
|
|
330
|
-
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test("frameModal reports when pinned rows cannot fit", () => {
|
|
334
|
-
const result = frameModal({
|
|
335
|
-
width: 40,
|
|
336
|
-
maxHeight: 5,
|
|
337
|
-
header: ["title"],
|
|
338
|
-
body: ["command"],
|
|
339
|
-
footer: ["one", "two", "three"],
|
|
340
|
-
color: plain,
|
|
341
|
-
});
|
|
342
|
-
expect(result.pinnedRowsFit).toBe(false);
|
|
343
|
-
expect(result.lines.length).toBeLessThanOrEqual(5);
|
|
344
|
-
// Fail-closed frame keeps its borders and offers no approval control.
|
|
345
|
-
expect(result.lines.at(0)).toContain("╭");
|
|
346
|
-
expect(result.lines.at(-1)).toContain("╰");
|
|
347
|
-
expect(result.lines.join("\n")).toContain("Terminal too short");
|
|
348
|
-
expect(result.lines.join("\n")).not.toContain("two");
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("frameModal uses an unframed fail-closed diagnostic below border capacity", () => {
|
|
352
|
-
for (const maxHeight of [1, 2]) {
|
|
353
|
-
const result = frameModal({
|
|
354
|
-
width: 20,
|
|
355
|
-
maxHeight,
|
|
356
|
-
header: ["DANGEROUS"],
|
|
357
|
-
body: ["command"],
|
|
358
|
-
footer: ["Allow", "Deny"],
|
|
359
|
-
color: plain,
|
|
360
|
-
});
|
|
361
|
-
expect(result.pinnedRowsFit).toBe(false);
|
|
362
|
-
expect(result.lines.length).toBeLessThanOrEqual(maxHeight);
|
|
363
|
-
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
364
|
-
for (const line of result.lines) expect(visibleWidth(line)).toBeLessThanOrEqual(20);
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
test("frameModal fails closed when overflow cannot fit an indicator and body row", () => {
|
|
369
|
-
const result = frameModal({
|
|
370
|
-
width: 40,
|
|
371
|
-
maxHeight: 8,
|
|
372
|
-
header: ["title"],
|
|
373
|
-
body: Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
|
374
|
-
footer: ["divider", "Allow", "Deny", "help"],
|
|
375
|
-
color: plain,
|
|
376
|
-
});
|
|
377
|
-
expect(result.pinnedRowsFit).toBe(false);
|
|
378
|
-
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
379
|
-
expect(result.lines.join("\n")).not.toContain("Allow");
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
test("frameModal never exceeds maxHeight across a range of budgets", () => {
|
|
383
|
-
for (const maxHeight of [1, 2, 3, 4, 5, 6, 8, 12, 16, 24, 32]) {
|
|
384
|
-
const result = frameModal({
|
|
385
|
-
width: 40,
|
|
386
|
-
maxHeight,
|
|
387
|
-
header: ["title"],
|
|
388
|
-
body: Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
|
389
|
-
footer: ["divider", "Allow", "Deny", "help"],
|
|
390
|
-
color: plain,
|
|
391
|
-
});
|
|
392
|
-
expect(result.lines.length).toBeLessThanOrEqual(maxHeight);
|
|
393
|
-
for (const line of result.lines) expect(visibleWidth(line)).toBe(40);
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
test("a permission-shaped modal fits its controls at MIN_PERMISSION_MODAL_HEIGHT", () => {
|
|
398
|
-
// divider + countdown + 2 choices + blank + help = 6 pinned footer rows.
|
|
399
|
-
const footer = ["divider", "auto-deny in 5s", "Allow", "Deny", "", "help"];
|
|
400
|
-
const result = frameModal({
|
|
401
|
-
width: 40,
|
|
402
|
-
maxHeight: MIN_PERMISSION_MODAL_HEIGHT,
|
|
403
|
-
header: ["DANGEROUS"],
|
|
404
|
-
body: Array.from({ length: 30 }, (_, i) => `arg-${i}`),
|
|
405
|
-
footer,
|
|
406
|
-
color: plain,
|
|
407
|
-
});
|
|
408
|
-
expect(result.pinnedRowsFit).toBe(true);
|
|
409
|
-
expect(result.lines.length).toBeLessThanOrEqual(MIN_PERMISSION_MODAL_HEIGHT);
|
|
410
|
-
expect(result.lines.join("\n")).toContain("Allow");
|
|
411
|
-
expect(result.lines.join("\n")).toContain("Deny");
|
|
412
|
-
expect(result.visibleBodyLines).toBeGreaterThanOrEqual(1);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
test("frameModal delegates styling to frameLines (ANSI width + bg reassertion)", () => {
|
|
416
|
-
const result = frameModal({
|
|
417
|
-
width: 30,
|
|
418
|
-
maxHeight: 8,
|
|
419
|
-
header: ["\x1b[1mbold title\x1b[0m"],
|
|
420
|
-
body: ["plain body"],
|
|
421
|
-
footer: ["help"],
|
|
422
|
-
color: (s) => `\x1b[36m${s}\x1b[39m`,
|
|
423
|
-
bg: (s) => `\x1b[44m${s}\x1b[49m`,
|
|
424
|
-
fg: (s) => `\x1b[37m${s}\x1b[39m`,
|
|
425
|
-
});
|
|
426
|
-
for (const line of result.lines) expect(visibleWidth(line)).toBe(30);
|
|
427
|
-
// The embedded \x1b[0m from the bold header must not punch a hole in the bg.
|
|
428
|
-
const header = result.lines[1] as string;
|
|
429
|
-
expect(header).toContain("\x1b[0m\x1b[44m");
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
test("frameModal supports a pinned top row and still respects maxHeight", () => {
|
|
433
|
-
const result = frameModal({
|
|
434
|
-
width: 40,
|
|
435
|
-
maxHeight: 8,
|
|
436
|
-
top: "tab bar",
|
|
437
|
-
header: ["title"],
|
|
438
|
-
body: Array.from({ length: 20 }, (_, i) => `row ${i}`),
|
|
439
|
-
footer: ["help"],
|
|
440
|
-
color: plain,
|
|
441
|
-
});
|
|
442
|
-
expect(result.lines.length).toBeLessThanOrEqual(8);
|
|
443
|
-
expect(result.lines.join("\n")).toContain("tab bar");
|
|
444
|
-
expect(result.lines.join("\n")).toContain("help");
|
|
445
|
-
});
|