gentle-pi 2.5.0 → 2.6.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/README.md +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
3
8
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
9
|
import {
|
|
5
10
|
CHANGE_STATUS,
|
|
6
11
|
ChangesTracker,
|
|
12
|
+
WorktreeChangesTracker,
|
|
13
|
+
parseWorktrees,
|
|
7
14
|
changesSummary,
|
|
8
15
|
emptyChanges,
|
|
9
16
|
parseNumstat,
|
|
@@ -167,6 +174,167 @@ test("ChangesTracker coalesces overlapping refreshes into at most one extra roun
|
|
|
167
174
|
assert.equal(rounds, 3);
|
|
168
175
|
});
|
|
169
176
|
|
|
177
|
+
test("registered roots hide clean and missing worktrees while metadata never admits bare or prunable siblings", async () => {
|
|
178
|
+
let roots = "worktree /main\0branch refs/heads/main\0\0worktree /linked space\0detached\0\0worktree /clean\0\0worktree /gone\0\0worktree /pruned\0prunable missing\0\0worktree /bare\0bare\0\0";
|
|
179
|
+
const calls: string[] = [];
|
|
180
|
+
let registered = ["/main", "/linked space", "/clean", "/gone"];
|
|
181
|
+
const tracker = new WorktreeChangesTracker(async () => ({ stdout: roots, code: 0 }), (root) => async (args) => {
|
|
182
|
+
calls.push(root);
|
|
183
|
+
if (root === "/gone") throw new Error("missing directory");
|
|
184
|
+
return { stdout: args[0] === "status" && root !== "/clean" ? "?? same.ts\0" : "", code: 0 };
|
|
185
|
+
}, (root) => async () => root === "/main" ? 2 : 3, () => registered);
|
|
186
|
+
await tracker.start();
|
|
187
|
+
assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch, tree.model.added]), [["/main", "main", 2], ["/linked space", undefined, 3]]);
|
|
188
|
+
assert.equal(tracker.model.files.length, 2, "identical filenames in different roots remain distinct");
|
|
189
|
+
assert.equal(new Set(tracker.model.files.map((file) => file.path)).size, 2);
|
|
190
|
+
assert.ok(!calls.includes("/pruned") && !calls.includes("/bare"));
|
|
191
|
+
roots += "worktree /new\0branch refs/heads/new\0\0";
|
|
192
|
+
registered.push("/new");
|
|
193
|
+
await tracker.refresh();
|
|
194
|
+
assert.equal(tracker.worktrees.length, 3);
|
|
195
|
+
roots = "worktree /clean\0\0";
|
|
196
|
+
registered = ["/clean"];
|
|
197
|
+
await tracker.refresh();
|
|
198
|
+
assert.deepEqual(tracker.worktrees, []);
|
|
199
|
+
assert.deepEqual(tracker.model, emptyChanges());
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("only registered worktrees are scanned, with whole preexisting and untracked contents", async () => {
|
|
203
|
+
const queried: string[] = [];
|
|
204
|
+
const roots = ["/used"];
|
|
205
|
+
const tracker = new WorktreeChangesTracker(async () => ({ code: 0, stdout: "worktree /used\0\0worktree /hidden\0\0" }), (root) => async (args) => {
|
|
206
|
+
queried.push(root);
|
|
207
|
+
return { code: 0, stdout: args[0] === "status" ? " M old.ts\0?? new.ts\0" : "4\t2\told.ts\n" };
|
|
208
|
+
}, () => async () => 3, () => roots);
|
|
209
|
+
await tracker.start();
|
|
210
|
+
assert.deepEqual(new Set(queried), new Set(["/used"]));
|
|
211
|
+
assert.equal(tracker.model.files.length, 2);
|
|
212
|
+
assert.equal(tracker.model.added, 7);
|
|
213
|
+
roots.push("/hidden");
|
|
214
|
+
await tracker.refresh();
|
|
215
|
+
assert.equal(tracker.model.files.length, 4);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("worktree refresh shares one scan so polling faster than discovery cannot prolong it", async () => {
|
|
219
|
+
let release: (() => void) | undefined;
|
|
220
|
+
let scans = 0;
|
|
221
|
+
const tracker = new WorktreeChangesTracker(async () => {
|
|
222
|
+
scans++;
|
|
223
|
+
if (scans === 2) await new Promise<void>((resolve) => { release = resolve; });
|
|
224
|
+
return { stdout: "", code: 0 };
|
|
225
|
+
}, () => async () => ({ stdout: "", code: 0 }));
|
|
226
|
+
await tracker.start();
|
|
227
|
+
const first = tracker.refresh();
|
|
228
|
+
const overlapping = tracker.refresh();
|
|
229
|
+
release!();
|
|
230
|
+
await Promise.all([first, overlapping]);
|
|
231
|
+
assert.equal(scans, 2, "overlapping poll must not extend an already slow scan");
|
|
232
|
+
await tracker.refresh();
|
|
233
|
+
assert.equal(scans, 3, "next refresh still rediscovers roots");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("registration during an in-flight status scan is included before refresh resolves", async () => {
|
|
237
|
+
const roots = ["/used"];
|
|
238
|
+
let release: () => void;
|
|
239
|
+
let entered: () => void;
|
|
240
|
+
const scanning = new Promise<void>((resolve) => { entered = resolve; });
|
|
241
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
242
|
+
let paused = false;
|
|
243
|
+
const tracker = new WorktreeChangesTracker(async () => ({ code: 0, stdout: "" }), (root) => async (args) => {
|
|
244
|
+
if (root === "/used" && args[0] === "status" && !paused) { paused = true; entered(); await gate; }
|
|
245
|
+
return { code: 0, stdout: args[0] === "status" ? " M file.ts\0" : "1\t0\tfile.ts\n" };
|
|
246
|
+
}, undefined, () => roots);
|
|
247
|
+
const scan = tracker.refresh();
|
|
248
|
+
await scanning;
|
|
249
|
+
roots.push("/newly-used");
|
|
250
|
+
const concurrent = tracker.refresh();
|
|
251
|
+
release();
|
|
252
|
+
await Promise.all([scan, concurrent]);
|
|
253
|
+
assert.equal(tracker.model.files.length, 2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("worktree porcelain preserves unusual directory names and detached fallback", () => {
|
|
257
|
+
assert.deepEqual(parseWorktrees("worktree /a\nbranch strange\0HEAD abc\0detached\0\0"), [{ root: "/a\nbranch strange" }]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("registered roots remain scannable during metadata discovery failure", async () => {
|
|
261
|
+
let code = 128;
|
|
262
|
+
const tracker = new WorktreeChangesTracker(async () => ({ stdout: "worktree /repo\0\0", code }), () => async (args) => ({ stdout: args[0] === "status" ? "?? new.ts\0" : "", code: 0 }), undefined, () => ["/repo"]);
|
|
263
|
+
await tracker.start();
|
|
264
|
+
assert.equal(tracker.worktrees.length, 1);
|
|
265
|
+
code = 0;
|
|
266
|
+
await tracker.refresh();
|
|
267
|
+
assert.equal(tracker.worktrees.length, 1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("isolated Git worktrees include preexisting dirty files only after root registration", async (t) => {
|
|
271
|
+
const temporary = await mkdtemp(join(tmpdir(), "gentle-shell-worktrees-"));
|
|
272
|
+
// Every repository, linked root and Git configuration belongs to this fixture.
|
|
273
|
+
t.after(() => rm(temporary, { recursive: true, force: true }));
|
|
274
|
+
const fixture = await realpath(temporary);
|
|
275
|
+
const main = join(fixture, "main");
|
|
276
|
+
const linked = join(fixture, "linked space");
|
|
277
|
+
const clean = join(fixture, "clean");
|
|
278
|
+
const untracked = join(fixture, "untracked");
|
|
279
|
+
const empty = join(fixture, "empty");
|
|
280
|
+
const config = join(fixture, "gitconfig");
|
|
281
|
+
await mkdir(empty);
|
|
282
|
+
await writeFile(config, "");
|
|
283
|
+
const env = { ...process.env };
|
|
284
|
+
for (const key of Object.keys(env)) {
|
|
285
|
+
if (key.startsWith("GIT_")) delete env[key];
|
|
286
|
+
}
|
|
287
|
+
env.GIT_CONFIG_GLOBAL = config;
|
|
288
|
+
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
289
|
+
env.GIT_ATTR_NOSYSTEM = "1";
|
|
290
|
+
const exec = promisify(execFile);
|
|
291
|
+
const gitAt = (root: string) => async (args: string[]) => {
|
|
292
|
+
const { stdout } = await exec("git", [
|
|
293
|
+
"--no-optional-locks", "-C", root,
|
|
294
|
+
"-c", `core.hooksPath=${empty}`,
|
|
295
|
+
"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false",
|
|
296
|
+
...args,
|
|
297
|
+
], { env });
|
|
298
|
+
return { stdout, code: 0 };
|
|
299
|
+
};
|
|
300
|
+
await gitAt(fixture)(["init", "--initial-branch=main", `--template=${empty}`, main]);
|
|
301
|
+
await gitAt(main)(["config", "user.name", "Shell Test"]);
|
|
302
|
+
await gitAt(main)(["config", "user.email", "shell-test@example.invalid"]);
|
|
303
|
+
await writeFile(join(main, "same.ts"), "export const value = 1;\n");
|
|
304
|
+
await gitAt(main)(["add", "same.ts"]);
|
|
305
|
+
await gitAt(main)(["commit", "-m", "Initial fixture"]);
|
|
306
|
+
await gitAt(main)(["worktree", "add", "-b", "linked", linked]);
|
|
307
|
+
await gitAt(main)(["worktree", "add", "-b", "clean", clean]);
|
|
308
|
+
|
|
309
|
+
// Discovery starts from a linked root, not the clone's primary worktree.
|
|
310
|
+
const registered = [main, linked, clean];
|
|
311
|
+
const tracker = new WorktreeChangesTracker(gitAt(linked), gitAt, undefined, () => registered);
|
|
312
|
+
await tracker.start();
|
|
313
|
+
assert.deepEqual(tracker.worktrees, [], "all clean roots are hidden");
|
|
314
|
+
await writeFile(join(main, "same.ts"), "export const value = 2;\n");
|
|
315
|
+
await writeFile(join(linked, "same.ts"), "export const value = 3;\n");
|
|
316
|
+
await tracker.refresh();
|
|
317
|
+
assert.deepEqual(new Set(tracker.worktrees.map((tree) => tree.root)), new Set([main, linked]));
|
|
318
|
+
assert.deepEqual(tracker.worktrees.map((tree) => tree.model.files[0].path), ["same.ts", "same.ts"]);
|
|
319
|
+
assert.equal(new Set(tracker.model.files.map((file) => file.path)).size, 2, "aggregate paths distinguish the two roots");
|
|
320
|
+
|
|
321
|
+
await gitAt(main)(["worktree", "add", "-b", "untracked", untracked]);
|
|
322
|
+
await writeFile(join(untracked, "notes.md"), "New worktree content\n");
|
|
323
|
+
await tracker.refresh();
|
|
324
|
+
assert.equal(tracker.worktrees.length, 2, "a new dirty sibling stays hidden until registered");
|
|
325
|
+
registered.push(untracked);
|
|
326
|
+
await tracker.refresh();
|
|
327
|
+
assert.deepEqual(new Set(tracker.worktrees.map((tree) => tree.root)), new Set([main, linked, untracked]));
|
|
328
|
+
assert.deepEqual(tracker.worktrees.find((tree) => tree.root === untracked)?.model.files, [file("notes.md", 0, 0, CHANGE_STATUS.UNTRACKED)]);
|
|
329
|
+
assert.ok(!tracker.worktrees.some((tree) => tree.root === clean), "unchanged linked root remains hidden");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("untracked-only unborn roots remain visible without a HEAD diff", async () => {
|
|
333
|
+
const tracker = new ChangesTracker(async (args) => ({ stdout: args[0] === "status" ? "?? first.ts\0" : "", code: args[0] === "status" ? 0 : 128 }), async () => 2);
|
|
334
|
+
await tracker.start();
|
|
335
|
+
assert.deepEqual(tracker.model.files, [file("first.ts", 2, 0, CHANGE_STATUS.UNTRACKED)]);
|
|
336
|
+
});
|
|
337
|
+
|
|
170
338
|
test("ChangesTracker counts the lines of untracked files, which git numstat leaves out", async () => {
|
|
171
339
|
const { git } = fakeGit(["", ""], ["", "?? notes.md\0?? empty.md\0"]);
|
|
172
340
|
const counted: string[] = [];
|
|
@@ -4,7 +4,6 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
5
5
|
import {
|
|
6
6
|
framePromptLines,
|
|
7
|
-
panelPainter,
|
|
8
7
|
PROMPT_STATE,
|
|
9
8
|
petalGlyph,
|
|
10
9
|
petalTone,
|
|
@@ -110,9 +109,32 @@ test("withPromptHint leaves the line alone when the hint does not fit", () => {
|
|
|
110
109
|
assert.equal(hinted, line);
|
|
111
110
|
});
|
|
112
111
|
|
|
113
|
-
test("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
test("working prompt stays transparent at narrow and normal widths", () => {
|
|
113
|
+
for (const width of [0, 1, 2, 3, 8, 40]) {
|
|
114
|
+
const lines = framePromptLines(["──", ` ${CURSOR}界`, "──"], width, options({
|
|
115
|
+
state: PROMPT_STATE.WORKING, fg: (_c, t) => t,
|
|
116
|
+
}));
|
|
117
|
+
for (const [row, line] of lines.entries()) {
|
|
118
|
+
assert.ok(visibleWidth(line) <= width);
|
|
119
|
+
let bg = false, cell = 0;
|
|
120
|
+
for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
|
|
121
|
+
if (token.startsWith("\x1b")) {
|
|
122
|
+
for (const code of token.slice(2, -1).split(";").map(Number)) {
|
|
123
|
+
if (code === 0 || code === 49) bg = false;
|
|
124
|
+
if (code === 44) bg = true;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
assert.equal(bg, false, `row ${row}, cell ${cell} must remain transparent`);
|
|
128
|
+
cell += visibleWidth(token);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
assert.equal(bg, false);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("transparent prompt preserves pi's cursor reset", () => {
|
|
137
|
+
const lines = framePromptLines(editorLines(40), 40, options({ fg: (_c, t) => t }));
|
|
138
|
+
assert.ok(lines[1].includes(CURSOR));
|
|
139
|
+
assert.doesNotMatch(lines.join("\n"), /\x1b\[44m/);
|
|
118
140
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderSidebarBanner } from "../lib/shell-sidebar-banner.ts";
|
|
5
|
+
|
|
6
|
+
const plain = { fg: (_role: string, text: string) => text, bold: (text: string) => text };
|
|
7
|
+
test("sidebar heading is only the centered literal product label", () => {
|
|
8
|
+
const label = "✿ Gentle-Pi ✿";
|
|
9
|
+
for (const width of [0, 1, 8, 11, 20, 21, 30, 45, 46, 80]) {
|
|
10
|
+
const lines = renderSidebarBanner(plain, width);
|
|
11
|
+
const space = width - visibleWidth(label);
|
|
12
|
+
assert.deepEqual(lines, space >= 0 ? [" ".repeat(Math.floor(space / 2)) + label + " ".repeat(Math.ceil(space / 2))] : []);
|
|
13
|
+
assert.ok(lines.every((line) => visibleWidth(line) === width));
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("heading uses the current theme on every render", () => {
|
|
18
|
+
let palette = "dark";
|
|
19
|
+
const theme = { ...plain, fg: (role: string, text: string) => `<${palette}:${role}>${text}` };
|
|
20
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<dark:accent>✿ <dark:text>Gentle-Pi <dark:accent>✿");
|
|
21
|
+
palette = "light";
|
|
22
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<light:accent>✿ <light:text>Gentle-Pi <light:accent>✿");
|
|
23
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ScrollView, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
|
+
import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
|
|
5
|
+
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
6
|
+
import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
|
|
7
|
+
import { renderShellSidebarBar } from "../lib/shell-bar.ts";
|
|
8
|
+
import { renderTodoCard, type TodoState } from "../lib/shell-todo.ts";
|
|
9
|
+
|
|
10
|
+
const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
11
|
+
const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
|
|
12
|
+
function fixture(mode = "fullscreen", columns = 140) {
|
|
13
|
+
const original = () => ({ type: "vstack", entries: [] });
|
|
14
|
+
const root = { render: () => ["transcript"], invalidate() {}, [NODE]: original };
|
|
15
|
+
let renders = 0;
|
|
16
|
+
const host = { mode, terminal: { columns }, layoutRoot: root, requestRender() { renders++; } };
|
|
17
|
+
const tui = host as unknown as TUI;
|
|
18
|
+
const bottom = sidebarPart(tui, "footer", { render: (_width: number) => ["Status"], invalidate() {} });
|
|
19
|
+
return { host, tui, root, original, bottom, renders: () => renders };
|
|
20
|
+
}
|
|
21
|
+
function rail(f: ReturnType<typeof fixture>): ScrollView {
|
|
22
|
+
const node = f.root[NODE]() as unknown as { type: string; entries: { component: ScrollView }[] };
|
|
23
|
+
assert.equal(node.type, "hstack");
|
|
24
|
+
return node.entries[1].component;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("grouped Status preserves structured fields and opaque integration text", () => {
|
|
28
|
+
const lines = renderShellSidebarBar({
|
|
29
|
+
cwd: "/project", branch: "main", dirty: 2, sessionName: "session",
|
|
30
|
+
modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000,
|
|
31
|
+
costTotal: 1, subscription: false, statuses: ["opaque integration"],
|
|
32
|
+
}, theme, 46);
|
|
33
|
+
const text = lines.join("\n");
|
|
34
|
+
let previous = -1;
|
|
35
|
+
for (const heading of ["Status", "Project", "Model", "Context", "Usage", "Integrations"]) {
|
|
36
|
+
const index = text.indexOf(heading);
|
|
37
|
+
assert.ok(index > previous, heading);
|
|
38
|
+
previous = index;
|
|
39
|
+
}
|
|
40
|
+
assert.match(text, /opaque integration/);
|
|
41
|
+
assert.match(text, /Branch.*main/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("scrollable TODO keeps every task while bottom and collapsed cards stay bounded", () => {
|
|
45
|
+
const state: TodoState = { tasks: Array.from({ length: 20 }, (_, i) => ({ id: i + 1, title: `Task-${i + 1}!`, status: "pending" })), nextId: 21, updatedTurn: 0 };
|
|
46
|
+
const todoTheme = { ...theme, strikethrough: (text: string) => text };
|
|
47
|
+
const render = (scrollable: boolean, collapsed = false) => renderTodoCard(state, todoTheme, 46, { scrollable, collapsed, staleTurns: 0 }).join("\n");
|
|
48
|
+
for (const task of state.tasks) assert.ok(render(true).includes(task.title));
|
|
49
|
+
assert.ok(!render(false).includes("Task-20!"));
|
|
50
|
+
assert.ok(!render(true, true).includes("Task-20!"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("installation on a missing-terminal host is a harmless no-op", () => {
|
|
54
|
+
const dispose = installSidebar({} as TUI, theme);
|
|
55
|
+
assert.doesNotThrow(dispose);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("only fullscreen at 140 columns activates; shrinking restores bottom paint", (t) => {
|
|
59
|
+
for (const [mode, width, active] of [["regular", 140, false], ["fullscreen", 139, false], ["fullscreen", 140, true]] as const) {
|
|
60
|
+
const f = fixture(mode, width);
|
|
61
|
+
t.after(installSidebar(f.tui, theme));
|
|
62
|
+
assert.equal(f.root[NODE]().type, active ? "hstack" : "vstack");
|
|
63
|
+
assert.deepEqual(f.bottom.render(80), active ? [] : ["Status"]);
|
|
64
|
+
f.host.terminal.columns = 139;
|
|
65
|
+
assert.equal(f.root[NODE]().type, "vstack");
|
|
66
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rail orders Status, changes, agents, TODO independent of registration order", (t) => {
|
|
71
|
+
const f = fixture();
|
|
72
|
+
for (const key of ["todo", "agents", "changes"]) {
|
|
73
|
+
sidebarPart(f.tui, key, { render: () => [key, ""], invalidate() {} });
|
|
74
|
+
}
|
|
75
|
+
t.after(installSidebar(f.tui, theme));
|
|
76
|
+
assert.deepEqual(rail(f).render(50).map((line) => line.trim()), ["✿ Gentle-Pi ✿", "", "Status", "", "changes", "", "agents", "", "todo"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("branding belongs to scroll content before Status, never transcript or narrow bottom", (t) => {
|
|
80
|
+
const f = fixture();
|
|
81
|
+
t.after(installSidebar(f.tui, theme));
|
|
82
|
+
const scroll = rail(f);
|
|
83
|
+
const lines = scroll.render(50);
|
|
84
|
+
const brandIndex = lines.findIndex((line) => line.includes("✿ Gentle-Pi ✿"));
|
|
85
|
+
assert.ok(brandIndex >= 0 && brandIndex < lines.findIndex((line) => line.includes("Status")));
|
|
86
|
+
assert.doesNotMatch(lines.join("\n"), /[\u2800-\u28ff]/);
|
|
87
|
+
const heading = lines[brandIndex];
|
|
88
|
+
const usableWidth = scroll.getContentWidth(50) - 2;
|
|
89
|
+
const spare = usableWidth - visibleWidth("✿ Gentle-Pi ✿");
|
|
90
|
+
const scrollbarWidth = 50 - scroll.getContentWidth(50);
|
|
91
|
+
assert.equal(heading, " ".repeat(1 + Math.floor(spare / 2)) + "✿ Gentle-Pi ✿" + " ".repeat(1 + Math.ceil(spare / 2) + scrollbarWidth));
|
|
92
|
+
assert.deepEqual(f.root.render(), ["transcript"]);
|
|
93
|
+
scroll.updateLayout(lines.length, 2, () => {});
|
|
94
|
+
scroll.scrollBy(9);
|
|
95
|
+
assert.ok(scroll.scrollTop > 0);
|
|
96
|
+
f.host.terminal.columns = 80;
|
|
97
|
+
assert.equal(f.root[NODE]().type, "vstack");
|
|
98
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("wheel scrolls the rail and is consumed at both boundaries and blank space", (t) => {
|
|
102
|
+
const f = fixture();
|
|
103
|
+
t.after(installSidebar(f.tui, theme));
|
|
104
|
+
const scroll = rail(f);
|
|
105
|
+
scroll.updateLayout(20, 5, () => {});
|
|
106
|
+
for (const [delta, expected] of [[-1, 0], [3, 3], [100, 15], [1, 15], [-100, 0]]) {
|
|
107
|
+
const result = scroll.handleMouse({ type: "wheel", wheelDelta: delta, x: 3, y: 4, screenX: 93, screenY: 6, width: 50, height: 5 } as Parameters<typeof scroll.handleMouse>[0]);
|
|
108
|
+
assert.equal(result?.handled, true);
|
|
109
|
+
assert.equal(scroll.scrollTop, expected);
|
|
110
|
+
assert.equal(result?.target?.component, scroll);
|
|
111
|
+
}
|
|
112
|
+
f.host.terminal.columns = 139;
|
|
113
|
+
assert.equal(f.root[NODE]().type, "vstack");
|
|
114
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
115
|
+
f.host.terminal.columns = 160;
|
|
116
|
+
const restored = rail(f);
|
|
117
|
+
assert.deepEqual(f.bottom.render(80), []);
|
|
118
|
+
const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number; grow: number; shrink: number; minSize: number }[] };
|
|
119
|
+
assert.equal(layout.gap, 3);
|
|
120
|
+
assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
|
|
121
|
+
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
122
|
+
{ basis: 50, grow: 0, shrink: 0, minSize: 50 },
|
|
123
|
+
]);
|
|
124
|
+
const restoredLines = restored.render(50);
|
|
125
|
+
// Native ScrollView.render only appends the scrollbar gutter; short rows need not fill the layout allocation.
|
|
126
|
+
for (const line of restoredLines) assert.ok(visibleWidth(line) <= 50);
|
|
127
|
+
assert.ok(restoredLines.some((line) => line.startsWith(" Status ")));
|
|
128
|
+
assert.deepEqual(f.root.render(), ["transcript"]);
|
|
129
|
+
restored.updateLayout(20, 5, () => {});
|
|
130
|
+
const before = restored.scrollTop;
|
|
131
|
+
const result = restored.handleMouse({ type: "wheel", wheelDelta: 2, x: 3, y: 4, screenX: 113, screenY: 6, width: 50, height: 5 } as Parameters<typeof restored.handleMouse>[0]);
|
|
132
|
+
assert.equal(result?.handled, true);
|
|
133
|
+
assert.equal(result?.target?.component, restored);
|
|
134
|
+
assert.equal(restored.scrollTop, Math.min(15, before + 2));
|
|
135
|
+
for (const line of restored.render(50)) assert.ok(visibleWidth(line) <= 50);
|
|
136
|
+
assert.deepEqual(f.root.render(), ["transcript"]);
|
|
137
|
+
scroll.updateLayout(1, 5, () => {});
|
|
138
|
+
assert.equal(scroll.handleMouse({ type: "wheel", wheelDelta: 1 } as Parameters<typeof scroll.handleMouse>[0])?.handled, true);
|
|
139
|
+
assert.equal(scroll.scrollTop, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rail dispatches a clipped, scroll-translated left click to only the matching sidebar part", (t) => {
|
|
143
|
+
const f = fixture();
|
|
144
|
+
let clicks = 0;
|
|
145
|
+
const received: TuiMouseEvent[] = [];
|
|
146
|
+
const todo = {
|
|
147
|
+
render: () => ["Todo header", "Todo body"],
|
|
148
|
+
invalidate() {},
|
|
149
|
+
handleMouse(event: TuiMouseEvent) {
|
|
150
|
+
received.push(event);
|
|
151
|
+
if (event.type !== "click" || event.button !== "left" || event.y !== 0) return undefined;
|
|
152
|
+
clicks++;
|
|
153
|
+
return { handled: true, render: true };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
sidebarPart(f.tui, "todo", todo);
|
|
157
|
+
const dispose = installSidebar(f.tui, theme);
|
|
158
|
+
t.after(dispose);
|
|
159
|
+
const scroll = rail(f);
|
|
160
|
+
const content = scroll.render(50);
|
|
161
|
+
scroll.updateLayout(content.length, 5, () => {});
|
|
162
|
+
scroll.scrollBy(1);
|
|
163
|
+
const headerY = content.findIndex((line) => line.includes("Todo header")) - scroll.scrollTop;
|
|
164
|
+
assert.ok(headerY >= 0);
|
|
165
|
+
const mouse = (type: TuiMouseEvent["type"], button: TuiMouseEvent["button"], y: number): TuiMouseEvent => ({
|
|
166
|
+
type, button, x: 2, y, screenX: 92, screenY: 30 + y, width: 50, height: 5, shift: false, alt: false, ctrl: false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.equal(scroll.handleMouse(mouse("press", "left", headerY)), undefined);
|
|
170
|
+
assert.equal(scroll.handleMouse(mouse("click", "right", headerY)), undefined);
|
|
171
|
+
assert.equal(scroll.handleMouse(mouse("click", "left", headerY + 1)), undefined);
|
|
172
|
+
assert.equal(clicks, 0);
|
|
173
|
+
const hit = scroll.handleMouse(mouse("click", "left", headerY));
|
|
174
|
+
assert.equal(hit?.handled, true);
|
|
175
|
+
assert.equal(clicks, 1);
|
|
176
|
+
assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 47, height: 2 });
|
|
177
|
+
|
|
178
|
+
const gapY = headerY - 1;
|
|
179
|
+
assert.equal(scroll.handleMouse(mouse("click", "left", gapY)), undefined, "section gaps do not hit a neighbor");
|
|
180
|
+
assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 48 }), undefined, "padding outside the clipped part is inert");
|
|
181
|
+
invalidateSidebar(f.tui);
|
|
182
|
+
assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "stale geometry is inert");
|
|
183
|
+
f.host.terminal.columns = 139;
|
|
184
|
+
assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "resized-away rails are inert");
|
|
185
|
+
dispose();
|
|
186
|
+
assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "disposed rails are inert");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("rail rejects removed or replaced parts before cached geometry is prepared again", (t) => {
|
|
190
|
+
const f = fixture();
|
|
191
|
+
let originalClicks = 0;
|
|
192
|
+
let replacementClicks = 0;
|
|
193
|
+
const original = {
|
|
194
|
+
render: () => ["Todo header"],
|
|
195
|
+
invalidate() {},
|
|
196
|
+
handleMouse(event: TuiMouseEvent) {
|
|
197
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
198
|
+
originalClicks++;
|
|
199
|
+
return { handled: true };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, original);
|
|
203
|
+
const dispose = installSidebar(f.tui, theme);
|
|
204
|
+
t.after(dispose);
|
|
205
|
+
const scroll = rail(f);
|
|
206
|
+
const content = scroll.render(50);
|
|
207
|
+
scroll.updateLayout(content.length, 5, () => {});
|
|
208
|
+
const headerY = content.findIndex((line) => line.includes("Todo header"));
|
|
209
|
+
const click = (): TuiMouseEvent => ({ type: "click", button: "left", x: 2, y: headerY, screenX: 92, screenY: 30 + headerY, width: 50, height: 5, shift: false, alt: false, ctrl: false });
|
|
210
|
+
assert.equal(scroll.handleMouse(click())?.handled, true, "healthy cached geometry still dispatches");
|
|
211
|
+
assert.equal(originalClicks, 1);
|
|
212
|
+
|
|
213
|
+
mounted.dispose();
|
|
214
|
+
assert.equal(scroll.handleMouse(click()), undefined, "removed parts are inert before the next prepare");
|
|
215
|
+
assert.equal(originalClicks, 1);
|
|
216
|
+
|
|
217
|
+
const replacement = {
|
|
218
|
+
render: () => ["Todo replacement"],
|
|
219
|
+
invalidate() {},
|
|
220
|
+
handleMouse(event: TuiMouseEvent) {
|
|
221
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
222
|
+
replacementClicks++;
|
|
223
|
+
return { handled: true };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, replacement);
|
|
227
|
+
assert.equal(scroll.handleMouse(click()), undefined, "a replacement cannot receive stale cached geometry");
|
|
228
|
+
assert.equal(originalClicks, 1);
|
|
229
|
+
assert.equal(replacementClicks, 0);
|
|
230
|
+
|
|
231
|
+
rail(f);
|
|
232
|
+
assert.equal(scroll.handleMouse(click())?.handled, true, "prepared replacement dispatches normally");
|
|
233
|
+
assert.equal(replacementClicks, 1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("real layout frames reuse unchanged sidebar output and invalidate at state and breakpoint boundaries", (t) => {
|
|
237
|
+
const f = fixture();
|
|
238
|
+
const counts = { footer: 0, changes: 0, agents: 0, todo: 0 };
|
|
239
|
+
let todo = "Todo one";
|
|
240
|
+
for (const key of Object.keys(counts) as Array<keyof typeof counts>) {
|
|
241
|
+
sidebarPart(f.tui, key, {
|
|
242
|
+
render: () => {
|
|
243
|
+
counts[key]++;
|
|
244
|
+
return [`${key === "todo" ? todo : key}`];
|
|
245
|
+
},
|
|
246
|
+
invalidate() {},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
t.after(installSidebar(f.tui, theme));
|
|
250
|
+
|
|
251
|
+
const first = renderLayoutFrame(f.root, 140, 20, () => {});
|
|
252
|
+
assert.deepEqual(counts, { footer: 1, changes: 1, agents: 1, todo: 1 });
|
|
253
|
+
const sidebarRail = first.root.children[1]?.component as ScrollView;
|
|
254
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
255
|
+
assert.deepEqual(counts, { footer: 1, changes: 1, agents: 1, todo: 1 });
|
|
256
|
+
|
|
257
|
+
todo = "Todo two";
|
|
258
|
+
sidebarRail.invalidate();
|
|
259
|
+
const changed = renderLayoutFrame(f.root, 140, 20, () => {});
|
|
260
|
+
assert.match(changed.lines.join("\n"), /Todo two/);
|
|
261
|
+
assert.deepEqual(counts, { footer: 2, changes: 2, agents: 2, todo: 2 });
|
|
262
|
+
|
|
263
|
+
f.host.terminal.columns = 139;
|
|
264
|
+
renderLayoutFrame(f.root, 139, 20, () => {});
|
|
265
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
266
|
+
f.host.terminal.columns = 140;
|
|
267
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
268
|
+
assert.deepEqual(counts, { footer: 3, changes: 3, agents: 3, todo: 3 });
|
|
269
|
+
|
|
270
|
+
f.host.mode = "regular";
|
|
271
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
272
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
273
|
+
f.host.mode = "fullscreen";
|
|
274
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
275
|
+
assert.deepEqual(counts, { footer: 4, changes: 4, agents: 4, todo: 4 });
|
|
276
|
+
|
|
277
|
+
let replacementRenders = 0;
|
|
278
|
+
sidebarPart(f.tui, "todo", {
|
|
279
|
+
render: () => {
|
|
280
|
+
replacementRenders++;
|
|
281
|
+
return ["Todo replacement"];
|
|
282
|
+
},
|
|
283
|
+
invalidate() {},
|
|
284
|
+
});
|
|
285
|
+
const replaced = renderLayoutFrame(f.root, 140, 20, () => {});
|
|
286
|
+
assert.match(replaced.lines.join("\n"), /Todo replacement/);
|
|
287
|
+
assert.equal(replacementRenders, 1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a rail digest refreshes live state that no invalidation announces", (t) => {
|
|
291
|
+
const f = fixture();
|
|
292
|
+
let model = "model-a";
|
|
293
|
+
let renders = 0;
|
|
294
|
+
// The digest is the only signal: no part is re-registered and invalidateSidebar
|
|
295
|
+
// is never called here, which is exactly the /model case in fullscreen.
|
|
296
|
+
sidebarPart(f.tui, "footer", { render: () => ["Status"], invalidate() {} }, {
|
|
297
|
+
digest: () => model,
|
|
298
|
+
render: () => {
|
|
299
|
+
renders++;
|
|
300
|
+
return [`Model ${model}`];
|
|
301
|
+
},
|
|
302
|
+
invalidate() {},
|
|
303
|
+
});
|
|
304
|
+
t.after(installSidebar(f.tui, theme));
|
|
305
|
+
|
|
306
|
+
assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-a/);
|
|
307
|
+
assert.equal(renders, 1);
|
|
308
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
309
|
+
assert.equal(renders, 1, "an unchanged digest still reuses the prepared rail");
|
|
310
|
+
|
|
311
|
+
model = "model-b";
|
|
312
|
+
assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-b/);
|
|
313
|
+
assert.equal(renders, 2);
|
|
314
|
+
|
|
315
|
+
// Rail digests run inside the layout frame, so a broken one must not disable
|
|
316
|
+
// the sidebar for the parts that still work.
|
|
317
|
+
let todoRenders = 0;
|
|
318
|
+
sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, {
|
|
319
|
+
digest: () => { throw new Error("broken digest"); },
|
|
320
|
+
render: () => {
|
|
321
|
+
todoRenders++;
|
|
322
|
+
return ["Todo card"];
|
|
323
|
+
},
|
|
324
|
+
invalidate() {},
|
|
325
|
+
});
|
|
326
|
+
model = "model-c";
|
|
327
|
+
assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Model model-c/);
|
|
328
|
+
assert.equal(todoRenders, 1);
|
|
329
|
+
assert.match(renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"), /Todo card/);
|
|
330
|
+
assert.equal(todoRenders, 1, "a throwing digest degrades to invalidation-only");
|
|
331
|
+
invalidateSidebar(f.tui);
|
|
332
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
333
|
+
assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("reuses final sidebar presentation until a relevant invalidation", (t) => {
|
|
337
|
+
const f = fixture();
|
|
338
|
+
sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
|
|
339
|
+
t.after(installSidebar(f.tui, theme));
|
|
340
|
+
|
|
341
|
+
const first = f.root[NODE]();
|
|
342
|
+
assert.equal(f.root[NODE](), first);
|
|
343
|
+
const scroll = (first as { entries: { component: ScrollView }[] }).entries[1].component;
|
|
344
|
+
scroll.updateLayout(scroll.render(50).length, 1, () => {});
|
|
345
|
+
scroll.scrollBy(1);
|
|
346
|
+
const scrolled = f.root[NODE]();
|
|
347
|
+
assert.notEqual(scrolled, first);
|
|
348
|
+
assert.equal(f.root[NODE](), scrolled);
|
|
349
|
+
|
|
350
|
+
scroll.invalidate();
|
|
351
|
+
const invalidated = f.root[NODE]();
|
|
352
|
+
assert.notEqual(invalidated, scrolled);
|
|
353
|
+
assert.equal(f.root[NODE](), invalidated);
|
|
354
|
+
|
|
355
|
+
f.host.terminal.columns = 141;
|
|
356
|
+
const resized = f.root[NODE]();
|
|
357
|
+
assert.notEqual(resized, invalidated);
|
|
358
|
+
assert.equal(f.root[NODE](), resized);
|
|
359
|
+
f.host.mode = "regular";
|
|
360
|
+
assert.equal(f.root[NODE]().type, "vstack");
|
|
361
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("cleanup restores the native layout and bottom paint without disposing widgets", () => {
|
|
365
|
+
const f = fixture();
|
|
366
|
+
const dispose = installSidebar(f.tui, theme);
|
|
367
|
+
rail(f);
|
|
368
|
+
dispose();
|
|
369
|
+
assert.equal(f.root[NODE], f.original);
|
|
370
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
371
|
+
assert.equal(sidebarState(f.tui).parts.size, 1);
|
|
372
|
+
assert.ok(f.renders() >= 2);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("unsupported roots, empty rails and overflowing parts leave native layout intact", (t) => {
|
|
376
|
+
for (const lines of [[], ["x".repeat(100)]]) {
|
|
377
|
+
const f = fixture();
|
|
378
|
+
sidebarPart(f.tui, "footer", { render: () => lines, invalidate() {} });
|
|
379
|
+
t.after(installSidebar(f.tui, theme));
|
|
380
|
+
assert.equal(f.root[NODE]().type, "vstack");
|
|
381
|
+
assert.equal(sidebarState(f.tui).active, false);
|
|
382
|
+
}
|
|
383
|
+
const f = fixture();
|
|
384
|
+
Reflect.deleteProperty(f.root, NODE);
|
|
385
|
+
t.after(installSidebar(f.tui, theme));
|
|
386
|
+
assert.deepEqual(f.bottom.render(80), ["Status"]);
|
|
387
|
+
});
|