gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -8,7 +8,8 @@ const source = readFileSync(join(root, "extensions/gentle-agents.ts"), "utf8");
8
8
  const match = source.match(/name: "orchestrator_session_id",\s*label: "[^"]+",\s*description: ("(?:[^"\\]|\\.)*")/);
9
9
  assert.ok(match, "Registered session tool must expose a public description");
10
10
  const description: string = JSON.parse(match[1]);
11
- const detail = readFileSync(join(root, "assets/orchestrator-delegation.md"), "utf8")
11
+ // S11 T2: the session subject rules moved from the delegation card to its reference.
12
+ const detail = readFileSync(join(root, "assets/orchestrator-delegation-reference.md"), "utf8")
12
13
  .split("## Session subject and display identity\n")[1].split("### Publish and find classified work")[0];
13
14
  const docs = readFileSync(join(root, "docs/gentle-agents-activity.md"), "utf8")
14
15
  .split("### Declare a recognizable subject\n")[1].split("### Publish curated state")[0];
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import test from "node:test";
8
8
  import { SessionManager } from "@earendil-works/pi-coding-agent";
9
- import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
9
+ import { SessionWorktreeRegistry, clearSessionWorktreeMemo, memoizeWorktreeResolver, resolveSessionWorktree, resolveSessionWorktreeWithGit, SESSION_WORKTREE_ENTRY, nativeRootSpelling, toolWorktreePath, worktreeGitEnvironment } from "../lib/session-worktree-registry.ts";
10
10
  import { sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
11
11
 
12
12
  // All Git and session writes belong to unique fixtures, never the live clone.
@@ -306,3 +306,15 @@ test("memoized identity still observes removal, nested repositories and expiry",
306
306
  counted(join(f.dir, "empty"), f.dir);
307
307
  assert.equal(calls, 4, "a missing repository is never memoized, so bootstrap is observed at once");
308
308
  });
