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.
Files changed (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,350 @@
1
+ import assert from "node:assert/strict";
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";
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
9
+ import {
10
+ CHANGE_STATUS,
11
+ ChangesTracker,
12
+ WorktreeChangesTracker,
13
+ parseWorktrees,
14
+ changesSummary,
15
+ emptyChanges,
16
+ parseNumstat,
17
+ parsePorcelain,
18
+ renderChangesWidget,
19
+ changesModel,
20
+ snapshotChanges,
21
+ type ChangedFile,
22
+ } from "../lib/shell-changes.ts";
23
+
24
+ // The changes view shows the working tree against HEAD, new files included,
25
+ // so a resumed session sees the same picture as a fresh one.
26
+
27
+ const plainTheme = {
28
+ fg(_color: string, text: string) {
29
+ return text;
30
+ },
31
+ bold(text: string) {
32
+ return text;
33
+ },
34
+ };
35
+
36
+ const taggedTheme = {
37
+ fg(color: string, text: string) {
38
+ return `<${color}>${text}</${color}>`;
39
+ },
40
+ bold(text: string) {
41
+ return text;
42
+ },
43
+ };
44
+
45
+ function file(path: string, added: number, deleted: number, status: ChangedFile["status"] = CHANGE_STATUS.MODIFIED): ChangedFile {
46
+ return { path, added, deleted, status };
47
+ }
48
+
49
+ test("parseNumstat reads counts, treats binaries as zero, and resolves rename braces", () => {
50
+ const parsed = parseNumstat("12\t3\tlib/a.ts\n-\t-\tassets/logo.png\n0\t0\tsrc/{old => new}/file.ts\n5\t1\told.ts => new.ts\n");
51
+ assert.deepEqual(parsed, [
52
+ { path: "lib/a.ts", added: 12, deleted: 3 },
53
+ { path: "assets/logo.png", added: 0, deleted: 0 },
54
+ { path: "src/new/file.ts", added: 0, deleted: 0 },
55
+ { path: "new.ts", added: 5, deleted: 1 },
56
+ ]);
57
+ });
58
+
59
+ test("parsePorcelain reads NUL-separated status entries including renames and untracked files", () => {
60
+ const raw = [" M lib/a.ts", "A lib/b.ts", " D lib/c.ts", "?? notes.md", "R new.ts", "old.ts", "MM lib/d.ts"].join("\0") + "\0";
61
+ const parsed = parsePorcelain(raw);
62
+ assert.deepEqual(
63
+ [...parsed.entries()],
64
+ [
65
+ ["lib/a.ts", CHANGE_STATUS.MODIFIED],
66
+ ["lib/b.ts", CHANGE_STATUS.ADDED],
67
+ ["lib/c.ts", CHANGE_STATUS.DELETED],
68
+ ["notes.md", CHANGE_STATUS.UNTRACKED],
69
+ ["new.ts", CHANGE_STATUS.RENAMED],
70
+ ["lib/d.ts", CHANGE_STATUS.MODIFIED],
71
+ ],
72
+ );
73
+ });
74
+
75
+ test("snapshotChanges merges status with counts and keeps untracked files without counts", () => {
76
+ const files = snapshotChanges({
77
+ numstat: "4\t2\tlib/a.ts\n",
78
+ porcelain: " M lib/a.ts\0?? notes.md\0",
79
+ });
80
+ assert.deepEqual(files, [file("lib/a.ts", 4, 2), file("notes.md", 0, 0, CHANGE_STATUS.UNTRACKED)]);
81
+ });
82
+
83
+ test("changesModel sorts files by path and totals the counts", () => {
84
+ const model = changesModel([file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED), file("lib/a.ts", 4, 2)]);
85
+ assert.deepEqual(model.files, [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
86
+ assert.equal(model.added, 14);
87
+ assert.equal(model.deleted, 2);
88
+ });
89
+
90
+ test("changesSummary and the widget describe the session at a glance", () => {
91
+ const model = changesModel([file("extensions/gentle-shell.ts", 31, 0, CHANGE_STATUS.ADDED), file("lib/shell-bar.ts", 9, 7), file("tests/x.test.ts", 2, 0)]);
92
+ assert.equal(changesSummary(model), "3 files · +42 −7");
93
+ assert.equal(changesSummary(changesModel([file("a.ts", 1, 0)])), "1 file · +1 −0");
94
+
95
+ const [line, ...rest] = renderChangesWidget(model, plainTheme, 120);
96
+ assert.equal(rest.length, 0);
97
+ assert.match(line, /^✎ 3 files · \+42 −7 · extensions\/gentle-shell\.ts · lib\/shell-bar\.ts · tests\/x\.test\.ts {2,}\/gentle:changes$/);
98
+ assert.equal(visibleWidth(line), 120, "the command sits on the right edge");
99
+ });
100
+
101
+ test("renderChangesWidget colors counts by direction and yields nothing when clean", () => {
102
+ const model = changesModel([file("a.ts", 1, 2)]);
103
+ const [line] = renderChangesWidget(model, taggedTheme, 400);
104
+ assert.match(line, /<accent>✎<\/accent>/);
105
+ assert.match(line, /<success>\+1<\/success> <error>−2<\/error>/);
106
+ assert.match(line, /<dim>\/gentle:changes<\/dim>$/);
107
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
108
+ });
109
+
110
+ test("renderChangesWidget drops the file list before truncating on narrow terminals", () => {
111
+ const model = changesModel([file("a/very/long/path/one.ts", 1, 0), file("a/very/long/path/two.ts", 1, 0)]);
112
+ const [line] = renderChangesWidget(model, plainTheme, 40);
113
+ assert.equal(visibleWidth(line), 40);
114
+ assert.match(line, /^✎ 2 files · \+2 −0 +\/gentle:changes$/);
115
+ assert.doesNotMatch(line, /one\.ts/);
116
+ const [tiny] = renderChangesWidget(model, plainTheme, 20);
117
+ assert.ok(visibleWidth(tiny) <= 20);
118
+ assert.match(tiny, /^✎ 2 files/);
119
+ });
120
+
121
+ function fakeGit(numstats: string[], porcelains: string[], code = 0) {
122
+ const calls: string[][] = [];
123
+ let round = 0;
124
+ const git = async (args: string[]) => {
125
+ calls.push(args);
126
+ const isNumstat = args[0] === "diff";
127
+ const index = Math.min(isNumstat ? round : round++, numstats.length - 1);
128
+ return { stdout: isNumstat ? numstats[index] : porcelains[index], code };
129
+ };
130
+ return { git, calls };
131
+ }
132
+
133
+ test("ChangesTracker reads the working tree on start and again on refresh", async () => {
134
+ const { git, calls } = fakeGit(
135
+ ["4\t2\tlib/a.ts\n", "4\t2\tlib/a.ts\n10\t0\tlib/b.ts\n"],
136
+ [" M lib/a.ts\0", " M lib/a.ts\0A lib/b.ts\0"],
137
+ );
138
+ const tracker = new ChangesTracker(git);
139
+ await tracker.start();
140
+ assert.deepEqual(tracker.model.files, [file("lib/a.ts", 4, 2)]);
141
+ const model = await tracker.refresh();
142
+ assert.deepEqual(model.files, [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
143
+ assert.equal(calls.length, 4);
144
+ assert.deepEqual(calls[0], ["diff", "--numstat", "HEAD"]);
145
+ assert.deepEqual(calls[1], ["status", "--porcelain=v1", "--untracked-files=all", "-z"]);
146
+ });
147
+
148
+ test("ChangesTracker stays quiet outside a git repository", async () => {
149
+ const { git, calls } = fakeGit([""], [""], 128);
150
+ const tracker = new ChangesTracker(git);
151
+ await tracker.start();
152
+ assert.deepEqual(await tracker.refresh(), emptyChanges());
153
+ assert.equal(calls.length, 2);
154
+ });
155
+
156
+ test("ChangesTracker coalesces overlapping refreshes into at most one extra round", async () => {
157
+ let resolveGate: (() => void) | undefined;
158
+ let rounds = 0;
159
+ const git = async (args: string[]) => {
160
+ if (args[0] === "diff") {
161
+ rounds += 1;
162
+ if (rounds === 2) await new Promise<void>((resolve) => { resolveGate = resolve; });
163
+ }
164
+ return { stdout: "", code: 0 };
165
+ };
166
+ const tracker = new ChangesTracker(git);
167
+ await tracker.start();
168
+ const first = tracker.refresh();
169
+ const second = tracker.refresh();
170
+ const third = tracker.refresh();
171
+ await new Promise((resolve) => setTimeout(resolve, 0));
172
+ resolveGate?.();
173
+ await Promise.all([first, second, third]);
174
+ assert.equal(rounds, 3);
175
+ });
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
+
338
+ test("ChangesTracker counts the lines of untracked files, which git numstat leaves out", async () => {
339
+ const { git } = fakeGit(["", ""], ["", "?? notes.md\0?? empty.md\0"]);
340
+ const counted: string[] = [];
341
+ const tracker = new ChangesTracker(git, async (path) => {
342
+ counted.push(path);
343
+ return path === "notes.md" ? 3 : 0;
344
+ });
345
+ await tracker.start();
346
+ const model = await tracker.refresh();
347
+ assert.deepEqual(model.files, [file("empty.md", 0, 0, CHANGE_STATUS.UNTRACKED), file("notes.md", 3, 0, CHANGE_STATUS.UNTRACKED)]);
348
+ assert.equal(model.added, 3);
349
+ assert.deepEqual(counted, ["notes.md", "empty.md"]);
350
+ });
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { stripAnsi } from "../lib/terminal-theme.ts";
5
+ import {
6
+ framePromptLines,
7
+ PROMPT_STATE,
8
+ petalGlyph,
9
+ petalTone,
10
+ withPromptHint,
11
+ type PromptFrameOptions,
12
+ } from "../lib/shell-prompt.ts";
13
+
14
+ // The Gentle Shell prompt wraps pi's editor output (a top rule, padded content
15
+ // lines, a bottom rule) in a rounded frame with a petal that shows the agent
16
+ // state. Framing is pure: it takes the editor's lines and returns new ones.
17
+
18
+ const CURSOR = "\x1b[7m \x1b[0m";
19
+
20
+ function options(overrides: Partial<PromptFrameOptions> = {}): PromptFrameOptions {
21
+ return {
22
+ state: PROMPT_STATE.IDLE,
23
+ tick: 0,
24
+ borderColor: (text) => text,
25
+ fg: (color, text) => `<${color}>${text}</${color}>`,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ test("framePromptLines sets the petal in bold when the theme offers it", () => {
31
+ const lines = framePromptLines(editorLines(40), 40, options({ bold: (text) => `*${text}*` }));
32
+ assert.match(lines[0], /<borderAccent>\*✿\*<\/borderAccent>/);
33
+ });
34
+
35
+ function editorLines(width: number, content: string[] = [` ${CURSOR}`]): string[] {
36
+ const inner = width - 2;
37
+ return ["─".repeat(inner), ...content.map((line) => line + " ".repeat(inner - visibleWidth(line))), "─".repeat(inner)];
38
+ }
39
+
40
+ test("framePromptLines draws rounded corners, side rules, and keeps every line at width", () => {
41
+ const width = 40;
42
+ const lines = framePromptLines(editorLines(width), width, options({ fg: (_c, t) => t }));
43
+ assert.equal(lines.length, 3);
44
+ for (const line of lines) assert.equal(visibleWidth(line), width, `line "${stripAnsi(line)}" is not ${width} wide`);
45
+ assert.match(stripAnsi(lines[0]), /^╭─ ✿ ─+╮$/);
46
+ assert.match(stripAnsi(lines[1]), /^│ .* │$/);
47
+ assert.match(stripAnsi(lines[2]), /^╰─+╯$/);
48
+ });
49
+
50
+ test("framePromptLines paints the frame with the editor border color and the petal with the state tone", () => {
51
+ const lines = framePromptLines(editorLines(40), 40, options({ borderColor: (text) => `[b]${text}[/b]`, bold: (text) => `*${text}*` }));
52
+ assert.match(lines[0], /^\[b\]╭─ \[\/b\]<borderAccent>\*✿\*<\/borderAccent>\[b\] ─+╮\[\/b\]$/);
53
+ assert.match(lines[1], /^\[b\]│\[\/b\].*\[b\]│\[\/b\]$/);
54
+ assert.match(lines[2], /^\[b\]╰─+╯\[\/b\]$/);
55
+ });
56
+
57
+ test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
58
+ assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
59
+ assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["borderAccent", "accent", "thinkingHigh", "mdQuoteBorder", "borderAccent"]);
60
+ assert.equal(petalTone(PROMPT_STATE.QUEUED, 1), "warning");
61
+ });
62
+
63
+ test("petalGlyph spins through the flowers while working and rests otherwise", () => {
64
+ assert.equal(petalGlyph(PROMPT_STATE.IDLE, 3), "✿");
65
+ assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalGlyph(PROMPT_STATE.WORKING, tick)), ["✿", "❀", "❁", "✾", "✿"]);
66
+ assert.equal(petalGlyph(PROMPT_STATE.QUEUED, 1), "❀");
67
+ });
68
+
69
+ test("framePromptLines spins the petal and labels the working and queued states", () => {
70
+ const plain = (_color: string, text: string) => text;
71
+ const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 1 }));
72
+ assert.match(working[0], /<accent>❀<\/accent>/);
73
+ assert.match(working[0], /<muted>working<\/muted>/);
74
+ const workingPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 1, fg: plain }));
75
+ assert.match(stripAnsi(workingPlain[0]), /^╭─ ❀ working ─+╮$/);
76
+ assert.equal(visibleWidth(workingPlain[0]), 40);
77
+
78
+ const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED }));
79
+ assert.match(queued[0], /<warning>✿<\/warning>/);
80
+ assert.match(queued[0], /<muted>queued<\/muted>/);
81
+ const queuedPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED, fg: plain }));
82
+ assert.match(stripAnsi(queuedPlain[0]), /^╭─ ✿ queued ─+╮$/);
83
+ assert.equal(visibleWidth(queuedPlain[0]), 40);
84
+ });
85
+
86
+ test("framePromptLines keeps the editor scroll indicators inside the frame", () => {
87
+ const width = 40;
88
+ const inner = width - 2;
89
+ const top = `─── ↑ 2 more ${"─".repeat(inner - 13)}`;
90
+ const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
91
+ const lines = framePromptLines([top, ` x${" ".repeat(inner - 2)}`, bottom], width, options({ fg: (_c, t) => t }));
92
+ assert.match(stripAnsi(lines[0]), /^╭─ ✿ ↑ 2 more ─+╮$/);
93
+ assert.match(stripAnsi(lines[2]), /^╰─ ↓ 3 more ─+╯$/);
94
+ for (const line of lines) assert.equal(visibleWidth(line), width);
95
+ });
96
+
97
+ test("withPromptHint places a dim hint after the cursor on an empty editor line", () => {
98
+ const inner = 38;
99
+ const line = ` ${CURSOR}${" ".repeat(inner - 2)}`;
100
+ const hinted = withPromptHint(line, "type, or / for commands", (color, text) => `<${color}>${text}</${color}>`);
101
+ assert.match(hinted, /\x1b\[7m \x1b\[0m <dim>type, or \/ for commands<\/dim> +$/);
102
+ const hintedPlain = withPromptHint(line, "type, or / for commands", (_c, t) => t);
103
+ assert.equal(visibleWidth(hintedPlain), inner);
104
+ });
105
+
106
+ test("withPromptHint leaves the line alone when the hint does not fit", () => {
107
+ const line = ` ${CURSOR}${" ".repeat(6)}`;
108
+ const hinted = withPromptHint(line, "type, or / for commands", (_c, t) => t);
109
+ assert.equal(hinted, line);
110
+ });
111
+
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/);
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
+ });