gentle-pi 4.3.0 → 4.5.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 (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -107,7 +107,7 @@ test("visual visibility hides only selected optional status segments", () => {
107
107
  assert.doesNotMatch(renderShellHeaderBar(buildShellHeaderModel(data), plainTheme, 160, undefined, settings).text, /gpt-5\.5|ctx|\$9\.49|usage/);
108
108
  });
109
109
 
110
- test("Status groups running job descriptions beside RDD without a compact count", () => {
110
+ test("Status keeps RDD beside structured groups while running jobs moved to their own card", () => {
111
111
  const data = model({ jobs: [
112
112
  { id: "job-2", label: "CI for PR 1860", startedAt: 2 },
113
113
  { id: "job-1", label: "Build runtime", startedAt: 1 },
@@ -115,12 +115,12 @@ test("Status groups running job descriptions beside RDD without a compact count"
115
115
  for (const width of [30, 60]) {
116
116
  const lines = renderShellSidebarBar(data, plainTheme, width);
117
117
  const text = lines.join("\n");
118
- assert.match(text, /Jobs[\s\S]*CI for PR 1860[\s\S]*Build runtime/);
118
+ // Running jobs live in their own sidebar card; the Status card never
119
+ // prints them, so the two surfaces cannot duplicate one job.
120
+ assert.doesNotMatch(text, /Jobs|CI for PR 1860|Build runtime/);
119
121
  assert.match(text, /🌹 RDD/);
120
- assert.doesNotMatch(text, /2 jobs/);
121
122
  assert.ok(lines.every((line) => visibleWidth(line) <= width));
122
123
  }
123
- assert.doesNotMatch(renderShellSidebarBar(model({ jobs: [] }), plainTheme, 60).join("\n"), /Jobs/);
124
124
  });
125
125
 
126
126
  test("header shows only the running job count when requested and preserves it at mobile widths", () => {
@@ -355,7 +355,7 @@ test("renderShellSidebarBar paints the Status card frame with border and the tit
355
355
  });
356
356
 
357
357
  test("sidebar unifies project, captured changes and integrations in one frame", () => {
358
- const data = model({ changes: { files: 2, added: 7, deleted: 3, notice: "capture warning" }, statuses: ["MCP connected"] });
358
+ const data = model({ changes: { files: 2, added: 7, deleted: 3 }, statuses: ["MCP connected"] });
359
359
  const lines = renderShellSidebarBar(data, plainTheme, 46);
360
360
  const text = lines.join("\n");
361
361
  assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
@@ -366,7 +366,6 @@ test("sidebar unifies project, captured changes and integrations in one frame",
366
366
  previous = index;
367
367
  }
368
368
  assert.match(text, /2 files.*\+7.*−3/);
369
- assert.match(text, /capture warning/);
370
369
  assert.match(text, /\/gentle:changes/);
371
370
  assert.match(text, /main/);
372
371
  for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
@@ -856,6 +855,19 @@ test("unified below-input float includes optional Changes and sanitized statuses
856
855
  assert.equal(renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, changes), undefined);
857
856
  });
858
857
 
858
+ // The idle Changes row exists only when files are actually tracked; an empty
859
+ // capture — including an unborn-only root, which the tracker never scans — is
860
+ // silence (explained by /gentle:changes on demand), never an instruction row.
861
+ test("the below-input float shows no Changes row without tracked files", (t) => {
862
+ useCardStyle(t, CARD_STYLE.FLOAT);
863
+ const theme = withBackground(plainTheme);
864
+ const presentation = { ...DEFAULT_VISUAL_SETTINGS, headerPlacement: "below-input" as const };
865
+ const empty = { files: [], added: 0, deleted: 0 };
866
+ const chrome = renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, empty)!;
867
+ assert.equal(chrome.headerRow, 2, "an empty capture is silence, not a Changes row");
868
+ assert.doesNotMatch(stripAnsi(chrome.rows.join("\n")), /make a commit to start tracking/);
869
+ });
870
+
859
871
  test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) => {
860
872
  const theme = withBackground(taggedTheme);
861
873
  const data = model({ usage: USAGE_TWO_WINDOWS, statuses: ["mcp ok"] });
@@ -15,6 +15,7 @@ import {
15
15
  foreignRootBranch,
16
16
  parseWorktrees,
17
17
  changesSummary,
18
+ changesDigest,
18
19
  emptyChanges,
19
20
  parseNumstat,
20
21
  parsePorcelain,
@@ -101,6 +102,36 @@ test("changesModel sorts files by path and totals the counts", () => {
101
102
  assert.equal(model.deleted, 2);
102
103
  });
103
104
 
105
+ // The footer Status memo reads the changes digest on every keystroke, so the
106
+ // model stores it once per build and reading it never walks the file list.
107
+ // The digest hashes every per-file tuple the renderer consumes (path, status,
108
+ // counts, diffRevision, countsUnavailable); a 64-bit collision could only
109
+ // skip one refresh until the next change moves it again -- never render a
110
+ // wrong picture.
111
+ test("the changes digest is stored on the model and stays equal while nothing moves", () => {
112
+ const files = [file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)];
113
+ const model = changesModel([...files]);
114
+ assert.equal(Object.hasOwn(model, "digest"), true, "the digest is a stored field, not recomputed per read");
115
+ assert.equal(model.digest, changesDigest(model), "the stored digest matches an independently computed one");
116
+ assert.equal(changesModel([...files]).digest, model.digest, "an unchanged model rebuilds to the same digest");
117
+ assert.equal(emptyChanges().digest, emptyChanges().digest, "the empty model carries a stable digest");
118
+ });
119
+
120
+ test("the changes digest moves with every per-file detail the renderer consumes", () => {
121
+ const base = changesModel([file("lib/a.ts", 4, 2)]).digest;
122
+ const digestFor = (files: ChangedFile[]) => changesModel(files).digest!;
123
+ assert.notEqual(digestFor([file("lib/a.ts", 4, 2, CHANGE_STATUS.RENAMED)]), base, "a status change moves the digest even at equal counts");
124
+ assert.notEqual(digestFor([file("lib/a.ts", 5, 2)]), base, "an added-count change moves the digest");
125
+ assert.notEqual(digestFor([file("lib/a.ts", 4, 3)]), base, "a deleted-count change moves the digest");
126
+ assert.notEqual(digestFor([file("lib/b.ts", 4, 2)]), base, "a path change moves the digest");
127
+ const revised = file("lib/a.ts", 4, 2);
128
+ revised.diffRevision = "abc123";
129
+ assert.notEqual(digestFor([revised]), base, "a diffRevision change moves the digest");
130
+ const partial = file("lib/a.ts", 4, 2);
131
+ partial.countsUnavailable = "timeout";
132
+ assert.notEqual(digestFor([partial]), base, "countsUnavailable moves the digest");
133
+ });
134
+
104
135
  test("changesSummary and the widget describe the session at a glance", () => {
105
136
  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)]);
106
137
  assert.equal(changesSummary(model), "3 files · +42 −7");
@@ -121,6 +152,14 @@ test("renderChangesWidget colors counts by direction and yields nothing when cle
121
152
  assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
122
153
  });
123
154
 
155
+ // The unborn state (no commits yet) is silence in the idle bar: the widget
156
+ // renders only tracked files, and /gentle:changes explains the state on demand.
157
+ test("renderChangesWidget stays silent when nothing is tracked", () => {
158
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 120), []);
159
+ assert.deepEqual(renderChangesWidget(emptyChanges(), taggedTheme, 400), []);
160
+ assert.deepEqual(renderChangesWidget(emptyChanges(), plainTheme, 12), []);
161
+ });
162
+
124
163
  test("renderChangesWidget drops the file list before truncating on narrow terminals", () => {
125
164
  const model = changesModel([file("a/very/long/path/one.ts", 1, 0), file("a/very/long/path/two.ts", 1, 0)]);
126
165
  const [line] = renderChangesWidget(model, plainTheme, 40);
@@ -132,6 +171,67 @@ test("renderChangesWidget drops the file list before truncating on narrow termin
132
171
  assert.match(tiny, /^✎ 2 files/);
133
172
  });
134
173
 
174
+ // Repeated frames hit the widget from two surfaces (the sidebar part and the
175
+ // below-input header row), but its paint depends only on what the model digest
176
+ // already covers plus the width and the theme. A counting theme is the seam: a
177
+ // cache hit paints nothing, so theme.fg stays silent instead of walking files.
178
+ function countingTheme() {
179
+ let calls = 0;
180
+ return {
181
+ get fgCalls() {
182
+ return calls;
183
+ },
184
+ fg(_color: string, text: string) {
185
+ calls += 1;
186
+ return text;
187
+ },
188
+ };
189
+ }
190
+
191
+ test("renderChangesWidget memoizes a frame per digest, width and theme so an unchanged model repaints nothing", () => {
192
+ const model = changesModel([file("lib/a.ts", 4, 2), file("lib/b.ts", 10, 0, CHANGE_STATUS.ADDED)]);
193
+ const theme = countingTheme();
194
+ const first = renderChangesWidget(model, theme, 120);
195
+ assert.ok(theme.fgCalls > 0, "the first render paints");
196
+ const afterFirst = theme.fgCalls;
197
+ const second = renderChangesWidget(model, theme, 120);
198
+ assert.deepEqual(second, first, "an unchanged model, width and theme return the same rows");
199
+ assert.equal(theme.fgCalls, afterFirst, "a memo hit repaints nothing: no per-file iteration");
200
+ });
201
+
202
+ test("renderChangesWidget re-renders when the digest moves", () => {
203
+ const theme = countingTheme();
204
+ const before = changesModel([file("lib/a.ts", 4, 2)]);
205
+ renderChangesWidget(before, theme, 120);
206
+ const afterFirst = theme.fgCalls;
207
+ const moved = changesModel([file("lib/a.ts", 5, 2)]);
208
+ assert.notEqual(moved.digest, before.digest, "a count change moves the digest");
209
+ const lines = renderChangesWidget(moved, theme, 120);
210
+ assert.ok(theme.fgCalls > afterFirst, "a digest change re-renders");
211
+ assert.match(lines.join("\n"), /\+5/, "the re-render shows the new totals");
212
+ });
213
+
214
+ test("renderChangesWidget re-renders when the width or the theme changes", () => {
215
+ const model = changesModel([file("lib/a.ts", 4, 2)]);
216
+ const theme = countingTheme();
217
+ const wide = renderChangesWidget(model, theme, 400);
218
+ const afterWide = theme.fgCalls;
219
+ renderChangesWidget(model, theme, 40);
220
+ assert.ok(theme.fgCalls > afterWide, "a width change re-renders");
221
+ const otherTheme = countingTheme();
222
+ assert.deepEqual(renderChangesWidget(model, otherTheme, 400), wide, "a new theme object repaints the same wide frame");
223
+ assert.ok(otherTheme.fgCalls > 0, "the new theme did the painting");
224
+ });
225
+
226
+ test("renderChangesWidget never caches a digest-less model", () => {
227
+ const model = { files: [file("lib/a.ts", 4, 2)], added: 4, deleted: 2 };
228
+ const theme = countingTheme();
229
+ renderChangesWidget(model, theme, 120);
230
+ const afterFirst = theme.fgCalls;
231
+ renderChangesWidget(model, theme, 120);
232
+ assert.ok(theme.fgCalls > afterFirst, "a hand-built model without a digest re-renders every call");
233
+ });
234
+
135
235
  function fakeGit(numstats: string[], porcelains: string[], code = 0) {
136
236
  const calls: string[][] = [];
137
237
  let round = 0;
@@ -336,7 +436,6 @@ test("foreignRootBranch still reports a genuine detached HEAD as undefined, not
336
436
  test("a root missing from worktree list resolves its own branch instead of defaulting to detached", async (t) => {
337
437
  const scenarios: Array<{ name: string; symbolic: { stdout: string; code: number }; verify: { stdout: string; code: number }; expected: string | undefined }> = [
338
438
  { name: "a normal branch on a foreign clone", symbolic: { stdout: "feature\n", code: 0 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: "feature" },
339
- { name: "an unborn branch (no commits yet)", symbolic: { stdout: "main\n", code: 0 }, verify: { stdout: "", code: 1 }, expected: "no commits yet" },
340
439
  { name: "a genuinely detached HEAD", symbolic: { stdout: "", code: 1 }, verify: { stdout: "deadbeef\n", code: 0 }, expected: undefined },
341
440
  ];
342
441
  for (const scenario of scenarios) {
@@ -358,9 +457,57 @@ test("a root missing from worktree list resolves its own branch instead of defau
358
457
  assert.deepEqual(tracker.worktrees.map((tree) => [tree.root, tree.branch]), [["/foreign", scenario.expected]]);
359
458
  assert.ok(calls.some((args) => args[0] === "symbolic-ref"), "a root absent from worktree list must be asked directly");
360
459
  });
460
+ // An unborn foreign root is a holder before it is a branch question: with
461
+ // no commits there is no baseline to diff against, so it is skipped --
462
+ // neither dirty nor failed -- before any branch lookup happens.
463
+ await t.test("an unborn foreign root is skipped as a holder before the branch ask", async () => {
464
+ const calls: string[][] = [];
465
+ const tracker = new WorktreeChangesTracker(
466
+ async () => ({ stdout: "", code: 0 }),
467
+ () => async (args) => {
468
+ calls.push(args);
469
+ if (args[0] === "rev-parse") return { stdout: "", code: 1 };
470
+ return { stdout: "", code: 0 };
471
+ },
472
+ undefined,
473
+ () => ["/foreign"],
474
+ );
475
+ await tracker.start();
476
+ assert.deepEqual(tracker.worktrees, []);
477
+ assert.deepEqual(tracker.unbornRoots, ["/foreign"]);
478
+ assert.deepEqual(tracker.failedRoots, []);
479
+ assert.ok(!calls.some((args) => args[0] === "symbolic-ref"), "a skipped holder is never asked for a branch");
480
+ });
361
481
  }
362
482
  });
363
483
 
484
+ // A worktree-holder repository (no commits, only linked worktrees inside) has
485
+ // no baseline: `git diff HEAD` is meaningless and every file reports
486
+ // untracked, so scanning it floods Changes with thousands of phantom files.
487
+ // It is skipped as neither dirty nor failed, and its first commit starts
488
+ // tracking it on the next scan.
489
+ test("a root with no commits is skipped as a holder, neither dirty nor failed, and recovers on its first commit", async () => {
490
+ let holderHasCommits = false;
491
+ const tracker = new WorktreeChangesTracker(
492
+ async () => ({ stdout: "worktree /real\0branch refs/heads/main\0\0", code: 0 }),
493
+ (root) => async (args) => {
494
+ if (args[0] === "rev-parse") return { stdout: "", code: root === "/holder" && !holderHasCommits ? 1 : 0 };
495
+ return { stdout: args[0] === "status" && (root === "/real" || (root === "/holder" && holderHasCommits)) ? "?? a.ts\0" : "", code: 0 };
496
+ },
497
+ undefined,
498
+ () => ["/holder", "/real"],
499
+ );
500
+ await tracker.start();
501
+ assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/real"], "an unborn root is skipped, not scanned");
502
+ assert.deepEqual(tracker.unbornRoots, ["/holder"]);
503
+ assert.deepEqual(tracker.failedRoots, [], "no commits is not a Git failure");
504
+ assert.equal(tracker.scannedRootCount, 2);
505
+ holderHasCommits = true;
506
+ await tracker.refresh();
507
+ assert.deepEqual(tracker.worktrees.map((tree) => tree.root), ["/holder", "/real"], "the first commit starts tracking");
508
+ assert.deepEqual(tracker.unbornRoots, []);
509
+ });
510
+
364
511
  // The direct per-root ask (for a root missing from worktree list) can fail on
365
512
  // its own -- root gone, git unavailable -- independently of the change scan
366
513
  // that already found real files. That failure must label the root
@@ -8,15 +8,15 @@ import {
8
8
  PROMPT_STATE,
9
9
  petalGlyph,
10
10
  petalTone,
11
- SHELL_PULSE_MS,
11
+ SHELL_PULSE_WATCHDOG_MS,
12
12
  SHELL_SCANNER_STEPS,
13
13
  withPromptHint,
14
14
  type PromptFrameOptions,
15
15
  } from "../lib/shell-prompt.ts";
16
16
 
17
- test("scanner reflects a symmetric light wave across working text at Pi's original cadence", () => {
17
+ test("scanner reflects a symmetric light wave across working text at the watchdog cadence", () => {
18
18
  const fg = (role: string, char: string) => `<${role}>${char}</${role}>`;
19
- assert.equal(SHELL_PULSE_MS, 80);
19
+ assert.equal(SHELL_PULSE_WATCHDOG_MS, 1000);
20
20
  assert.equal(SHELL_SCANNER_STEPS, 15);
21
21
  for (const word of ["working…", "Thinking…"]) {
22
22
  for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
@@ -1,7 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { buildShellBarModel, type ShellFooterData } from "../extensions/gentle-shell.ts";
4
+ import { buildShellBarModel, createFooterStatusMemo, type ShellFooterData } from "../extensions/gentle-shell.ts";
5
+ import { CHANGE_STATUS } from "../lib/shell-changes.ts";
6
+ import type { RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
7
+ import type { ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
8
+ import type { ProviderUsage } from "../lib/shell-usage.ts";
5
9
  import { createQuietToolRenderer } from "../extensions/quiet-tools.ts";
6
10
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
11
 
@@ -127,3 +131,132 @@ test("image renderers keep their live geometry instead of caching opaque compone
127
131
  { expanded: true, isPartial: false }, theme as never, {} as never);
128
132
  assert.notDeepEqual(card.render(80), card.render(80));
129
133
  });
134
+
135
+ function footerMemoFixture() {
136
+ let reads = 0;
137
+ let projections = 0;
138
+ let leaf = "leaf-1";
139
+ const entries = [{ type: "message", message: { role: "assistant", usage: { cost: { total: 1 } } } }];
140
+ // One stable model object, replaced only by setModelId: real hosts keep the
141
+ // model identity stable, and sessionStatsKey compares it by identity.
142
+ let model: { id: string; contextWindow: number } = { id: "model", contextWindow: 1000 };
143
+ const ctx = {
144
+ get model() { return model; },
145
+ getContextUsage() { projections++; return { percent: 10, contextWindow: 1000 }; },
146
+ modelRegistry: { isUsingOAuth: () => false },
147
+ sessionManager: {
148
+ getEntries() { reads++; return [...entries]; },
149
+ getEntryCount: () => entries.length,
150
+ getSessionId: () => "session-1",
151
+ getLeafId: () => leaf,
152
+ getCwd: () => "/repo",
153
+ getSessionName: () => "session",
154
+ },
155
+ };
156
+ let branch: string | null = "main";
157
+ let statuses = new Map<string, string>();
158
+ let usage: ProviderUsage | undefined;
159
+ let added = 0;
160
+ let deleted = 0;
161
+ let unbornRoots = 0;
162
+ let review: ReviewSidebarSnapshot | undefined;
163
+ let jobs: RunningJobDisplay[] | undefined;
164
+ let settings: unknown = { statusPlacement: "auto" };
165
+ let style: unknown = "comfortable";
166
+ const footerData = { getGitBranch: () => branch, getExtensionStatuses: () => statuses } as unknown as ShellFooterData;
167
+ let profile: string | undefined = "team";
168
+ const memo = createFooterStatusMemo({
169
+ pi, ctx: ctx as unknown as ExtensionContext, footerData,
170
+ usage: () => usage,
171
+ profile: () => profile,
172
+ jobs: () => jobs,
173
+ review: () => review,
174
+ tracker: () => ({ model: { files: [{ path: "a.ts", status: CHANGE_STATUS.MODIFIED, added, deleted }], added, deleted }, unbornRoots: Array.from({ length: unbornRoots }, () => "root") }),
175
+ visualSettings: () => settings,
176
+ cardStyle: () => style,
177
+ });
178
+ return {
179
+ ctx: ctx as unknown as ExtensionContext & { sessionManager: { getLeafId?: () => string } },
180
+ memo,
181
+ counts: () => [reads, projections],
182
+ setLeaf: (value: string) => { leaf = value; },
183
+ spend: () => { entries[0]!.message.usage.cost.total += 1; },
184
+ setModelId: (value: string) => { model = { ...model, id: value }; },
185
+ setProfile: (value: string | undefined) => { profile = value; },
186
+ setBranch: (value: string | null) => { branch = value; },
187
+ setStatuses: (value: Map<string, string>) => { statuses = value; },
188
+ setUsage: (value: ProviderUsage | undefined) => { usage = value; },
189
+ setChangeCounts: (nextAdded: number, nextDeleted: number) => { added = nextAdded; deleted = nextDeleted; },
190
+ setUnbornRoots: (value: number) => { unbornRoots = value; },
191
+ setReview: (value: ReviewSidebarSnapshot | undefined) => { review = value; },
192
+ setJobs: (value: RunningJobDisplay[] | undefined) => { jobs = value; },
193
+ setSettings: (value: unknown) => { settings = value; },
194
+ setStyle: (value: unknown) => { style = value; },
195
+ };
196
+ }
197
+
198
+ test("the footer status memo reuses one model while live inputs hold still", () => {
199
+ const f = footerMemoFixture();
200
+ const first = f.memo.model();
201
+ assert.equal(first.costTotal, 1);
202
+ assert.deepEqual(first.changes, { files: 1, added: 0, deleted: 0 });
203
+ for (let frame = 0; frame < 10; frame++) assert.equal(f.memo.model(), first, "stable inputs reuse the built model");
204
+ assert.deepEqual(f.counts(), [1, 1], "warm frames never rescan the session");
205
+ });
206
+
207
+ test("the footer status digest key notices every live input the model reads", () => {
208
+ const f = footerMemoFixture();
209
+ const before = f.memo.key();
210
+ assert.equal(f.memo.key(), before, "the key is deterministic while nothing moves");
211
+ const mutations: Array<[string, () => void]> = [
212
+ ["branch", () => f.setBranch("feature")],
213
+ ["profile", () => f.setProfile("other (local)")],
214
+ ["statuses", () => f.setStatuses(new Map([["jobs", "1 running"]]))],
215
+ ["usage", () => f.setUsage({ provider: "p", plan: undefined, limits: [], fetchedAt: 1 })],
216
+ ["change counts", () => f.setChangeCounts(2, 1)],
217
+ ["unborn roots", () => f.setUnbornRoots(1)],
218
+ ["review", () => f.setReview({ state: "open", scope: undefined } as unknown as ReviewSidebarSnapshot)],
219
+ ["jobs", () => f.setJobs([{ id: "j", label: "l", startedAt: 0 }])],
220
+ ["visual settings", () => f.setSettings({ statusPlacement: "bottom" })],
221
+ ["card style", () => f.setStyle("compact")],
222
+ ["model", () => f.setModelId("new-model")],
223
+ ["cwd", () => (f.ctx.sessionManager as { getCwd(): string }).getCwd = () => "/other"],
224
+ ["session name", () => (f.ctx.sessionManager as { getSessionName(): string }).getSessionName = () => "renamed"],
225
+ ["leaf", () => f.setLeaf("leaf-2")],
226
+ ];
227
+ let previous = before;
228
+ for (const [name, mutate] of mutations) {
229
+ mutate();
230
+ const current = f.memo.key();
231
+ assert.notEqual(current, previous, `a moved ${name} changes the digest key`);
232
+ previous = current;
233
+ }
234
+ // A branch or profile move rebuilds the model without rescanning statistics:
235
+ // the model object identity stays stable across those rebuilds.
236
+ const settled = f.memo.model();
237
+ const [reads, projections] = f.counts();
238
+ f.setBranch("release");
239
+ assert.notEqual(f.memo.model(), settled);
240
+ assert.deepEqual(f.counts(), [reads, projections]);
241
+ });
242
+
243
+ test("a moved leaf rebuilds the footer model with fresh statistics", () => {
244
+ const f = footerMemoFixture();
245
+ const first = f.memo.model();
246
+ f.setLeaf("leaf-2");
247
+ const second = f.memo.model();
248
+ assert.notEqual(second, first);
249
+ assert.deepEqual(f.counts(), [2, 2]);
250
+ assert.equal(f.memo.model(), second, "the fresh model is memoized again");
251
+ });
252
+
253
+ test("hosts without leaf identity stay honest through the digest key's values", () => {
254
+ const f = footerMemoFixture();
255
+ assert.equal(f.memo.key(), f.memo.key(), "the key stays deterministic on uncached hosts");
256
+ Reflect.deleteProperty(f.ctx.sessionManager, "getLeafId");
257
+ const first = f.memo.model();
258
+ f.spend();
259
+ const second = f.memo.model();
260
+ assert.notEqual(second, first, "a changed cost value rebuilds the model without leaf identity");
261
+ assert.equal(second.costTotal, 2);
262
+ });
@@ -589,6 +589,73 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
589
589
  assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
590
590
  });
591
591
 
592
+ test("the jobs rail section paints between Status and Agents", (t) => {
593
+ const f = fixture();
594
+ sidebarPart(f.tui, "jobs", { render: () => ["jobs"], invalidate() {} }, { digest: () => "jobs", render: () => ["Jobs card"], invalidate() {} });
595
+ sidebarPart(f.tui, "agents", { render: () => ["agents"], invalidate() {} }, { digest: () => "agents", render: () => ["Agents card"], invalidate() {} });
596
+ sidebarPart(f.tui, "todo", { render: () => ["todo"], invalidate() {} }, { digest: () => "todo", render: () => ["Todo card"], invalidate() {} });
597
+ t.after(installSidebar(f.tui, theme));
598
+ const text = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
599
+ const footer = text.indexOf("Status");
600
+ const jobs = text.indexOf("Jobs card");
601
+ const agents = text.indexOf("Agents card");
602
+ const todo = text.indexOf("Todo card");
603
+ for (const [name, index] of [["Status", footer], ["Jobs card", jobs], ["Agents card", agents], ["Todo card", todo]] as const) {
604
+ assert.ok(index >= 0, `${name} paints in the rail`);
605
+ }
606
+ assert.ok(footer < jobs && jobs < agents && agents < todo, `sections paint in order, got: ${text}`);
607
+ });
608
+
609
+ test("a theme switch repaints rail sections whose digest did not change", (t) => {
610
+ const f = fixture();
611
+ // Pi hands extensions a live theme proxy and announces a switch only through
612
+ // ui.invalidate(), which never reaches the rail inside the layout tree.
613
+ let code = 31;
614
+ const live = { fg: (_color: string, text: string) => `\x1b[${code}m${text}\x1b[39m`, bold: (text: string) => text };
615
+ for (const key of ["footer", "agents", "todo"]) {
616
+ sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
617
+ digest: () => "stable",
618
+ render: () => [live.fg("accent", `${key} card`)],
619
+ invalidate() {},
620
+ });
621
+ }
622
+ t.after(installSidebar(f.tui, live));
623
+
624
+ const before = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
625
+ for (const key of ["footer", "agents", "todo"]) assert.ok(before.includes(`\x1b[31m${key} card`), `${key} paints with the first theme`);
626
+
627
+ code = 32;
628
+ const after = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
629
+ for (const key of ["footer", "agents", "todo"]) {
630
+ assert.ok(after.includes(`\x1b[32m${key} card`), `${key} repaints with the new theme`);
631
+ assert.ok(!after.includes(`\x1b[31m${key} card`), `${key} drops the old theme`);
632
+ }
633
+ });
634
+
635
+ test("structural cache misses skip the whole-rail digest pass", (t) => {
636
+ const f = fixture();
637
+ let digests = 0;
638
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
639
+ digest: () => { digests++; return "stable"; },
640
+ render: () => [`Todo ${digests}`],
641
+ invalidate() {},
642
+ });
643
+ t.after(installSidebar(f.tui, theme));
644
+
645
+ // Every frame evaluates the digest exactly once: the whole-rail comparison
646
+ // reuses it for the section cache instead of calling each digest twice per
647
+ // prepare (issue #1681).
648
+ renderLayoutFrame(f.root, 140, 20, () => {});
649
+ const prepared = digests;
650
+ assert.equal(prepared, 1, "preparing the rail evaluates the digest once");
651
+ renderLayoutFrame(f.root, 140, 20, () => {});
652
+ assert.equal(digests, prepared + 1, "a cache hit compares the digest once");
653
+ renderLayoutFrame(f.root, 141, 20, () => {});
654
+ assert.equal(digests, prepared + 2, "a structural miss re-prepares with a single digest pass");
655
+ renderLayoutFrame(f.root, 140, 20, () => {});
656
+ assert.equal(digests, prepared + 3, "returning to the cached structure stays on a single digest pass");
657
+ });
658
+
592
659
  test("reuses final sidebar presentation until a relevant invalidation", (t) => {
593
660
  const f = fixture();
594
661
  sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
@@ -36,7 +36,7 @@ for (const [name, text] of Object.entries({ registeredDescription: background.de
36
36
  test("inline still means the same parent, and the public background API stays asynchronous", () => {
37
37
  assert.ok(core.includes("Small path: inline (parent; `bash_background` policy);"));
38
38
  assert.ok(guide.includes("Inline means the same parent, not necessarily synchronous bash"));
39
- assert.ok(core.includes("run the focused test and the suite inline, once each"));
39
+ assert.ok(core.includes("run the focused test and the suite inline in one command"));
40
40
  assert.match(background.description, /return a job id at once/);
41
41
  assert.match(background.description, /notified once, automatically/);
42
42
  assert.match(background.description, /do not poll, sleep, or delegate a subagent to wait/);
@@ -25,7 +25,9 @@ const PREFIXED_NAMES: Record<string, string> = {
25
25
  "work-unit-commits": "gentle-ai-work-unit-commits",
26
26
  };
27
27
 
28
- const UNPREFIXED_DIRS = ["gentle-ai"];
28
+ // gentle-pi-owned skills that gentle-ai does not install keep their own
29
+ // gentle- namespace, so they need no gentle-ai- prefix to avoid a collision.
30
+ const UNPREFIXED_DIRS = ["gentle-ai", "design"];
29
31
 
30
32
  for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
31
33
  test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
@@ -16,7 +16,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
16
16
  import assert from "node:assert/strict";
17
17
  import fs from "node:fs";
18
18
  import { syncBuiltinESMExports } from "node:module";
19
- import { join } from "node:path";
19
+ import { join, sep } from "node:path";
20
20
  const root = ${JSON.stringify(root)};
21
21
  const cwd = join(root, "workspace");
22
22
  const home = join(root, "home");
@@ -52,6 +52,8 @@ for (const code of ["ENOENT", "EMFILE"]) {
52
52
  });
53
53
  const ctx = { cwd, hasUI: true, ui: { notify: (message, level) => notices.push({ message, level }) } };
54
54
  const waitFor = async (predicate) => {
55
+ // Must stay well below the parent's spawnSync timeout so a missed
56
+ // outcome fails with this assertion instead of an opaque ETIMEDOUT.
55
57
  const deadline = Date.now() + 5000;
56
58
  while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
57
59
  assert.ok(predicate(), "timed out waiting for watcher outcome");
@@ -66,18 +68,28 @@ for (const code of ["ENOENT", "EMFILE"]) {
66
68
  if (code === "ENOENT" && process.platform !== "darwin" && process.platform !== "win32") {
67
69
  // Force the transient missing-directory race at Node's real recursive
68
70
  // readdir boundary, after watcher creation, not in our refresh callback.
71
+ // Older Node rescans the parent folder; Node 24.22+ resolves new files
72
+ // via lstat and only readdirs a newly added directory, so trigger with
73
+ // a directory and fail whichever readdir lands under assets.
69
74
  const nativeReaddir = fs.readdirSync;
70
75
  let reachedRescan = false;
71
76
  fs.readdirSync = function (path, options) {
72
- if (path === assets) {
77
+ if (typeof path === "string" && (path === assets || path.startsWith(assets + sep))) {
73
78
  fs.readdirSync = nativeReaddir;
74
79
  reachedRescan = true;
75
80
  throw Object.assign(new Error("ENOENT: transient skill assets disappeared"), { code, syscall: "scandir", path });
76
81
  }
77
82
  return nativeReaddir(path, options);
78
83
  };
79
- fs.writeFileSync(join(assets, "trigger.txt"), "trigger directory rescan");
80
- await waitFor(() => reachedRescan);
84
+ fs.mkdirSync(join(assets, "trigger"));
85
+ // The rescan race needs live recursive-watch events. Where the OS
86
+ // cannot deliver them (an exhausted inotify budget, some WSL2
87
+ // setups), waiting the full waitFor budget is pointless: settle
88
+ // briefly for the natural race, then exercise the public error
89
+ // listener directly, as the manual fallback below always did.
90
+ const raceDeadline = Date.now() + 2000;
91
+ while (!reachedRescan && Date.now() < raceDeadline) await new Promise((resolve) => setTimeout(resolve, 25));
92
+ fs.readdirSync = nativeReaddir;
81
93
  // Newer Node versions swallow rescan ENOENT themselves. Still
82
94
  // exercise our public error listener without requiring Node's bug.
83
95
  if (!notices.some(({ message, level }) => level === "warning" && message.includes(code))) {
@@ -112,7 +124,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
112
124
  timeout: 15_000,
113
125
  env: { ...process.env, GIT_CEILING_DIRECTORIES: root },
114
126
  });
115
- assert.equal(child.error, undefined, child.error?.message);
127
+ assert.equal(child.error, undefined, `${child.error?.message}\n${child.stderr}`);
116
128
  assert.equal(child.status, 0, child.stderr);
117
129
  assert.match(child.stdout, new RegExp(`SURVIVED ${code}`));
118
130
  });