309
+
310
+ test("Windows root spelling accepts forward slashes as the native separator only (gentle-shell#2314)", () => {
311
+ // Git prints C:/repo while realpath returns C:\\repo; both name the same root.
312
+ assert.equal(nativeRootSpelling("C:/path/to/sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
313
+ assert.equal(nativeRootSpelling("C:\\path\\to\\sibling-repo", "win32"), "C:\\path\\to\\sibling-repo");
314
+ // Only separators change: dot segments and trailing separators stay non-canonical.
315
+ assert.equal(nativeRootSpelling("C:/repo/.", "win32"), "C:\\repo\\.");
316
+ assert.equal(nativeRootSpelling("C:/repo/", "win32"), "C:\\repo\\");
317
+ // POSIX spelling is untouched; a backslash is an ordinary filename character there.
318
+ assert.equal(nativeRootSpelling("/srv/repo", "linux"), "/srv/repo");
319
+ assert.equal(nativeRootSpelling("/srv/re\\po", "darwin"), "/srv/re\\po");
320
+ });
@@ -219,6 +219,13 @@ test("renderShellBar renders one line with the segments in order", () => {
219
219
  );
220
220
  });
221
221
 
222
+ test("renderShellBar paints the context gauge with the given gauge roles", () => {
223
+ const [line] = renderShellBar(model(), taggedTheme, 400, undefined, { ok: "gaugeOk", empty: "gaugeEmpty" });
224
+ assert.match(line, /<gaugeOk>▰▰▰▰<\/gaugeOk><gaugeEmpty>▱▱▱▱<\/gaugeEmpty>/);
225
+ const [fallback] = renderShellBar(model(), taggedTheme, 400);
226
+ assert.match(fallback, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
227
+ });
228
+
222
229
  test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
223
230
  const [line] = renderShellBar(model(), taggedTheme, 400);
224
231
  assert.match(line, /<accent>✿ gentle shell<\/accent>/);
@@ -348,7 +355,7 @@ test("renderShellSidebarBar paints the Status card frame with border and the tit
348
355
  });
349
356
 
350
357
  test("sidebar unifies project, captured changes and integrations in one frame", () => {
351
- 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"] });
352
359
  const lines = renderShellSidebarBar(data, plainTheme, 46);
353
360
  const text = lines.join("\n");
354
361
  assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
@@ -359,7 +366,6 @@ test("sidebar unifies project, captured changes and integrations in one frame",
359
366
  previous = index;
360
367
  }
361
368
  assert.match(text, /2 files.*\+7.*−3/);
362
- assert.match(text, /capture warning/);
363
369
  assert.match(text, /\/gentle:changes/);
364
370
  assert.match(text, /main/);
365
371
  for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
@@ -849,6 +855,19 @@ test("unified below-input float includes optional Changes and sanitized statuses
849
855
  assert.equal(renderShellBelowInputFloat(model(), theme, 140, "alt+u", presentation, changes), undefined);
850
856
  });
851
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
+
852
871
  test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) => {
853
872
  const theme = withBackground(taggedTheme);
854
873
  const data = model({ usage: USAGE_TWO_WINDOWS, statuses: ["mcp ok"] });
@@ -4,6 +4,7 @@ import { TuiAltScreen, visibleWidth, type Terminal, type TuiMouseEvent } from "@
4
4
  import { CHANGE_STATUS, DIFF_TOO_LARGE, changesModel, type ChangedFile } from "../lib/shell-changes.ts";
5
5
  import { WorktreeChangesView, ChangesView, colorDiff, type ChangesViewDeps } from "../lib/shell-changes-view.ts";
6
6
  import { stripAnsi } from "../lib/terminal-theme.ts";
7
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
7
8
 
8
9
  // The changes overlay: files on the left, the selected file's diff on the
9
10
  // right, keys at the bottom. Rendering is pure; git access is injected.
@@ -458,7 +459,7 @@ const statusCases = [
458
459
  [CHANGE_STATUS.ADDED, "A", 3, 0],
459
460
  [CHANGE_STATUS.DELETED, "D", 0, 4],
460
461
  [CHANGE_STATUS.RENAMED, "R", 0, 0],
461
- [CHANGE_STATUS.UNTRACKED, "??", 5, 0],
462
+ [CHANGE_STATUS.UNTRACKED, "U", 5, 0],
462
463
  ] as const;
463
464
 
464
465
  for (const [status, code, added, deleted] of statusCases) {
@@ -757,3 +758,138 @@ test("ChangesView.update keeps the selected file, reloads moved diffs, and survi
757
758
  component.handleInput("j");
758
759
  component.handleInput("o");
759
760
  });
761
+
762
+ // Diff-only mode (F): the file column and its separator leave so the diff
763
+ // spans the whole inner width; j/k still walk files, esc returns first.
764
+ function diffOnlyView(overrides: { rows?: number; loadDiff?: (root: string, target: ChangedFile) => Promise<string> } = {}) {
765
+ const trees = [
766
+ { root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) },
767
+ { root: "/linked", branch: "agent", model: changesModel([file("c.ts", 1, 0)]) },
768
+ ];
769
+ const events: string[] = [];
770
+ const component = new WorktreeChangesView(trees, {
771
+ theme: plainTheme, rows: overrides.rows ?? 10,
772
+ loadDiff: overrides.loadDiff ?? (async (root, target) => `+${root}:${target.path} ${"x".repeat(80)}`),
773
+ onOpen: (root, target) => { events.push(`open:${root}:${target.path}`); },
774
+ onClose: () => { events.push("close"); },
775
+ onRefresh: () => { events.push("refresh"); },
776
+ requestRender() {},
777
+ });
778
+ return { component, events };
779
+ }
780
+
781
+ test("F hides the file list and its separator so the diff spans the full inner width", async () => {
782
+ const { component } = diffOnlyView();
783
+ component.handleInput("\r");
784
+ component.handleInput("j");
785
+ await settle();
786
+ const normal = component.render(100);
787
+ assert.match(normal[1]!, /^│ +▾ main · main +│ M a\.ts +│$/, "the normal view keeps the two-pane layout");
788
+ assert.match(stripAnsi(normal[2]!), /^│ ▸ M a\.ts .*│ \+\/main:a\.ts x+…│$/, "the narrow diff pane clips a long line");
789
+ assert.match(normal[8]!, /F diff only/);
790
+ component.handleInput("F");
791
+ await settle();
792
+ const wide = component.render(100);
793
+ assert.equal(wide.length, 10);
794
+ for (const line of wide) assert.equal(visibleWidth(line), 100);
795
+ assert.match(wide[1]!, /^│ M a\.ts +│$/, "the path header starts at the frame edge");
796
+ assert.equal(wide[2]!, `│ ${`+/main:a.ts ${"x".repeat(80)}`.padEnd(96)} │`, "the diff uses the full inner width");
797
+ assert.doesNotMatch(wide.slice(1, 8).join("\n"), /main · main|│.*│.*│/, "no list column and no separator remain");
798
+ assert.match(wide[8]!, /F files/);
799
+ component.handleInput("F");
800
+ assert.match(component.render(100)[1]!, /▾ main · main/, "F toggles back to the file list");
801
+ });
802
+
803
+ test("j/k in diff-only mode walk files across worktrees and the path header follows", async () => {
804
+ const { component } = diffOnlyView();
805
+ component.handleInput("F");
806
+ await settle();
807
+ assert.match(component.render(100)[1]!, /^│ M a\.ts +│$/, "entering on a group header jumps to its first file");
808
+ component.handleInput("j");
809
+ await settle();
810
+ assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
811
+ component.handleInput("j");
812
+ await settle();
813
+ assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "j crosses into the next worktree's files");
814
+ assert.match(component.render(100)[2]!, /\+\/linked:c\.ts/);
815
+ component.handleInput("j");
816
+ assert.match(component.render(100)[1]!, /^│ M c\.ts +│$/, "the last file stays selected");
817
+ component.handleInput("k");
818
+ await settle();
819
+ assert.match(component.render(100)[1]!, /^│ M b\.ts +│$/);
820
+ component.handleInput("j");
821
+ component.handleInput("\x1b");
822
+ assert.match(component.render(100).join("\n"), /▸ M c\.ts/, "the files view shows the file chosen in diff-only mode");
823
+ });
824
+
825
+ test("esc leaves diff-only mode before closing; q, o and r keep working there", async () => {
826
+ const { component, events } = diffOnlyView();
827
+ component.handleInput("F");
828
+ component.handleInput("\x1b");
829
+ assert.deepEqual(events, [], "the first esc only returns to the file list");
830
+ assert.match(component.render(100)[1]!, /main · main/);
831
+ component.handleInput("\x1b");
832
+ assert.deepEqual(events, ["close"]);
833
+ component.handleInput("F");
834
+ component.handleInput("o");
835
+ component.handleInput("r");
836
+ component.handleInput("q");
837
+ assert.deepEqual(events, ["close", "open:/main:a.ts", "refresh", "close"]);
838
+ });
839
+
840
+ test("diff-only mode has no list mouse zone; the wheel scrolls the widened diff", async () => {
841
+ const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
842
+ const { component } = diffOnlyView({ rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}` });
843
+ component.handleInput("F");
844
+ await settle();
845
+ component.render(80);
846
+ assert.equal(component.handleMouse(mouseButton("move", "none", 3, 2, 8)), undefined, "no hover target");
847
+ assert.equal(component.handleMouse(mouseButton("click", "left", 3, 2, 8)), undefined, "no click target");
848
+ assert.match(component.render(80)[1]!, /^│ M a\.ts +│$/, "a click where the list was does not select");
849
+ assert.deepEqual(component.handleMouse(mouse("wheel", 3, 2, 3, 8)), { handled: true, render: true });
850
+ assert.doesNotMatch(component.render(80)[2]!, /@@/, "the left edge now scrolls the diff");
851
+ component.handleInput("\x1b");
852
+ component.render(80);
853
+ assert.deepEqual(component.handleMouse(mouse("click", 3, 3, undefined, 8)), { handled: true, render: true }, "leaving diff-only restores the list zone");
854
+ assert.match(component.render(80)[3]!, /▸ M b\.ts/);
855
+ });
856
+
857
+ test("the native fullscreen wrapper keeps keyboard, hover, click and wheel on the changes view", async () => {
858
+ let onInput: ((data: string) => void) | undefined;
859
+ const terminal: Terminal = {
860
+ start(input: Parameters<Terminal["start"]>[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 8; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {},
861
+ };
862
+ const long = Array.from({ length: 30 }, (_, index) => `+line ${index}`).join("\n");
863
+ let renders = 0;
864
+ const trees = [{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }];
865
+ const component = new WorktreeChangesView(trees, {
866
+ theme: { fg: (color: string, text: string) => color === "warning" ? `\x1b[33m${text}\x1b[39m` : text }, rows: 8, loadDiff: async () => `@@ -0,0 +1,30 @@\n${long}`,
867
+ onOpen() {}, onClose() {}, onRefresh() {}, requestRender() {},
868
+ });
869
+ const interaction = createNativeFullscreenInteraction({ keyboardTarget: component, requestRender: () => { renders++; } });
870
+ interaction.addChild(component);
871
+ const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
872
+ tui.setLayoutRoot(interaction);
873
+ tui.start();
874
+ interaction.handleInput("\r");
875
+ assert.ok(renders > 0, "keys reach the view and request a render");
876
+ tui.renderNow(true);
877
+ try {
878
+ onInput?.("\x1b[<35;4;4M");
879
+ assert.match(interaction.render(80)[3]!, /\x1b\[33mM b\.ts/, "hover paints the row under the pointer");
880
+ onInput?.("\x1b[<0;4;4M");
881
+ onInput?.("\x1b[<0;4;4m");
882
+ assert.match(stripAnsi(interaction.render(80)[3]!), /▸ M b\.ts/, "a click selects the file");
883
+ await settle();
884
+ tui.renderNow(true);
885
+ onInput?.("\x1b[<65;60;3M");
886
+ assert.doesNotMatch(stripAnsi(interaction.render(80)[2]!), /@@/, "the wheel scrolls the diff");
887
+ interaction.handleInput("F");
888
+ tui.renderNow(true);
889
+ onInput?.("\x1b[<0;4;3M");
890
+ onInput?.("\x1b[<0;4;3m");
891
+ assert.match(stripAnsi(interaction.render(80)[1]!), /^│ M b\.ts +│$/, "diff-only mode ignores clicks where the list was");
892
+ } finally {
893
+ tui.stop();
894
+ }
895
+ });
@@ -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
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { paintGauge, renderGauge, type GaugePaint } from "../lib/shell-gauge.ts";
4
+
5
+ // Issue #2222: gauge-specific theme roles fall back to today's roles when a
6
+ // theme does not define them, so existing themes render byte-identically.
7
+ // (Fixtures here are unit-scoped stubs; Pi's theme schema is validated
8
+ // separately by the loader and was checked directly against ThemeJsonSchema.)
9
+
10
+ const taggedTheme = {
11
+ fg(color: string, text: string) {
12
+ return `<${color}>${text}</${color}>`;
13
+ },
14
+ };
15
+
16
+ test("paintGauge falls back to today's roles when no gauge roles are given", () => {
17
+ assert.equal(paintGauge(45, taggedTheme), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
18
+ assert.equal(paintGauge(85, taggedTheme), `<warning>▰▰▰▰▰▰▰</warning><border>▱</border>`);
19
+ assert.equal(paintGauge(96, taggedTheme), `<error>▰▰▰▰▰▰▰▰</error><border></border>`);
20
+ assert.equal(paintGauge(null, taggedTheme), `<dim></dim><border>▱▱▱▱▱▱▱▱</border>`);
21
+ });
22
+
23
+ test("paintGauge uses the gauge-specific roles when the theme defines them", () => {
24
+ const roles: GaugePaint = { ok: "gaugeOk", warning: "gaugeWarning", error: "gaugeError", empty: "gaugeEmpty" };
25
+ assert.equal(paintGauge(45, taggedTheme, 8, roles), `<gaugeOk>▰▰▰▰</gaugeOk><gaugeEmpty>▱▱▱▱</gaugeEmpty>`);
26
+ assert.equal(paintGauge(85, taggedTheme, 8, roles), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><gaugeEmpty>▱</gaugeEmpty>`);
27
+ assert.equal(paintGauge(96, taggedTheme, 8, roles), `<gaugeError>▰▰▰▰▰▰▰▰</gaugeError><gaugeEmpty></gaugeEmpty>`);
28
+ // A null percentage stays dim: there is nothing to gauge yet.
29
+ assert.equal(paintGauge(null, taggedTheme, 8, roles), `<dim></dim><gaugeEmpty>▱▱▱▱▱▱▱▱</gaugeEmpty>`);
30
+ });
31
+
32
+ test("paintGauge falls back per role, not per block", () => {
33
+ // A theme that defines only gaugeWarning keeps accent for the ok range.
34
+ assert.equal(paintGauge(45, taggedTheme, 8, { warning: "gaugeWarning" }), `<accent>▰▰▰▰</accent><border>▱▱▱▱</border>`);
35
+ assert.equal(paintGauge(85, taggedTheme, 8, { warning: "gaugeWarning" }), `<gaugeWarning>▰▰▰▰▰▰▰</gaugeWarning><border>▱</border>`);
36
+ });
37
+
38
+ test("renderGauge uses theme-defined glyphs when given, defaults otherwise", () => {
39
+ assert.equal(renderGauge(50, 4, { filled: "█", empty: "░" }), "██░░");
40
+ assert.equal(renderGauge(50, 4), "▰▰▱▱");
41
+ assert.equal(renderGauge(50, 4, { filled: "█" }), "██▱▱");
42
+ });
@@ -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
+ });