gentle-pi 2.4.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -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 +39 -11
- package/assets/orchestrator.md +5 -5
- 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/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- 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 +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- 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 +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- 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 +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -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 +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- 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 +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- 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 +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- 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-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- 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 +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -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-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- 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 +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { SessionWorktreeRegistry, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
|
|
9
|
+
|
|
10
|
+
// All Git and session writes belong to unique fixtures, never the live clone.
|
|
11
|
+
function fixture(t: test.TestContext) {
|
|
12
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "session-worktrees-")));
|
|
13
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
14
|
+
const main = join(dir, "main");
|
|
15
|
+
const linked = join(dir, "linked");
|
|
16
|
+
const other = join(dir, "other");
|
|
17
|
+
const empty = join(dir, "empty");
|
|
18
|
+
mkdirSync(empty);
|
|
19
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
20
|
+
Object.assign(env, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1" });
|
|
21
|
+
writeFileSync(join(empty, "config"), "");
|
|
22
|
+
const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
23
|
+
git(dir, ["init", "--initial-branch=main", `--template=${empty}`, main]);
|
|
24
|
+
git(main, ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "Fixture"]);
|
|
25
|
+
git(main, ["worktree", "add", "-b", "linked", linked]);
|
|
26
|
+
git(dir, ["init", `--template=${empty}`, other]);
|
|
27
|
+
mkdirSync(join(linked, "src"));
|
|
28
|
+
writeFileSync(join(linked, "src", "file.ts"), "preexisting\n");
|
|
29
|
+
const alias = join(dir, "alias");
|
|
30
|
+
symlinkSync(linked, alias, "dir");
|
|
31
|
+
return { dir, main, linked, other, alias };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function host(cwd: string, session = SessionManager.inMemory(cwd)) {
|
|
35
|
+
const emitted: unknown[] = [];
|
|
36
|
+
const pi = { appendEntry: (type: string, data: unknown) => { session.appendCustomEntry(type, data); }, events: { emit: (_name: string, data: unknown) => emitted.push(data) } };
|
|
37
|
+
return { pi, session, emitted, registry: () => new SessionWorktreeRegistry(pi, session, cwd) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("canonical roots include cwd, dedupe aliases immediately and reject unrelated clones", (t) => {
|
|
41
|
+
const f = fixture(t);
|
|
42
|
+
const h = host(f.main);
|
|
43
|
+
const registry = h.registry();
|
|
44
|
+
registry.start();
|
|
45
|
+
assert.deepEqual(registry.roots(), [f.main]);
|
|
46
|
+
assert.equal(registry.register(join(f.alias, "src", "file.ts"), "tool:read"), f.linked);
|
|
47
|
+
registry.register(f.linked, "explicit");
|
|
48
|
+
assert.deepEqual(registry.roots(), [f.main, f.linked]);
|
|
49
|
+
assert.equal(h.session.getEntries().length, 2);
|
|
50
|
+
assert.throws(() => registry.register(f.other, "explicit"), /same Git clone/);
|
|
51
|
+
assert.throws(() => registry.register(join(f.dir, "missing"), "explicit"), /worktree/);
|
|
52
|
+
assert.equal(resolveSessionWorktree(f.alias, f.main)?.root, f.linked);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("restore reads the whole session tree, resumes the same id, and ignores new/fork/clone ids", (t) => {
|
|
56
|
+
const f = fixture(t);
|
|
57
|
+
const session = SessionManager.create(f.main, join(f.dir, "sessions"));
|
|
58
|
+
const first = session.appendMessage({ role: "user", content: "start", timestamp: 1 });
|
|
59
|
+
// Pi persists entries after the first assistant message.
|
|
60
|
+
session.appendMessage({ role: "assistant", content: [], api: "openai-responses", provider: "test", model: "test", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 2 });
|
|
61
|
+
const h = host(f.main, session);
|
|
62
|
+
h.registry().start();
|
|
63
|
+
h.registry().register(f.linked, "explicit");
|
|
64
|
+
session.branch(first);
|
|
65
|
+
assert.deepEqual(h.registry().roots(), [f.main, f.linked], "off-branch registrations remain session-wide");
|
|
66
|
+
const resumed = host(f.main, SessionManager.open(session.getSessionFile()!));
|
|
67
|
+
assert.deepEqual(resumed.registry().roots(), [f.main, f.linked]);
|
|
68
|
+
const forkPath = session.createBranchedSession(session.getEntries().at(-1)!.id)!;
|
|
69
|
+
const fork = host(f.main, SessionManager.open(forkPath));
|
|
70
|
+
fork.registry().start();
|
|
71
|
+
assert.deepEqual(fork.registry().roots(), [f.main], "inherited custom entries carry the old id");
|
|
72
|
+
const fresh = host(f.main);
|
|
73
|
+
for (const entry of resumed.session.getEntries()) {
|
|
74
|
+
if (entry.type === "custom") fresh.session.appendCustomEntry(entry.customType, entry.data);
|
|
75
|
+
}
|
|
76
|
+
fresh.registry().start();
|
|
77
|
+
assert.deepEqual(fresh.registry().roots(), [f.main], "cloned entries cannot expand a new session registry");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("durable append works without a shell listener and independent registries do not double append", (t) => {
|
|
81
|
+
const f = fixture(t);
|
|
82
|
+
const h = host(f.main);
|
|
83
|
+
const shell = h.registry();
|
|
84
|
+
shell.start();
|
|
85
|
+
const agents = h.registry();
|
|
86
|
+
agents.register(f.linked, "subagent:spawn");
|
|
87
|
+
assert.deepEqual(shell.roots(), [f.main, f.linked]);
|
|
88
|
+
shell.register(f.alias, "explicit");
|
|
89
|
+
assert.equal(h.session.getEntries().filter((e) => e.type === "custom" && e.customType === SESSION_WORKTREE_ENTRY).length, 2);
|
|
90
|
+
agents.close();
|
|
91
|
+
assert.throws(() => agents.register(f.main, "explicit"), /inactive session/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("restoration validates entries and hides missing roots without deleting their registrations", (t) => {
|
|
95
|
+
const f = fixture(t);
|
|
96
|
+
const h = host(f.main);
|
|
97
|
+
const registry = h.registry();
|
|
98
|
+
registry.start();
|
|
99
|
+
registry.register(f.linked, "explicit");
|
|
100
|
+
h.session.appendCustomEntry(SESSION_WORKTREE_ENTRY, { sessionId: h.session.getSessionId(), root: f.other, evidence: "explicit" });
|
|
101
|
+
h.session.appendCustomEntry(SESSION_WORKTREE_ENTRY, { sessionId: h.session.getSessionId(), root: 42 });
|
|
102
|
+
rmSync(f.linked, { recursive: true });
|
|
103
|
+
assert.deepEqual(registry.roots(), [f.main]);
|
|
104
|
+
assert.ok(h.session.getEntries().some((entry) => entry.type === "custom" && (entry.data as { root?: string })?.root === f.linked));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("Git child environment removes routing and config overrides without mutating its source", () => {
|
|
108
|
+
const env = { PATH: "/tools", HOME: "/fixture", GIT_DIR: "/foreign/.git", GIT_WORK_TREE: "/foreign", GIT_INDEX_FILE: "/foreign/index", GIT_COMMON_DIR: "/foreign/common", GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.worktree", GIT_CONFIG_VALUE_0: "/foreign", git_dir: "/windows-case-alias" };
|
|
109
|
+
const before = { ...env };
|
|
110
|
+
assert.deepEqual(worktreeGitEnvironment(env), { PATH: "/tools", HOME: "/fixture" });
|
|
111
|
+
assert.deepEqual(env, before);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("session startup identity lookup hides its direct Git children", (t) => {
|
|
115
|
+
const f = fixture(t);
|
|
116
|
+
const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
|
|
117
|
+
const run = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
|
|
118
|
+
calls.push({ command, args, options });
|
|
119
|
+
return args.at(-1) === "--show-toplevel" ? `${f.main}\n` : `${join(f.main, ".git")}\n`;
|
|
120
|
+
}) as typeof import("node:child_process").execFileSync;
|
|
121
|
+
assert.equal(resolveSessionWorktreeWithGit(f.main, f.main, run)?.root, f.main);
|
|
122
|
+
assert.equal(calls.length, 2);
|
|
123
|
+
for (const call of calls) {
|
|
124
|
+
assert.equal(call.command, "git");
|
|
125
|
+
assert.equal(call.options.shell, false);
|
|
126
|
+
assert.equal(call.options.windowsHide, true);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("only standard path-bearing calls have registration candidates; shell and prose never do", () => {
|
|
131
|
+
for (const name of ["read", "write", "edit", "grep", "find", "ls"]) assert.equal(toolWorktreePath(name, { path: "../linked/file" }), "../linked/file");
|
|
132
|
+
for (const name of ["grep", "find", "ls"]) assert.equal(toolWorktreePath(name, {}), ".");
|
|
133
|
+
for (const name of ["bash", "powershell", "custom", "subagent_run"]) assert.equal(toolWorktreePath(name, { path: "/linked", command: "cd /linked", task: "/linked" }), undefined);
|
|
134
|
+
assert.equal(toolWorktreePath("read", { path: 42 }), undefined);
|
|
135
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
formatCost,
|
|
6
|
+
formatTokens,
|
|
7
|
+
gaugeTone,
|
|
8
|
+
renderGauge,
|
|
9
|
+
renderShellBar,
|
|
10
|
+
shellEnabled,
|
|
11
|
+
type ShellBarModel,
|
|
12
|
+
type ShellBarTheme,
|
|
13
|
+
} from "../lib/shell-bar.ts";
|
|
14
|
+
|
|
15
|
+
// The Gentle Shell bar replaces pi's three-line footer with one line of
|
|
16
|
+
// segments. Rendering is pure so it can be verified without a TUI.
|
|
17
|
+
|
|
18
|
+
const taggedTheme: ShellBarTheme = {
|
|
19
|
+
fg(color: string, value: string) {
|
|
20
|
+
return `<${color}>${value}</${color}>`;
|
|
21
|
+
},
|
|
22
|
+
bold(value: string) {
|
|
23
|
+
return value;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const plainTheme: ShellBarTheme = {
|
|
28
|
+
fg(_color: string, value: string) {
|
|
29
|
+
return value;
|
|
30
|
+
},
|
|
31
|
+
bold(value: string) {
|
|
32
|
+
return value;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function model(overrides: Partial<ShellBarModel> = {}): ShellBarModel {
|
|
37
|
+
return {
|
|
38
|
+
cwd: "~/work/gentle-pi",
|
|
39
|
+
branch: "main",
|
|
40
|
+
dirty: undefined,
|
|
41
|
+
sessionName: undefined,
|
|
42
|
+
modelId: "gpt-5.5",
|
|
43
|
+
effort: "medium",
|
|
44
|
+
contextPercent: 45,
|
|
45
|
+
contextWindow: 272_000,
|
|
46
|
+
costTotal: 9.49,
|
|
47
|
+
subscription: true,
|
|
48
|
+
usage: undefined,
|
|
49
|
+
statuses: [],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("renderGauge fills cells proportionally to the percentage", () => {
|
|
55
|
+
assert.equal(renderGauge(45, 8), "▰▰▰▰▱▱▱▱");
|
|
56
|
+
assert.equal(renderGauge(0, 8), "▱▱▱▱▱▱▱▱");
|
|
57
|
+
assert.equal(renderGauge(100, 8), "▰▰▰▰▰▰▰▰");
|
|
58
|
+
assert.equal(renderGauge(null, 8), "▱▱▱▱▱▱▱▱");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("gaugeTone turns to warning at 80% and error at 95%", () => {
|
|
62
|
+
assert.equal(gaugeTone(45), "accent");
|
|
63
|
+
assert.equal(gaugeTone(79.9), "accent");
|
|
64
|
+
assert.equal(gaugeTone(80), "warning");
|
|
65
|
+
assert.equal(gaugeTone(95), "error");
|
|
66
|
+
assert.equal(gaugeTone(null), "dim");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("formatTokens and formatCost keep the bar compact", () => {
|
|
70
|
+
assert.equal(formatTokens(950), "950");
|
|
71
|
+
assert.equal(formatTokens(4_200), "4.2k");
|
|
72
|
+
assert.equal(formatTokens(272_000), "272k");
|
|
73
|
+
assert.equal(formatTokens(13_000_000), "13M");
|
|
74
|
+
assert.equal(formatCost(9.49, true), "$9.49 sub");
|
|
75
|
+
assert.equal(formatCost(0.004, false), "$0.004");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("renderShellBar renders one line with the segments in order", () => {
|
|
79
|
+
const [line, ...rest] = renderShellBar(model(), plainTheme, 160);
|
|
80
|
+
assert.equal(rest.length, 0);
|
|
81
|
+
assert.equal(
|
|
82
|
+
line,
|
|
83
|
+
"✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
|
|
88
|
+
const [line] = renderShellBar(model(), taggedTheme, 400);
|
|
89
|
+
assert.match(line, /<accent>✿ gentle-pi<\/accent>/);
|
|
90
|
+
assert.match(line, /<text>gpt-5\.5<\/text>/);
|
|
91
|
+
assert.match(line, /<syntaxFunction>medium<\/syntaxFunction>/);
|
|
92
|
+
assert.match(line, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
|
|
93
|
+
assert.match(line, /<dim>⟡<\/dim>/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("renderShellBar shows the branch as dirty-neutral and omits it outside git", () => {
|
|
97
|
+
const [line] = renderShellBar(model({ branch: null }), plainTheme, 160);
|
|
98
|
+
assert.match(line, /⟡ ~\/work\/gentle-pi ⟡/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("renderShellBar shows the session dirty count next to the branch", () => {
|
|
102
|
+
const [line] = renderShellBar(model({ dirty: 3 }), taggedTheme, 400);
|
|
103
|
+
assert.match(line, /<text>main<\/text> <warning>±3<\/warning>/);
|
|
104
|
+
const [clean] = renderShellBar(model({ dirty: 0 }), plainTheme, 160);
|
|
105
|
+
assert.doesNotMatch(clean, /±/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("renderShellBar adds the subscription windows after the cost when usage is known", () => {
|
|
109
|
+
const usage = {
|
|
110
|
+
provider: "openai-codex",
|
|
111
|
+
plan: "pro",
|
|
112
|
+
fetchedAt: 0,
|
|
113
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
114
|
+
{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null },
|
|
115
|
+
{ label: "week", usedPercent: 31, windowSeconds: 604_800, resetAt: null },
|
|
116
|
+
] }],
|
|
117
|
+
};
|
|
118
|
+
const [line] = renderShellBar(model({ usage }), plainTheme, 200);
|
|
119
|
+
assert.match(line, /\$9\.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("renderShellBar shows an unknown context as a question mark after compaction", () => {
|
|
123
|
+
const [line] = renderShellBar(model({ contextPercent: null }), plainTheme, 160);
|
|
124
|
+
assert.match(line, /ctx ▱▱▱▱▱▱▱▱ \?%/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("renderShellBar right-aligns the session name when it fits", () => {
|
|
128
|
+
const [line] = renderShellBar(model({ sessionName: "Release notes" }), plainTheme, 120);
|
|
129
|
+
assert.equal(visibleWidth(line), 120);
|
|
130
|
+
assert.match(line, /Release notes$/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("renderShellBar appends extension statuses as trailing segments", () => {
|
|
134
|
+
const [line] = renderShellBar(model({ statuses: ["🔌 MCP: 3 servers\tenabled"] }), plainTheme, 160);
|
|
135
|
+
assert.match(line, /⟡ 🔌 MCP: 3 servers enabled$/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("renderShellBar repaints extension statuses in the bar role, discarding colors the extension embedded", () => {
|
|
139
|
+
const tagged = { fg: (color: string, text: string) => `<${color}>${text}</${color}>`, bold: (text: string) => text };
|
|
140
|
+
const [line] = renderShellBar(model({ statuses: ["\x1b[38;2;255;0;0mMCP: 3/3 servers\x1b[0m"] }), tagged, 400);
|
|
141
|
+
assert.match(line, /<muted>MCP: 3\/3 servers<\/muted>$/);
|
|
142
|
+
assert.doesNotMatch(line, /\x1b\[/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("renderShellBar compacts the path and branch before it sacrifices an extension status", () => {
|
|
146
|
+
const long = model({ branch: "fix/shell-bar-status-ansi", dirty: 2, statuses: ["MCP: 3/3 servers"] });
|
|
147
|
+
const [full] = renderShellBar(long, plainTheme, 160);
|
|
148
|
+
assert.match(full, /~\/work\/gentle-pi fix\/shell-bar-status-ansi ±2 .* MCP: 3\/3 servers$/);
|
|
149
|
+
const [compact] = renderShellBar(long, plainTheme, 118);
|
|
150
|
+
assert.ok(visibleWidth(compact) <= 118, `line overflowed: ${visibleWidth(compact)}`);
|
|
151
|
+
assert.match(compact, /⟡ gentle-pi fix\/shell-bar-… ±2 ⟡/);
|
|
152
|
+
assert.match(compact, /MCP: 3\/3 servers$/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("renderShellBar drops the session name, then trailing segments, before truncating", () => {
|
|
156
|
+
const wide = model({ sessionName: "Release notes", statuses: ["MCP: 3 servers enabled"] });
|
|
157
|
+
const [atNinety] = renderShellBar(wide, plainTheme, 90);
|
|
158
|
+
assert.ok(visibleWidth(atNinety) <= 90, `line overflowed: ${visibleWidth(atNinety)}`);
|
|
159
|
+
assert.doesNotMatch(atNinety, /Release notes/);
|
|
160
|
+
assert.match(atNinety, /gpt-5\.5/);
|
|
161
|
+
|
|
162
|
+
const [atFifty] = renderShellBar(wide, plainTheme, 50);
|
|
163
|
+
assert.ok(visibleWidth(atFifty) <= 50, `line overflowed: ${visibleWidth(atFifty)}`);
|
|
164
|
+
assert.match(atFifty, /^✿ gentle-pi/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("shellEnabled stays off inside a Gentle Agents child", () => {
|
|
168
|
+
assert.equal(shellEnabled({ GENTLE_PI_AGENTS_CHILD: "1" }), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
|
|
172
|
+
assert.equal(shellEnabled({}), true);
|
|
173
|
+
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "1" }), true);
|
|
174
|
+
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "0" }), false);
|
|
175
|
+
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
|
|
176
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { CARD_TONE, renderCard, type Card } from "../lib/shell-card.ts";
|
|
5
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
6
|
+
|
|
7
|
+
// Cards are how Gentle notices look: the same rounded frame as the prompt,
|
|
8
|
+
// with the title in the notice tone. They collapse to one body line when pi
|
|
9
|
+
// asks for it.
|
|
10
|
+
|
|
11
|
+
const plainTheme = {
|
|
12
|
+
fg(_color: string, text: string) {
|
|
13
|
+
return text;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const taggedTheme = {
|
|
18
|
+
fg(color: string, text: string) {
|
|
19
|
+
return `<${color}>${text}</${color}>`;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ansiTheme = {
|
|
24
|
+
fg(_color: string, text: string) {
|
|
25
|
+
return `\x1b[35m${text}\x1b[0m`;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function card(overrides: Partial<Card> = {}): Card {
|
|
30
|
+
return {
|
|
31
|
+
title: "Gentle AI",
|
|
32
|
+
subtitle: "review preflight",
|
|
33
|
+
body: ["Receipt-driven development is enabled, and this worktree holds an unreviewed candidate.", "", "Call the gentle_review tool with inspect and follow the transition it returns."],
|
|
34
|
+
tone: CARD_TONE.INFO,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("renderCard draws the rounded frame with the title in the top rule and wraps the body inside", () => {
|
|
40
|
+
const lines = renderCard(card(), plainTheme, 48, { expanded: true }).map(stripAnsi);
|
|
41
|
+
assert.match(lines[0], /^╭─ ✿ Gentle AI · review preflight ─+╮$/);
|
|
42
|
+
assert.match(lines[1], /^│ Receipt-driven development is enabled, and +│$/);
|
|
43
|
+
for (const line of lines) assert.equal(visibleWidth(line), 48, `"${line}" is not 48 wide`);
|
|
44
|
+
assert.ok(lines.some((line) => /^│ +│$/.test(line)), "blank body lines keep the frame");
|
|
45
|
+
assert.ok(lines.some((line) => line.includes("gentle_review")), "every paragraph is rendered when expanded");
|
|
46
|
+
assert.match(lines[lines.length - 1], /^╰─+╯$/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("renderCard collapses to the frame and the first body line with an expand hint", () => {
|
|
50
|
+
const lines = renderCard(card(), plainTheme, 60, { expanded: false }).map(stripAnsi);
|
|
51
|
+
assert.equal(lines.length, 3);
|
|
52
|
+
assert.match(lines[1], /^│ Receipt-driven development is enabled.*… +│$/);
|
|
53
|
+
assert.equal(visibleWidth(lines[1]), 60);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("renderCard keeps the rail and its corners in the tone and the rest of the frame in the border color", () => {
|
|
57
|
+
const info = renderCard(card(), taggedTheme, 80, { expanded: true });
|
|
58
|
+
assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><border>─ <\/border><customMessageLabel>✿ Gentle AI<\/customMessageLabel> <muted>·<\/muted> <muted>review preflight<\/muted><border> ─+<\/border><border>╮<\/border>$/);
|
|
59
|
+
assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<border>│<\/border>$/);
|
|
60
|
+
assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><border>─+╯<\/border>$/);
|
|
61
|
+
|
|
62
|
+
const warning = renderCard(card({ tone: CARD_TONE.WARNING, subtitle: undefined }), taggedTheme, 80, { expanded: true });
|
|
63
|
+
assert.match(warning[0], /^<warning>╭<\/warning>/);
|
|
64
|
+
assert.match(warning[0], /<warning>✿ Gentle AI<\/warning>/);
|
|
65
|
+
assert.match(warning[1], /^<warning>│<\/warning> /);
|
|
66
|
+
assert.match(warning[warning.length - 1], /^<warning>╰<\/warning>/);
|
|
67
|
+
|
|
68
|
+
const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true });
|
|
69
|
+
assert.match(error[0], /<error>✿ Gentle AI<\/error>/);
|
|
70
|
+
assert.equal(CARD_TONE.SUCCESS, "success");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("renderCard places a hint at the right end of the top rule without background fill", () => {
|
|
74
|
+
const lines = renderCard(card(), plainTheme, 60, { expanded: false, hint: "ctrl+o expand" });
|
|
75
|
+
assert.match(stripAnsi(lines[0]), /^╭─ ✿ Gentle AI · review preflight ─+ ctrl\+o expand ╮$/);
|
|
76
|
+
assert.equal(visibleWidth(lines[0]), 60);
|
|
77
|
+
|
|
78
|
+
assert.doesNotMatch(lines.join("\n"), /\x1b\[44m/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("cards remain transparent across content resets and narrow widths", () => {
|
|
82
|
+
for (const width of [0, 1, 2, 3, 4, 8, 40]) {
|
|
83
|
+
const lines = renderCard(card({ body: ["red\x1b[0m blue", ""] }), ansiTheme, width, { expanded: true });
|
|
84
|
+
for (const [row, line] of lines.entries()) {
|
|
85
|
+
let painted = false, column = 0;
|
|
86
|
+
for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
|
|
87
|
+
if (token.startsWith("\x1b")) {
|
|
88
|
+
for (const code of token.slice(2, -1).split(";").map(Number)) {
|
|
89
|
+
if (code === 0 || code === 49) painted = false;
|
|
90
|
+
if (code === 44) painted = true;
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
assert.equal(painted, false, `row ${row}, cell ${column}`);
|
|
94
|
+
column += visibleWidth(token);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
assert.equal(painted, false, "background must not leak into host padding");
|
|
98
|
+
assert.equal(visibleWidth(line), width);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("renderCard accepts a custom glyph and an empty body", () => {
|
|
104
|
+
const lines = renderCard(card({ glyph: "✎", body: [] }), plainTheme, 40, { expanded: true }).map(stripAnsi);
|
|
105
|
+
assert.equal(lines.length, 2);
|
|
106
|
+
assert.match(lines[0], /^╭─ ✎ Gentle AI · review preflight ─+╮$/);
|
|
107
|
+
assert.match(lines[1], /^╰─+╯$/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("renderCard keeps the top rule at width with a two-cell glyph", () => {
|
|
111
|
+
const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), plainTheme, 60, { expanded: false, hint: "ctrl+o expand" });
|
|
112
|
+
for (const line of lines) assert.equal(visibleWidth(line), 60, `"${stripAnsi(line)}" is not 60 wide`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("renderCard drops the hint before truncating title content", () => {
|
|
116
|
+
const lines = renderCard(card(), plainTheme, 40, { expanded: false, hint: "ctrl+o expand" }).map(stripAnsi);
|
|
117
|
+
assert.equal(lines[0], "╭─ ✿ Gentle AI · review preflight ─────╮");
|
|
118
|
+
assert.ok(!lines[0].includes("ctrl+o"));
|
|
119
|
+
assert.equal(visibleWidth(lines[0]), 40);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("renderCard truncates ANSI-styled title content at display width", () => {
|
|
123
|
+
const lines = renderCard(
|
|
124
|
+
card({ glyph: "\u{1F339}\uFE0E", title: "Gentle AI review", subtitle: "completed · review acknowledge approved" }),
|
|
125
|
+
ansiTheme,
|
|
126
|
+
30,
|
|
127
|
+
{ expanded: false, hint: "ctrl+o to expand" },
|
|
128
|
+
);
|
|
129
|
+
assert.match(stripAnsi(lines[0]), /^╭─ 🌹︎ Gentle AI review.*╮$/);
|
|
130
|
+
assert.ok(!stripAnsi(lines[0]).includes("ctrl+o"));
|
|
131
|
+
for (const line of lines) assert.equal(visibleWidth(line), 30, `"${stripAnsi(line)}" is not 30 wide`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("renderCard never exceeds extremely narrow supplied widths", () => {
|
|
135
|
+
for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
|
|
136
|
+
const lines = renderCard(card({ glyph: "\u{1F339}\uFE0E" }), ansiTheme, width, { expanded: true, hint: "ctrl+o to expand" });
|
|
137
|
+
for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
138
|
+
}
|
|
139
|
+
});
|