gentle-pi 3.2.1 → 3.3.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/docs/gentle-shell.md +27 -14
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +22 -17
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +123 -21
- package/extensions/gentle-shell.ts +379 -31
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/native-review-cli.ts +14 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +246 -171
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +150 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +795 -23
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +19 -1
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +277 -300
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +22 -24
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +192 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
CHANGE_STATUS,
|
|
11
11
|
ChangesTracker,
|
|
12
12
|
WorktreeChangesTracker,
|
|
13
|
+
RootBranchLabels,
|
|
14
|
+
UNKNOWN_BRANCH,
|
|
15
|
+
foreignRootBranch,
|
|
13
16
|
parseWorktrees,
|
|
14
17
|
changesSummary,
|
|
15
18
|
emptyChanges,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
changesModel,
|
|
20
23
|
snapshotChanges,
|
|
21
24
|
type ChangedFile,
|
|
25
|
+
type GitResult,
|
|
22
26
|
} from "../lib/shell-changes.ts";
|
|
23
27
|
|
|
24
28
|
// The changes view shows the working tree against HEAD, new files included,
|
|
@@ -72,6 +76,16 @@ test("parsePorcelain reads NUL-separated status entries including renames and un
|
|
|
72
76
|
);
|
|
73
77
|
});
|
|
74
78
|
|
|
79
|
+
test("parsePorcelain drops embedded-repository markers, never a real file", () => {
|
|
80
|
+
// Git reports a nested .git directory (an embedded/foreign repository, the
|
|
81
|
+
// live evidence's ~/work/NaN-builders inside ~/work) as one directory-shaped
|
|
82
|
+
// "?? path/" line, never expanded into files, even with --untracked-files=all.
|
|
83
|
+
// Treating that line as a changed file creates a phantom entry for whatever
|
|
84
|
+
// ancestor repository happens to contain the nested one.
|
|
85
|
+
const raw = ["?? NaN-builders/", "?? notes.md", " M lib/a.ts"].join("\0") + "\0";
|
|
86
|
+
assert.deepEqual([...parsePorcelain(raw).entries()], [["notes.md", CHANGE_STATUS.UNTRACKED], ["lib/a.ts", CHANGE_STATUS.MODIFIED]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
75
89
|
test("snapshotChanges merges status with counts and keeps untracked files without counts", () => {
|
|
76
90
|
const files = snapshotChanges({
|
|
77
91
|
numstat: "4\t2\tlib/a.ts\n",
|
|
@@ -265,6 +279,114 @@ test("worktree porcelain preserves unusual directory names and detached fallback
|
|
|
265
279
|
assert.deepEqual(parseWorktrees("worktree /a\nbranch strange\0HEAD abc\0detached\0\0"), [{ root: "/a\nbranch strange" }]);
|
|
266
280
|
});
|
|
267
281
|
|
|
282
|
+
// A GitRunner that cannot even ask (root removed, git missing, the runner
|
|
283
|
+
// itself rejecting) must reject foreignRootBranch too -- never resolve to the
|
|
284
|
+
// same undefined a genuinely detached HEAD produces. Swallowing the two into
|
|
285
|
+
// one shape is exactly the bug: the caller has no way left to tell "asked and
|
|
286
|
+
// there is no branch" from "could not ask at all".
|
|
287
|
+
test("foreignRootBranch propagates a GitRunner failure instead of reporting detached", async () => {
|
|
288
|
+
const failing = async (): Promise<GitResult> => { throw new Error("git unavailable"); };
|
|
289
|
+
await assert.rejects(() => foreignRootBranch(failing), /git unavailable/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("foreignRootBranch still reports a genuine detached HEAD as undefined, not a failure", async () => {
|
|
293
|
+
const detached = async (args: string[]): Promise<GitResult> => (args[0] === "symbolic-ref" ? { stdout: "", code: 1 } : { stdout: "deadbeef\n", code: 0 });
|
|
294
|
+
assert.equal(await foreignRootBranch(detached), undefined);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// C3 (odd/tasks/usage-click-and-changes-attribution.md): a root registered by
|
|
298
|
+
// the session but absent from a single `git worktree list` scan -- because it
|
|
299
|
+
// belongs to a different Git clone entirely, e.g. an inner repository nested
|
|
300
|
+
// inside an outer one -- must not default to "detached". It must be asked
|
|
301
|
+
// directly, and "no commits yet" must read differently from a real branch.
|
|
302
|
+
test("a root missing from worktree list resolves its own branch instead of defaulting to detached", async (t) => {
|
|
303
|
+
const scenarios: Array<{ name: string; symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number }; expected: string | undefined }> = [
|
|
304
|
+
{ name: "a normal branch on a foreign clone", symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: "feature" },
|
|
305
|
+
{ name: "an unborn branch (no commits yet)", symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 }, expected: "no commits yet" },
|
|
306
|
+
{ name: "a genuinely detached HEAD", symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: undefined },
|
|
307
|
+
];
|
|
308
|
+
for (const scenario of scenarios) {
|
|
309
|
+
await t.test(scenario.name, async () => {
|
|
310
|
+
const calls: string[][] = [];
|
|
311
|
+
const tracker = new WorktreeChangesTracker(
|
|
312
|
+
async () => ({ stdout: "", code: 0 }), // /foreign never appears in this clone's own worktree list
|
|
313
|
+
() => async (args) => {
|
|
314
|
+
calls.push(args);
|
|
315
|
+
if (args[0] === "status") return { stdout: "?? changed.ts\0", code: 0 };
|
|
316
|
+
if (args[0] === "symbolic-ref") return scenario.symbolic;
|
|
317
|
+
if (args[0] === "rev-parse") return scenario.verify;
|
|
318
|
+
return { stdout: "", code: 0 };
|
|
319
|
+
},
|
|
320
|
+
undefined,
|
|
321
|
+
() => ["/foreign"],
|
|
322
|
+
);
|
|
323
|
+
await tracker.start();
|
|
324
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", scenario.expected]]);
|
|
325
|
+
assert.ok(calls.some((args) => args[0] === "symbolic-ref"), "a root absent from worktree list must be asked directly");
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// The direct per-root ask (for a root missing from worktree list) can fail on
|
|
331
|
+
// its own -- root gone, git unavailable -- independently of the change scan
|
|
332
|
+
// that already found real files. That failure must label the root
|
|
333
|
+
// UNKNOWN_BRANCH, distinct from "detached", and must never drop the
|
|
334
|
+
// already-captured changes.
|
|
335
|
+
test("a root missing from worktree list keeps its changes and reads UNKNOWN_BRANCH when the direct ask fails", async () => {
|
|
336
|
+
const tracker = new WorktreeChangesTracker(
|
|
337
|
+
async () => ({ stdout: "", code: 0 }),
|
|
338
|
+
() => async (args) => {
|
|
339
|
+
if (args[0] === "status") return { stdout: "?? changed.ts\0", code: 0 };
|
|
340
|
+
if (args[0] === "diff") return { stdout: "", code: 0 };
|
|
341
|
+
throw new Error("git unavailable"); // symbolic-ref / rev-parse: the direct branch ask
|
|
342
|
+
},
|
|
343
|
+
undefined,
|
|
344
|
+
() => ["/foreign"],
|
|
345
|
+
);
|
|
346
|
+
await tracker.start();
|
|
347
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", UNKNOWN_BRANCH]]);
|
|
348
|
+
assert.equal(tracker.worktrees[0]!.model.files.length, 1, "a failed branch lookup must not drop the root's real changes");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// A root already described by the same clone's own worktree list is trusted
|
|
352
|
+
// as-is: a real "detached" marker there is already 100% accurate, so no
|
|
353
|
+
// redundant per-root call is needed (and none happens).
|
|
354
|
+
test("a root already described by worktree list is trusted without a redundant lookup", async () => {
|
|
355
|
+
const calls: string[][] = [];
|
|
356
|
+
const tracker = new WorktreeChangesTracker(
|
|
357
|
+
async () => ({ stdout: "worktree /known\0detached\0\0", code: 0 }),
|
|
358
|
+
() => async (args) => {
|
|
359
|
+
calls.push(args);
|
|
360
|
+
return { stdout: args[0] === "status" ? "?? changed.ts\0" : "", code: 0 };
|
|
361
|
+
},
|
|
362
|
+
undefined,
|
|
363
|
+
() => ["/known"],
|
|
364
|
+
);
|
|
365
|
+
await tracker.start();
|
|
366
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/known", undefined]]);
|
|
367
|
+
assert.ok(!calls.some((args) => args[0] === "symbolic-ref"), "a known worktree-list entry must not trigger a fallback call");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// The live evidence: an outer repo (~/work, no commits) whose ONLY change is
|
|
371
|
+
// the embedded inner repository (~/work/NaN-builders) itself must never show
|
|
372
|
+
// up as a phantom tree once the embedded-repo marker is filtered out of its
|
|
373
|
+
// own file list -- it never had a real file to show in the first place.
|
|
374
|
+
test("an outer ancestor whose only change is the embedded inner repository is never a phantom tree", async () => {
|
|
375
|
+
const tracker = new WorktreeChangesTracker(
|
|
376
|
+
async () => ({ stdout: "", code: 0 }), // neither root belongs to the other's clone
|
|
377
|
+
(root) => async (args) => {
|
|
378
|
+
if (args[0] !== "status") return { stdout: "", code: 0 };
|
|
379
|
+
// The outer root's only "change" is the nested repo directory marker;
|
|
380
|
+
// the inner root has a real file of its own.
|
|
381
|
+
return { stdout: root === "/outer" ? "?? NaN-builders/\0" : "?? jpg-png-converter.md\0", code: 0 };
|
|
382
|
+
},
|
|
383
|
+
undefined,
|
|
384
|
+
() => ["/outer", "/outer/NaN-builders"],
|
|
385
|
+
);
|
|
386
|
+
await tracker.start();
|
|
387
|
+
assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/outer/NaN-builders"]);
|
|
388
|
+
});
|
|
389
|
+
|
|
268
390
|
test("registered roots remain scannable during metadata discovery failure", async () => {
|
|
269
391
|
let code = 128;
|
|
270
392
|
const tracker = new WorktreeChangesTracker(async () => ({ stdout: "worktree /repo\0\0", code }), () => async (args) => ({ stdout: args[0] === "status" ? "?? new.ts\0" : "", code: 0 }), undefined, () => ["/repo"]);
|
|
@@ -356,3 +478,58 @@ test("ChangesTracker counts the lines of untracked files, which git numstat leav
|
|
|
356
478
|
assert.equal(model.added, 3);
|
|
357
479
|
assert.deepEqual(counted, ["notes.md", "empty.md"]);
|
|
358
480
|
});
|
|
481
|
+
|
|
482
|
+
// The session-evidence tracker behind /gentle:changes knows roots, never
|
|
483
|
+
// branches. The overlay decorates its trees with each root's own HEAD state,
|
|
484
|
+
// resolved once per root and reported back so the view can repaint; until git
|
|
485
|
+
// answers the tree shows no branch (the view's "detached" fallback), and a
|
|
486
|
+
// genuinely detached HEAD stays that way.
|
|
487
|
+
test("RootBranchLabels decorates session trees with each root's branch once git answers", async () => {
|
|
488
|
+
const calls = new Map<string, string[][]>();
|
|
489
|
+
const answers: Record<string, { symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number } }> = {
|
|
490
|
+
"/repo": { symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 } },
|
|
491
|
+
"/fresh": { symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 } },
|
|
492
|
+
"/pinned": { symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 } },
|
|
493
|
+
};
|
|
494
|
+
let changed = 0;
|
|
495
|
+
const labels = new RootBranchLabels((root) => async (args) => {
|
|
496
|
+
calls.set(root, [...(calls.get(root) ?? []), args]);
|
|
497
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
498
|
+
return args[0] === "symbolic-ref" ? answers[root]!.symbolic : answers[root]!.verify;
|
|
499
|
+
}, () => changed++);
|
|
500
|
+
const trees = Object.keys(answers).map((root) => ({ root, model: changesModel([]) }));
|
|
501
|
+
const first = labels.decorate(trees);
|
|
502
|
+
assert.deepEqual(first.map((tree) => tree.branch), [undefined, undefined, undefined], "nothing is labelled before git answers");
|
|
503
|
+
await labels.settled();
|
|
504
|
+
assert.equal(changed, 3, "each resolved root asks the view to repaint once");
|
|
505
|
+
const second = labels.decorate(trees);
|
|
506
|
+
assert.deepEqual(second.map((tree) => tree.branch), ["feature", "no commits yet", undefined]);
|
|
507
|
+
labels.decorate(trees);
|
|
508
|
+
await labels.settled();
|
|
509
|
+
assert.deepEqual([...calls.values()].map((list) => list.length), [2, 2, 1], "one resolution per root, never per decorate call");
|
|
510
|
+
assert.equal(changed, 3);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// A root that could not even be asked (removed, git unavailable, the runner
|
|
514
|
+
// itself rejecting) must read UNKNOWN_BRANCH -- never the same undefined a
|
|
515
|
+
// genuine detached HEAD produces, and never silently stay unlabelled forever.
|
|
516
|
+
test("RootBranchLabels labels a root UNKNOWN_BRANCH when it could not be asked, distinct from a real detached HEAD", async () => {
|
|
517
|
+
let changed = 0;
|
|
518
|
+
const labels = new RootBranchLabels((root) => async () => {
|
|
519
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
520
|
+
if (root === "/gone") throw new Error("root removed");
|
|
521
|
+
return { stdout: "", code: 1 };
|
|
522
|
+
}, () => changed++);
|
|
523
|
+
const trees = [
|
|
524
|
+
{ root: "/gone", model: changesModel([]) },
|
|
525
|
+
{ root: "/detached", model: changesModel([]) },
|
|
526
|
+
];
|
|
527
|
+
labels.decorate(trees);
|
|
528
|
+
await labels.settled();
|
|
529
|
+
assert.deepEqual(
|
|
530
|
+
labels.decorate(trees).map((tree) => tree.branch),
|
|
531
|
+
[UNKNOWN_BRANCH, undefined],
|
|
532
|
+
"a failed ask reads UNKNOWN_BRANCH; a real detached HEAD still reads undefined (the view's detached fallback)",
|
|
533
|
+
);
|
|
534
|
+
assert.equal(changed, 2, "a failed ask still resolves and asks the view to repaint, instead of hanging forever");
|
|
535
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { HOVER_ROLE, paintHoverable } from "../lib/shell-hover.ts";
|
|
4
|
+
|
|
5
|
+
const theme = {
|
|
6
|
+
fg(role: string, text: string) {
|
|
7
|
+
return `<${role}>${text}</${role}>`;
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
test("paintHoverable paints the shared hover role only while hovered", () => {
|
|
12
|
+
assert.equal(paintHoverable(theme, "refresh", true), `<${HOVER_ROLE}>refresh</${HOVER_ROLE}>`);
|
|
13
|
+
assert.equal(paintHoverable(theme, "refresh", false), "refresh");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("paintHoverable falls back to an idle role when given one", () => {
|
|
17
|
+
assert.equal(paintHoverable(theme, "refresh", false, "dim"), "<dim>refresh</dim>");
|
|
18
|
+
assert.equal(paintHoverable(theme, "refresh", true, "dim"), `<${HOVER_ROLE}>refresh</${HOVER_ROLE}>`, "hover always wins over the idle role");
|
|
19
|
+
});
|
|
@@ -128,6 +128,26 @@ test("framePromptLines keeps the editor scroll indicators inside the frame", ()
|
|
|
128
128
|
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("framePromptLines shows an explicit escHint on the bottom rule, overriding the editor's own scroll indicator", () => {
|
|
132
|
+
const width = 40;
|
|
133
|
+
const inner = width - 2;
|
|
134
|
+
const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
|
|
135
|
+
const lines = framePromptLines(
|
|
136
|
+
["─".repeat(inner), ` x${" ".repeat(inner - 2)}`, bottom],
|
|
137
|
+
width,
|
|
138
|
+
options({ fg: (_c, t) => t, escHint: "esc again to cancel" }),
|
|
139
|
+
);
|
|
140
|
+
assert.match(stripAnsi(lines[2]), /^╰─ esc again to cancel ─+╯$/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("framePromptLines falls back to the scroll indicator when no escHint is set", () => {
|
|
144
|
+
const width = 40;
|
|
145
|
+
const inner = width - 2;
|
|
146
|
+
const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
|
|
147
|
+
const lines = framePromptLines(["─".repeat(inner), ` x${" ".repeat(inner - 2)}`, bottom], width, options({ fg: (_c, t) => t }));
|
|
148
|
+
assert.match(stripAnsi(lines[2]), /^╰─ ↓ 3 more ─+╯$/);
|
|
149
|
+
});
|
|
150
|
+
|
|
131
151
|
test("withPromptHint places a dim hint after the cursor on an empty editor line", () => {
|
|
132
152
|
const inner = 38;
|
|
133
153
|
const line = ` ${CURSOR}${" ".repeat(inner - 2)}`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Container, Spacer, TuiAltScreen, type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import { createChatViewport } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/chat-viewport.js";
|
|
5
|
+
import { installSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
6
|
+
import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
|
|
7
|
+
|
|
8
|
+
// End-to-end through pi-tui's real alt-screen renderer and Pi's real chat
|
|
9
|
+
// viewport: the unit fixtures elsewhere hand-build layout nodes, which is how
|
|
10
|
+
// two earlier attempts at this row passed their tests and still failed live.
|
|
11
|
+
const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
|
|
12
|
+
const strip = (text: string) => text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "").replace(/\s+$/, "");
|
|
13
|
+
const lines = (rows: string[]): Component => ({ render: () => rows, invalidate() {} });
|
|
14
|
+
|
|
15
|
+
async function renderViewport(columns: number, rows: number, sidebar: boolean): Promise<string[]> {
|
|
16
|
+
const terminal = {
|
|
17
|
+
start() {}, stop() {}, async drainInput() {}, write() {},
|
|
18
|
+
get columns() { return columns; }, get rows() { return rows; },
|
|
19
|
+
moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {},
|
|
20
|
+
kittyProtocolActive: false, getBufferedInput() { return ""; }, hasPendingInput() { return false; },
|
|
21
|
+
} as never;
|
|
22
|
+
const above = new Container();
|
|
23
|
+
above.addChild(new Spacer(1)); // Pi adds a spacer above the editor when no widget is shown
|
|
24
|
+
const footer = new Container();
|
|
25
|
+
const viewport = createChatViewport({
|
|
26
|
+
document: lines(Array.from({ length: 12 }, (_, i) => `transcript ${i + 1}`)),
|
|
27
|
+
pendingMessages: new Container(), status: new Container(), widgetsAbove: above,
|
|
28
|
+
editor: lines(["╭─ editor ─╮", "│ prompt │", "╰──────────╯"]),
|
|
29
|
+
widgetsBelow: new Container(), footer, scrollbar: "auto",
|
|
30
|
+
});
|
|
31
|
+
const tui = new TuiAltScreen(terminal, false);
|
|
32
|
+
tui.setLayoutRoot(viewport.root);
|
|
33
|
+
if (sidebar) {
|
|
34
|
+
footer.addChild(sidebarPart(tui, "footer", lines(["BOTTOM BAR"]), { render: () => ["Status card"], invalidate() {} }));
|
|
35
|
+
sidebarHeader(tui, { render: (width: number) => [`HEADER ${width}`], invalidate() {} });
|
|
36
|
+
installSidebar(tui, theme);
|
|
37
|
+
} else {
|
|
38
|
+
footer.addChild(lines(["BOTTOM BAR"]));
|
|
39
|
+
}
|
|
40
|
+
tui.start();
|
|
41
|
+
tui.requestRender(true);
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
43
|
+
tui.stop();
|
|
44
|
+
return ((tui as unknown as { previousScreen: string[] }).previousScreen ?? []).map(strip);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("fullscreen with the sidebar leaves no blank row between the editor and the terminal edge", async () => {
|
|
48
|
+
const screen = await renderViewport(140, 24, true);
|
|
49
|
+
assert.equal(screen[0], "HEADER 138", "the header owns the first row");
|
|
50
|
+
assert.match(screen[screen.length - 1] ?? "", /^╰/, `the editor's bottom border sits on the last row, got ${JSON.stringify(screen.slice(-3))}`);
|
|
51
|
+
assert.ok(screen.some((row) => row.startsWith("╭─ editor")), "the editor is still painted");
|
|
52
|
+
assert.ok(screen.some((row) => row.includes("Status card")), "the rail is still painted");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("fullscreen without the sidebar keeps Pi's bottom bar on the last row", async () => {
|
|
56
|
+
const screen = await renderViewport(140, 24, false);
|
|
57
|
+
assert.equal(screen[screen.length - 1], "BOTTOM BAR");
|
|
58
|
+
assert.match(screen[screen.length - 2] ?? "", /^╰/);
|
|
59
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
4
|
import { getScrollViewsAt, renderLayoutFrame, type LayoutBox } from "@earendil-works/pi-tui/dist/layout.js";
|
|
5
5
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
6
|
-
import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
6
|
+
import { sidebarHeader, sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
7
7
|
import { renderShellSidebarBar } from "../lib/shell-bar.ts";
|
|
8
8
|
import { renderTodoCard, type TodoState } from "../lib/shell-todo.ts";
|
|
9
9
|
|
|
@@ -23,6 +23,20 @@ function rail(f: ReturnType<typeof fixture>): ScrollView {
|
|
|
23
23
|
assert.equal(node.type, "hstack");
|
|
24
24
|
return node.entries[1].component;
|
|
25
25
|
}
|
|
26
|
+
type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number; grow: number; shrink: number; minSize: number }[] };
|
|
27
|
+
// Finds the [left, scroll] hstack regardless of whether it is returned
|
|
28
|
+
// directly (no active header) or nested one level under the header vstack.
|
|
29
|
+
function hstackOf(f: ReturnType<typeof fixture>): HstackNode {
|
|
30
|
+
const node = f.root[NODE]() as unknown as { type: string; entries: { component: { [NODE]?(): HstackNode } }[] };
|
|
31
|
+
if (node.type === "hstack") return node as unknown as HstackNode;
|
|
32
|
+
assert.equal(node.type, "vstack");
|
|
33
|
+
const nested = node.entries[1]?.component[NODE]?.();
|
|
34
|
+
assert.ok(nested, "the vstack's second entry must expose the hstack via NODE");
|
|
35
|
+
return nested!;
|
|
36
|
+
}
|
|
37
|
+
function railWithHeader(f: ReturnType<typeof fixture>): ScrollView {
|
|
38
|
+
return hstackOf(f).entries[1].component as ScrollView;
|
|
39
|
+
}
|
|
26
40
|
|
|
27
41
|
test("grouped Status preserves structured fields and opaque integration text", () => {
|
|
28
42
|
const lines = renderShellSidebarBar({
|
|
@@ -32,13 +46,14 @@ test("grouped Status preserves structured fields and opaque integration text", (
|
|
|
32
46
|
}, theme, 46);
|
|
33
47
|
const text = lines.join("\n");
|
|
34
48
|
let previous = -1;
|
|
35
|
-
for (const heading of ["Status", "Project", "Changes", "
|
|
49
|
+
for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
|
|
36
50
|
const index = text.indexOf(heading);
|
|
37
51
|
assert.ok(index > previous, heading);
|
|
38
52
|
previous = index;
|
|
39
53
|
}
|
|
40
54
|
assert.match(text, /opaque integration/);
|
|
41
55
|
assert.match(text, /Branch.*main/);
|
|
56
|
+
assert.doesNotMatch(text, /Usage/);
|
|
42
57
|
});
|
|
43
58
|
|
|
44
59
|
test("scrollable TODO keeps every task while bottom and collapsed cards stay bounded", () => {
|
|
@@ -265,14 +280,17 @@ test("real layout frames reuse unchanged sidebar output and invalidate at state
|
|
|
265
280
|
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
266
281
|
f.host.terminal.columns = 140;
|
|
267
282
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
268
|
-
|
|
283
|
+
// Dipping below the breakpoint and back nulls the whole-rail `prepared`
|
|
284
|
+
// memo, but the per-section cache survives underneath it: the revision
|
|
285
|
+
// never moved, so no section actually needed to re-render.
|
|
286
|
+
assert.deepEqual(counts, { footer: 2, changes: 0, agents: 2, todo: 2 });
|
|
269
287
|
|
|
270
288
|
f.host.mode = "regular";
|
|
271
289
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
272
290
|
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
273
291
|
f.host.mode = "fullscreen";
|
|
274
292
|
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
275
|
-
assert.deepEqual(counts, { footer:
|
|
293
|
+
assert.deepEqual(counts, { footer: 2, changes: 0, agents: 2, todo: 2 });
|
|
276
294
|
|
|
277
295
|
let replacementRenders = 0;
|
|
278
296
|
sidebarPart(f.tui, "todo", {
|
|
@@ -469,3 +487,223 @@ test("native transcript and sidebar keep separate scroll routing across resize,
|
|
|
469
487
|
assert.equal(result.frame.root.children.some(box => box.component === rail), mode === "fullscreen" && width >= 140);
|
|
470
488
|
}
|
|
471
489
|
});
|
|
490
|
+
|
|
491
|
+
// T3: per-section render memo. Each rail section caches its rendered lines by
|
|
492
|
+
// its own digest, so a section whose digest did not change is never re-run
|
|
493
|
+
// when a sibling section's digest ticks — only the whole rail's assembled
|
|
494
|
+
// `lines`/`hits` are rebuilt from the (possibly cached) per-section lines.
|
|
495
|
+
|
|
496
|
+
test("a footer digest change does not re-render Agents or TODO", (t) => {
|
|
497
|
+
const f = fixture();
|
|
498
|
+
let footerLabel = "one";
|
|
499
|
+
const counts = { footer: 0, agents: 0, todo: 0 };
|
|
500
|
+
sidebarPart(f.tui, "footer", { render: () => ["Status"], invalidate() {} }, {
|
|
501
|
+
digest: () => footerLabel,
|
|
502
|
+
render: () => { counts.footer++; return [`Footer ${footerLabel}`]; },
|
|
503
|
+
invalidate() {},
|
|
504
|
+
});
|
|
505
|
+
for (const key of ["agents", "todo"] as const) {
|
|
506
|
+
sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
|
|
507
|
+
render: () => { counts[key]++; return [key]; },
|
|
508
|
+
invalidate() {},
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
t.after(installSidebar(f.tui, theme));
|
|
512
|
+
|
|
513
|
+
assert.match(rail(f).render(50).join("\n"), /Footer one/);
|
|
514
|
+
assert.deepEqual(counts, { footer: 1, agents: 1, todo: 1 });
|
|
515
|
+
|
|
516
|
+
// Re-render with nothing changed at all: the outer unchanged fast path
|
|
517
|
+
// must not call any section's render either.
|
|
518
|
+
assert.match(rail(f).render(50).join("\n"), /Footer one/);
|
|
519
|
+
assert.deepEqual(counts, { footer: 1, agents: 1, todo: 1 });
|
|
520
|
+
|
|
521
|
+
footerLabel = "two";
|
|
522
|
+
assert.match(rail(f).render(50).join("\n"), /Footer two/);
|
|
523
|
+
assert.deepEqual(counts, { footer: 2, agents: 1, todo: 1 }, "only the section whose digest changed re-renders");
|
|
524
|
+
|
|
525
|
+
// invalidateSidebar bumps the global revision, which is only what a
|
|
526
|
+
// no-digest section keys its cache on: footer's own unchanged digest
|
|
527
|
+
// still protects it from re-rendering.
|
|
528
|
+
invalidateSidebar(f.tui);
|
|
529
|
+
assert.match(rail(f).render(50).join("\n"), /Footer two/);
|
|
530
|
+
assert.deepEqual(counts, { footer: 2, agents: 2, todo: 2 }, "revision invalidation reaches only the sections with no digest of their own");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("a throwing digest still degrades to invalidation-only under the per-section memo", (t) => {
|
|
534
|
+
const f = fixture();
|
|
535
|
+
let todoRenders = 0;
|
|
536
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
|
|
537
|
+
digest: () => { throw new Error("broken digest"); },
|
|
538
|
+
render: () => { todoRenders++; return ["Todo card"]; },
|
|
539
|
+
invalidate() {},
|
|
540
|
+
});
|
|
541
|
+
t.after(installSidebar(f.tui, theme));
|
|
542
|
+
|
|
543
|
+
assert.match(rail(f).render(50).join("\n"), /Todo card/);
|
|
544
|
+
assert.equal(todoRenders, 1);
|
|
545
|
+
assert.match(rail(f).render(50).join("\n"), /Todo card/);
|
|
546
|
+
assert.equal(todoRenders, 1, "an unchanged frame reuses the cached section even without a digest");
|
|
547
|
+
|
|
548
|
+
invalidateSidebar(f.tui);
|
|
549
|
+
assert.match(rail(f).render(50).join("\n"), /Todo card/);
|
|
550
|
+
assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("a part replaced under the same key never reuses the previous part's cached lines", (t) => {
|
|
554
|
+
const f = fixture();
|
|
555
|
+
let firstRenders = 0;
|
|
556
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
|
|
557
|
+
render: () => { firstRenders++; return ["first"]; },
|
|
558
|
+
invalidate() {},
|
|
559
|
+
});
|
|
560
|
+
t.after(installSidebar(f.tui, theme));
|
|
561
|
+
assert.match(rail(f).render(50).join("\n"), /first/);
|
|
562
|
+
assert.equal(firstRenders, 1);
|
|
563
|
+
|
|
564
|
+
let secondRenders = 0;
|
|
565
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
|
|
566
|
+
render: () => { secondRenders++; return ["second"]; },
|
|
567
|
+
invalidate() {},
|
|
568
|
+
});
|
|
569
|
+
const text = rail(f).render(50).join("\n");
|
|
570
|
+
assert.match(text, /second/);
|
|
571
|
+
assert.doesNotMatch(text, /first/);
|
|
572
|
+
assert.equal(secondRenders, 1);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// T2: the live header row. A registered "header" part wraps the existing
|
|
576
|
+
// [left, scroll] hstack in a one-row-taller vstack and takes over the brand
|
|
577
|
+
// the banner used to carry inside the rail. Hosts that never register a
|
|
578
|
+
// header (every fixture above) keep the exact old hstack-direct shape.
|
|
579
|
+
|
|
580
|
+
test("an active header wraps the hstack in a vstack and removes the banner from the rail", (t) => {
|
|
581
|
+
const f = fixture();
|
|
582
|
+
sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`], invalidate() {} });
|
|
583
|
+
t.after(installSidebar(f.tui, theme));
|
|
584
|
+
|
|
585
|
+
const node = f.root[NODE]() as unknown as HstackNode;
|
|
586
|
+
assert.equal(node.type, "vstack");
|
|
587
|
+
assert.equal(node.gap, 0);
|
|
588
|
+
assert.equal(node.align, "stretch");
|
|
589
|
+
assert.deepEqual(node.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
|
|
590
|
+
{ basis: 1, grow: 0, shrink: 0, minSize: 1 },
|
|
591
|
+
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
592
|
+
]);
|
|
593
|
+
const header = node.entries[0].component as { render(width: number): string[] };
|
|
594
|
+
// The rail card ends two columns before the terminal edge (its padding plus
|
|
595
|
+
// the scrollbar column); the header stops there too so its right group
|
|
596
|
+
// lines up with the card border instead of touching the edge.
|
|
597
|
+
assert.deepEqual(header.render(0), ["HEADER 138"], "the header renders at the terminal width minus the rail's right inset, not the rail width");
|
|
598
|
+
|
|
599
|
+
const hstack = hstackOf(f);
|
|
600
|
+
assert.equal(hstack.type, "hstack");
|
|
601
|
+
const scroll = railWithHeader(f);
|
|
602
|
+
const rail = scroll.render(50);
|
|
603
|
+
assert.doesNotMatch(rail.join("\n"), /✿ Gentle Shell ✿/, "the header carries the brand now, not the banner");
|
|
604
|
+
// The banner used to hold the first card away from the top; with the
|
|
605
|
+
// header in its place the rail keeps one blank row so the first card does
|
|
606
|
+
// not sit flush against the header.
|
|
607
|
+
assert.equal(rail[0]?.trim(), "", "the rail opens with a blank row under the header");
|
|
608
|
+
assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("without a registered header the rail keeps the banner and the plain hstack", (t) => {
|
|
612
|
+
const f = fixture();
|
|
613
|
+
t.after(installSidebar(f.tui, theme));
|
|
614
|
+
const node = f.root[NODE]() as unknown as { type: string };
|
|
615
|
+
assert.equal(node.type, "hstack");
|
|
616
|
+
const scroll = rail(f);
|
|
617
|
+
assert.match(scroll.render(50).join("\n"), /✿ Gentle Shell ✿/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("a header too narrow to show anything falls back to the plain hstack and the banner", (t) => {
|
|
621
|
+
const f = fixture();
|
|
622
|
+
sidebarHeader(f.tui, { render: () => [""], invalidate() {} });
|
|
623
|
+
t.after(installSidebar(f.tui, theme));
|
|
624
|
+
const node = f.root[NODE]() as unknown as { type: string };
|
|
625
|
+
assert.equal(node.type, "hstack", "a blank header line does not earn its own row");
|
|
626
|
+
const scroll = rail(f);
|
|
627
|
+
assert.match(scroll.render(50).join("\n"), /✿ Gentle Shell ✿/);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("header content changes reach the header row while an unrelated part is untouched", (t) => {
|
|
631
|
+
const f = fixture();
|
|
632
|
+
let label = "one";
|
|
633
|
+
sidebarHeader(f.tui, { digest: () => label, render: () => [`HEADER ${label}`], invalidate() {} });
|
|
634
|
+
t.after(installSidebar(f.tui, theme));
|
|
635
|
+
|
|
636
|
+
const headerLines = () => (f.root[NODE]() as unknown as HstackNode).entries[0].component as { render(width: number): string[] };
|
|
637
|
+
assert.deepEqual(headerLines().render(0), ["HEADER one"]);
|
|
638
|
+
label = "two";
|
|
639
|
+
assert.deepEqual(headerLines().render(0), ["HEADER two"]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("rail mouse dispatch still maps clicks correctly with the header row above it", (t) => {
|
|
643
|
+
const f = fixture();
|
|
644
|
+
let clicks = 0;
|
|
645
|
+
const todo = {
|
|
646
|
+
render: () => ["Todo header", "Todo body"],
|
|
647
|
+
invalidate() {},
|
|
648
|
+
handleMouse(event: TuiMouseEvent) {
|
|
649
|
+
if (event.type !== "click" || event.button !== "left" || event.y !== 0) return undefined;
|
|
650
|
+
clicks++;
|
|
651
|
+
return { handled: true, render: true };
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
sidebarPart(f.tui, "todo", todo);
|
|
655
|
+
sidebarHeader(f.tui, { render: () => ["HEADER"], invalidate() {} });
|
|
656
|
+
const dispose = installSidebar(f.tui, theme);
|
|
657
|
+
t.after(dispose);
|
|
658
|
+
const scroll = railWithHeader(f);
|
|
659
|
+
const content = scroll.render(50);
|
|
660
|
+
scroll.updateLayout(content.length, 5, () => {});
|
|
661
|
+
const headerY = content.findIndex((line) => line.includes("Todo header"));
|
|
662
|
+
assert.ok(headerY >= 0);
|
|
663
|
+
// The mouse event carries coordinates local to the scroll component itself
|
|
664
|
+
// (pi-tui's layout tree translates screen coordinates before dispatch), so
|
|
665
|
+
// nesting the hstack one level deeper under the header row does not shift
|
|
666
|
+
// what the rail receives.
|
|
667
|
+
const hit = scroll.handleMouse({ type: "click", button: "left", x: 2, y: headerY, screenX: 92, screenY: 31 + headerY, width: 50, height: 5, shift: false, alt: false, ctrl: false });
|
|
668
|
+
assert.equal(hit?.handled, true);
|
|
669
|
+
assert.equal(clicks, 1);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// T8(c): the header row is a leaf in the layout tree, not inside the rail's
|
|
673
|
+
// ScrollView, so pi-tui's mouse dispatch (tui-alt-screen.js) finds and calls
|
|
674
|
+
// its own handleMouse directly — it never goes through dispatchPartMouse.
|
|
675
|
+
test("the header component's handleMouse delegates to the registered header part", (t) => {
|
|
676
|
+
const f = fixture();
|
|
677
|
+
const received: TuiMouseEvent[] = [];
|
|
678
|
+
let result: { handled: boolean; render?: boolean } | undefined = { handled: true, render: true };
|
|
679
|
+
sidebarHeader(f.tui, {
|
|
680
|
+
render: () => ["HEADER"],
|
|
681
|
+
invalidate() {},
|
|
682
|
+
handleMouse(event: TuiMouseEvent) {
|
|
683
|
+
received.push(event);
|
|
684
|
+
return result;
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
t.after(installSidebar(f.tui, theme));
|
|
688
|
+
|
|
689
|
+
const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
|
|
690
|
+
assert.equal(node.type, "vstack", "an active header wraps the hstack in a vstack");
|
|
691
|
+
const header = node.entries[0]!.component as { handleMouse(event: TuiMouseEvent): { handled: boolean; render?: boolean } | undefined };
|
|
692
|
+
|
|
693
|
+
const event = { type: "click", button: "left", x: 5, y: 0, screenX: 5, screenY: 0, width: 140, height: 1, shift: false, alt: false, ctrl: false } as TuiMouseEvent;
|
|
694
|
+
assert.deepEqual(header.handleMouse(event), { handled: true, render: true });
|
|
695
|
+
assert.equal(received.length, 1);
|
|
696
|
+
assert.equal(received[0], event, "the event reaches the registered part unmodified");
|
|
697
|
+
|
|
698
|
+
result = undefined;
|
|
699
|
+
assert.equal(header.handleMouse(event), undefined, "an ignored click returns undefined, same as any other component");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("the header component's handleMouse is a harmless no-op when no header part is registered", (t) => {
|
|
703
|
+
const f = fixture();
|
|
704
|
+
t.after(installSidebar(f.tui, theme));
|
|
705
|
+
const node = f.root[NODE]() as unknown as { type: string; entries?: { component: Component }[] };
|
|
706
|
+
// No header registered: the active node stays the plain hstack (T2's
|
|
707
|
+
// backward-compatible fallback), so there is no header leaf to click at all.
|
|
708
|
+
assert.equal(node.type, "hstack");
|
|
709
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
4
|
-
import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
4
|
+
import { sidebarHeader, sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
5
5
|
|
|
6
6
|
const host = (terminal?: object) => ({ terminal }) as TUI;
|
|
7
7
|
const component = () => ({ render: (_width = 80) => ["bottom"], invalidate() {} });
|
|
@@ -48,3 +48,27 @@ test("disposing an old part preserves its replacement and releases its bottom",
|
|
|
48
48
|
(second as typeof second & { dispose?(): void }).dispose?.();
|
|
49
49
|
assert.equal(sidebarState(tui).parts.size, 0);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("sidebarHeader registers the header part under its own key and disposes it without a bottom widget", () => {
|
|
53
|
+
const tui = host({});
|
|
54
|
+
const rail = { render: () => ["header line"], invalidate() {}, digest: () => "d" };
|
|
55
|
+
const dispose = sidebarHeader(tui, rail);
|
|
56
|
+
assert.equal(sidebarState(tui).parts.get("header"), rail);
|
|
57
|
+
dispose();
|
|
58
|
+
assert.equal(sidebarState(tui).parts.get("header"), undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("sidebarHeader on an unsupported host is a harmless no-op", () => {
|
|
62
|
+
const dispose = sidebarHeader(host(), { render: () => [], invalidate() {} });
|
|
63
|
+
assert.doesNotThrow(dispose);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("sidebarHeader dispose does not remove a replacement rail registered after it", () => {
|
|
67
|
+
const tui = host({});
|
|
68
|
+
const first = { render: () => ["first"], invalidate() {} };
|
|
69
|
+
const dispose = sidebarHeader(tui, first);
|
|
70
|
+
const second = { render: () => ["second"], invalidate() {} };
|
|
71
|
+
sidebarHeader(tui, second);
|
|
72
|
+
dispose();
|
|
73
|
+
assert.equal(sidebarState(tui).parts.get("header"), second);
|
|
74
|
+
});
|