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
package/lib/shell-bar.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
2
+ import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugePaint, type GaugeTone } from "./shell-gauge.ts";
3
3
  import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts";
4
4
  import { sanitizeTerminalText } from "./terminal-theme.ts";
5
5
  import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.ts";
@@ -22,7 +22,7 @@ export interface ShellBarModel {
22
22
  review?: ReviewSidebarSnapshot;
23
23
  jobs?: RunningJobDisplay[];
24
24
  profile?: string;
25
- changes?: { files: number; added: number; deleted: number; notice?: string };
25
+ changes?: { files: number; added: number; deleted: number };
26
26
  cwd: string;
27
27
  branch: string | null;
28
28
  dirty: number | undefined;
@@ -159,21 +159,21 @@ function executionSegment(modelId: string, effort: string | undefined, theme: Sh
159
159
  : theme.fg(ROLE.MODEL, modelId);
160
160
  }
161
161
 
162
- function contextSegment(contextPercent: number | null, theme: ShellBarTheme): string {
162
+ function contextSegment(contextPercent: number | null, theme: ShellBarTheme, paint?: GaugePaint): string {
163
163
  const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`;
164
- return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
164
+ return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme, GAUGE_CELLS, paint)} ${theme.fg(ROLE.VALUE, percentText)}`;
165
165
  }
166
166
 
167
167
  function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string {
168
168
  return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription));
169
169
  }
170
170
 
171
- function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation): string[] {
171
+ function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation, paint?: GaugePaint): string[] {
172
172
  const location = locationSegment(model, theme);
173
173
  const modelSegment = executionSegment(model.modelId, model.effort, theme);
174
- const context = contextSegment(model.contextPercent, theme);
174
+ const context = contextSegment(model.contextPercent, theme, paint);
175
175
  const cost = costSegment(model.costTotal, model.subscription, theme);
176
- const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
176
+ const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId, paint) : undefined;
177
177
  const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
178
178
  return [
179
179
  ...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]),
@@ -240,7 +240,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
240
240
  changes?.files
241
241
  ? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}`
242
242
  : label("No captured changes"),
243
- ...(changes?.notice ? [theme.fg("warning", sanitizeStatus(changes.notice))] : []),
244
243
  label("/gentle:changes"),
245
244
  ],
246
245
  }]),
@@ -295,11 +294,11 @@ function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showMod
295
294
  const USAGE_LABEL_ROLE = ROLE.LABEL;
296
295
  const USAGE_HINT_ROLE = "dim";
297
296
 
298
- function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean): string {
297
+ function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean, paint?: GaugePaint): string {
299
298
  const percent = `${Math.round(window.usedPercent)}%`;
300
299
  const parts = [
301
300
  ...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []),
302
- ...(withGauge ? [paintGauge(window.usedPercent, theme)] : []),
301
+ ...(withGauge ? [paintGauge(window.usedPercent, theme, GAUGE_CELLS, paint)] : []),
303
302
  theme.fg(ROLE.VALUE, percent),
304
303
  ];
305
304
  return parts.join(" ");
@@ -310,10 +309,10 @@ function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: b
310
309
  // text only. A provider with no usage data at all has no windows to shape,
311
310
  // so all three collapse to the bare "usage" label plus the shortcut hint.
312
311
  type UsageStage = "full" | "text" | "primary";
313
- function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined): string {
312
+ function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined, paint?: GaugePaint): string {
314
313
  const label = theme.fg(USAGE_LABEL_ROLE, "usage");
315
314
  const shown = stage === "primary" ? windows.slice(0, 1) : windows;
316
- const body = shown.map((window) => usageWindowText(window, theme, stage === "full")).join(` ${theme.fg(ROLE.LABEL, "·")} `);
315
+ const body = shown.map((window) => usageWindowText(window, theme, stage === "full", paint)).join(` ${theme.fg(ROLE.LABEL, "·")} `);
317
316
  const head = body.length > 0 ? `${label} ${body}` : label;
318
317
  return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head;
319
318
  }
@@ -338,16 +337,16 @@ function fitHeaderContent(text: string, width: number): string {
338
337
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
339
338
  }
340
339
 
341
- export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
340
+ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
342
341
  const targetWidth = Math.max(0, Math.floor(width));
343
342
  let content: ShellHeaderResult | undefined;
344
343
  let contentWidth = targetWidth;
345
344
  const row = floatHeaderRow(theme, targetWidth, (inner) => {
346
345
  contentWidth = inner;
347
- content = headerContent(model, theme, inner, usageHint, presentation);
346
+ content = headerContent(model, theme, inner, usageHint, presentation, paint);
348
347
  return content.text;
349
348
  });
350
- if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation);
349
+ if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation, paint);
351
350
  const { usageSpan } = content;
352
351
  // The inset splits evenly around the content, so its left share is where
353
352
  // the content's columns start.
@@ -356,8 +355,8 @@ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarThe
356
355
  }
357
356
 
358
357
  /** Shared geometry for the rail header and the below-editor header widget. */
359
- export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation): ShellHeaderChrome {
360
- const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation);
358
+ export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, paint?: GaugePaint): ShellHeaderChrome {
359
+ const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation, paint);
361
360
  const targetWidth = Math.max(0, Math.floor(width));
362
361
  const painted = floatHeaderPaint(theme, targetWidth, () => "");
363
362
  const rule = renderShellHeaderRule(theme, targetWidth);
@@ -377,12 +376,12 @@ export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: num
377
376
  return y === chrome.headerRow && usageSpan !== undefined && x >= usageSpan.start && x < usageSpan.end;
378
377
  }
379
378
 
380
- function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
379
+ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
381
380
  const targetWidth = Math.max(0, Math.floor(width));
382
381
  const jobs = model.jobs ? theme.fg(ROLE.STATUS, shellJobsCount(model.jobs)) : undefined;
383
382
  const ctxCost = joinSegments([
384
383
  ...(jobs ? [jobs] : []),
385
- ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)]),
384
+ ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme, paint), costSegment(model.costTotal, model.subscription, theme)]),
386
385
  ], theme);
387
386
  const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
388
387
  const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false)
@@ -403,7 +402,7 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
403
402
  { leftIndex: leftStages.length - 1, usageStage: undefined },
404
403
  ];
405
404
  for (const { leftIndex, usageStage } of attempts) {
406
- const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
405
+ const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint, paint) : undefined;
407
406
  const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
408
407
  const left = joinSegments(leftStages[leftIndex]!, theme);
409
408
  if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue;
@@ -422,12 +421,14 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
422
421
 
423
422
  // A single owner supplies real captured filenames, not ShellBarModel's totals.
424
423
  // Undefined keeps every legacy surface alive for style/width/theme transitions.
425
- export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): ShellHeaderChrome | undefined {
424
+ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): ShellHeaderChrome | undefined {
426
425
  if (presentation?.headerPlacement !== "below-input") return undefined;
427
426
  const targetWidth = Math.max(0, Math.floor(width));
428
427
  if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
429
- const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation);
430
- const changesRow = changes?.files.length && presentation.visibility.changes !== false
428
+ const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation, paint);
429
+ // The row exists only while files are actually tracked; an unborn-only root
430
+ // is silence here (/gentle:changes explains the state on demand).
431
+ const changesRow = Boolean(changes && changes.files.length > 0) && presentation.visibility.changes !== false
431
432
  ? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
432
433
  : undefined;
433
434
  const statuses = presentation.statusPlacement === "hidden" ? [] : [
@@ -456,12 +457,12 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
456
457
  // header row's own cascade, so context, cost and usage outlive the location
457
458
  // on small screens, and keeps extension statuses on a second line instead of
458
459
  // dropping them the way the header deliberately does.
459
- export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): string[] {
460
- const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes);
460
+ export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): string[] {
461
+ const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes, paint);
461
462
  if (grouped) return grouped.rows;
462
463
  const headerModel = buildShellHeaderModel(model, true);
463
464
  // Neon, missing backgrounds and sub-minimum widths keep the old row count.
464
- const rows = [headerContent(headerModel, theme, width, usageHint, presentation).text];
465
+ const rows = [headerContent(headerModel, theme, width, usageHint, presentation, paint).text];
465
466
  const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status));
466
467
  return statuses.length ? [...rows, truncateToWidth(joinSegments(statuses, theme), Math.max(0, Math.floor(width)), "…")] : rows;
467
468
  }
@@ -503,8 +504,8 @@ export function renderShellHeaderRule(theme: ShellBarTheme, width: number): stri
503
504
  return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(targetWidth));
504
505
  }
505
506
 
506
- export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] {
507
- let segments = buildSegments(model, theme, presentation);
507
+ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation, paint?: GaugePaint): string[] {
508
+ let segments = buildSegments(model, theme, presentation, paint);
508
509
  const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
509
510
 
510
511
  let left = joinSegments(segments, theme);
@@ -67,12 +67,15 @@ export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
67
67
  return lines;
68
68
  }
69
69
 
70
+ // New files are two distinct states: `A` is tracked and staged, `U` is still
71
+ // untracked. Git's own `??` reads as a broken value in a status column, so the
72
+ // list names the state instead of echoing the plumbing code.
70
73
  const FILE_STATUS = {
71
74
  [CHANGE_STATUS.MODIFIED]: "M",
72
75
  [CHANGE_STATUS.ADDED]: "A",
73
76
  [CHANGE_STATUS.DELETED]: "D",
74
77
  [CHANGE_STATUS.RENAMED]: "R",
75
- [CHANGE_STATUS.UNTRACKED]: "??",
78
+ [CHANGE_STATUS.UNTRACKED]: "U",
76
79
  } as const;
77
80
 
78
81
  function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
@@ -113,18 +116,41 @@ function fit(text: string, width: number): string {
113
116
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
114
117
  }
115
118
 
116
- // Both standalone files and the worktree accordion use the original frame.
117
- function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile): string[] {
119
+ // One geometry feeds both rendering and pointer hit-testing, so clicks stay
120
+ // aligned with what is painted. Diff-only drops the list column and its
121
+ // separator; its empty list zone receives no pointer events.
122
+ interface PaneLayout {
123
+ inner: number;
124
+ listWidth: number;
125
+ diffWidth: number;
126
+ listStart: number;
127
+ listEnd: number;
128
+ diffStart: number;
129
+ diffEnd: number;
130
+ }
131
+
132
+ function paneLayout(width: number, diffOnly: boolean): PaneLayout {
118
133
  const inner = Math.max(8, width - 2);
134
+ if (diffOnly) return { inner, listWidth: 0, diffWidth: inner - 2, listStart: 0, listEnd: 0, diffStart: 2, diffEnd: inner };
119
135
  const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
120
136
  const diffWidth = inner - listWidth - 4;
137
+ return { inner, listWidth, diffWidth, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
138
+ }
139
+
140
+ // Both standalone files and the worktree accordion use the original frame.
141
+ function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile, diffOnly = false): string[] {
142
+ const { inner, listWidth, diffWidth } = paneLayout(width, diffOnly);
121
143
  const rightLines = headerFile ? [pathHeader(headerFile, theme, diffWidth), ...diffLines] : diffLines;
122
144
  const titleText = truncateToWidth(title, inner - 4, "…");
123
145
  const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
124
146
  const body: string[] = [];
125
147
  for (let row = 0; row < rows; row += 1) {
126
- const left = fit(leftLine(row), listWidth);
127
148
  const right = fit(rightLines[row] ?? "", diffWidth);
149
+ if (diffOnly) {
150
+ body.push(`${theme.fg(ROLE.FRAME, "│")} ${right} ${theme.fg(ROLE.FRAME, "│")}`);
151
+ continue;
152
+ }
153
+ const left = fit(leftLine(row), listWidth);
128
154
  body.push(`${theme.fg(ROLE.FRAME, "│")} ${left} ${theme.fg(ROLE.FRAME, "│")} ${right}${theme.fg(ROLE.FRAME, "│")}`);
129
155
  }
130
156
  const keysLine = `${theme.fg(ROLE.FRAME, "│")} ${fit(keys, inner - 2)} ${theme.fg(ROLE.FRAME, "│")}`;
@@ -142,11 +168,9 @@ interface PointerLayout {
142
168
  diffEnd: number;
143
169
  }
144
170
 
145
- function pointerLayout(width: number, bodyRows: number): PointerLayout {
146
- const inner = Math.max(8, width - 2);
147
- const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
148
- const diffWidth = inner - listWidth - 4;
149
- return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart: 2, listEnd: 2 + listWidth, diffStart: 5 + listWidth, diffEnd: 5 + listWidth + diffWidth };
171
+ function pointerLayout(width: number, bodyRows: number, diffOnly = false): PointerLayout {
172
+ const { listStart, listEnd, diffStart, diffEnd } = paneLayout(width, diffOnly);
173
+ return { width, height: bodyRows + CHROME_ROWS, bodyRows, listStart, listEnd, diffStart, diffEnd };
150
174
  }
151
175
 
152
176
  function displayText(text: string): string {
@@ -186,6 +210,8 @@ export class WorktreeChangesView {
186
210
  private disposed = false;
187
211
  private leftPressActive = false;
188
212
  private hoveredIndex: number | undefined;
213
+ // Not persisted: every opening starts with the file list visible.
214
+ private diffOnly = false;
189
215
  private readonly expanded = new Set<string>();
190
216
  private readonly previews = new Map<string, { fingerprint: string; view: ChangesView }>();
191
217
 
@@ -224,13 +250,27 @@ export class WorktreeChangesView {
224
250
  handleInput(data: string): void {
225
251
  if (this.disposed) return;
226
252
  if (data === "r") return this.deps.onRefresh();
227
- if (matchesKey(data, Key.escape) || data === "q") return this.deps.onClose();
253
+ if (data === "q") return this.deps.onClose();
254
+ if (matchesKey(data, Key.escape)) {
255
+ if (!this.diffOnly) return this.deps.onClose();
256
+ return this.setDiffOnly(false);
257
+ }
258
+ if (data === "F") return this.setDiffOnly(!this.diffOnly);
228
259
  const rows = this.visibleRows();
229
260
  const row = rows[this.selected];
230
261
  if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("j")) || matchesKey(data, Key.ctrl("k"))) {
231
262
  if (row?.file) this.previews.get(rowKey(row))?.view.handleInput(data);
232
263
  return;
233
264
  }
265
+ if (this.diffOnly) {
266
+ // Only files have a diff: j/k walk every file, crossing groups.
267
+ if (data === "j" || matchesKey(data, Key.down)) this.stepFile(1);
268
+ else if (data === "k" || matchesKey(data, Key.up)) this.stepFile(-1);
269
+ else if (row?.file && (data === "o" || matchesKey(data, Key.enter))) return this.deps.onOpen(row.tree.root, row.file);
270
+ else return;
271
+ this.deps.requestRender();
272
+ return;
273
+ }
234
274
  if (data === "j" || matchesKey(data, Key.down)) this.selected = Math.max(0, Math.min(rows.length - 1, this.selected + 1));
235
275
  else if (data === "k" || matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
236
276
  else if (row && (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))) {
@@ -315,9 +355,11 @@ export class WorktreeChangesView {
315
355
  : paintHoverable(theme, `${this.expanded.has(row.tree.root) ? "▾" : "▸"} ${displayText(row.tree.branch ?? "detached")} · ${displayText(basename(row.tree.root))}`, hovered, idleRole);
316
356
  return `${marker} ${text}`;
317
357
  };
318
- const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
319
- this.pointerLayout = pointerLayout(width, height);
320
- return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file);
358
+ const keys = theme.fg(ROLE.KEY_TEXT, this.diffOnly
359
+ ? "j/k file ctrl+j/k scroll o open r refresh F files esc files"
360
+ : "j/k select enter toggle/open ← fold ctrl+j/k scroll r refresh F diff only esc close");
361
+ this.pointerLayout = pointerLayout(width, height, this.diffOnly);
362
+ return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file, this.diffOnly);
321
363
  }
322
364
 
323
365
  invalidate(): void {
@@ -341,6 +383,31 @@ export class WorktreeChangesView {
341
383
  return Math.max(MIN_BODY_ROWS, rows - CHROME_ROWS);
342
384
  }
343
385
 
386
+ private setDiffOnly(on: boolean): void {
387
+ this.diffOnly = on;
388
+ this.hoveredIndex = undefined;
389
+ this.pointerLayout = undefined;
390
+ // A group header has no diff; diff-only starts on the next file.
391
+ if (on && !this.visibleRows()[this.selected]?.file) this.stepFile(1);
392
+ this.deps.requestRender();
393
+ }
394
+
395
+ // Move to the next file in the fully expanded order, expanding its group
396
+ // so the file list shows the same selection after diff-only mode.
397
+ private stepFile(direction: 1 | -1): void {
398
+ const all: WorktreeRow[] = this.trees.flatMap((tree) => [{ tree }, ...tree.model.files.map((file) => ({ tree, file }))]);
399
+ const current = this.visibleRows()[this.selected];
400
+ let index = current ? all.findIndex((row) => rowKey(row) === rowKey(current)) : -1;
401
+ do index += direction;
402
+ while (index >= 0 && index < all.length && !all[index]!.file);
403
+ const target = all[index];
404
+ if (!target) return;
405
+ this.expanded.add(target.tree.root);
406
+ this.selected = this.visibleRows().findIndex((row) => rowKey(row) === rowKey(target));
407
+ this.ensureSelectedVisible();
408
+ this.ensurePreview();
409
+ }
410
+
344
411
  private scrollList(delta: number, rows: number): boolean {
345
412
  const next = Math.max(0, Math.min(Math.max(0, this.visibleRows().length - rows), this.listOffset + Math.trunc(delta)));
346
413
  if (next === this.listOffset) return false;
@@ -27,6 +27,8 @@ export interface ChangesModel {
27
27
  files: ChangedFile[];
28
28
  added: number;
29
29
  deleted: number;
30
+ /** Stored once per model build; the footer memo reads it every keystroke. */
31
+ digest?: string;
30
32
  }
31
33
 
32
34
  export interface ChangesSnapshot {
@@ -63,7 +65,7 @@ const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
63
65
  const RENAME_ARROW = " => ";
64
66
 
65
67
  export function emptyChanges(): ChangesModel {
66
- return { files: [], added: 0, deleted: 0 };
68
+ return { files: [], added: 0, deleted: 0, digest: EMPTY_CHANGES_DIGEST };
67
69
  }
68
70
 
69
71
  function renamedPath(path: string): string {
@@ -124,9 +126,42 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
124
126
  files,
125
127
  added: files.reduce((total, file) => total + file.added, 0),
126
128
  deleted: files.reduce((total, file) => total + file.deleted, 0),
129
+ digest: changesDigest({ files, added: 0, deleted: 0 }),
127
130
  };
128
131
  }
129
132
 
133
+ // The footer Status memo asks "did any change move?" on every keystroke, so
134
+ // the answer must be O(1) to read: the model stores this digest once per
135
+ // build (changesModel/emptyChanges) and consumers read the stored field.
136
+ // It is a 64-bit FNV-1a roll over every per-file tuple the renderer consumes
137
+ // (path, status, counts, diffRevision, countsUnavailable), so counts alone
138
+ // cannot miss a same-count edit. A hash collision could only skip one
139
+ // refresh until the next change moves the digest again -- it never renders a
140
+ // wrong picture -- but it is a probabilistic signal, not an exact one.
141
+ function fnv1a(hash: number, text: string): number {
142
+ for (let index = 0; index < text.length; index += 1) {
143
+ hash ^= text.charCodeAt(index);
144
+ hash = Math.imul(hash, 0x01000193);
145
+ }
146
+ return hash >>> 0;
147
+ }
148
+
149
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
150
+ const SECOND_SEED = 0x811c9dc5 ^ 0x9e3779b9;
151
+
152
+ export function changesDigest(model: ChangesModel): string {
153
+ let first = FNV_OFFSET_BASIS;
154
+ let second = SECOND_SEED;
155
+ for (const file of model.files) {
156
+ const tuple = `\u0000${file.path}\u0000${file.status}\u0000${file.added}\u0000${file.deleted}\u0000${file.diffRevision ?? ""}\u0000${file.countsUnavailable ?? ""}`;
157
+ first = fnv1a(first, tuple);
158
+ second = fnv1a(second, tuple);
159
+ }
160
+ return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}`;
161
+ }
162
+
163
+ const EMPTY_CHANGES_DIGEST = changesDigest({ files: [], added: 0, deleted: 0 });
164
+
130
165
  export function changesSummary(model: ChangesModel): string {
131
166
  const noun = model.files.length === 1 ? "file" : "files";
132
167
  return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
@@ -134,19 +169,39 @@ export function changesSummary(model: ChangesModel): string {
134
169
 
135
170
  // One line: summary, the files joined by dots, and the command pushed to
136
171
  // the right edge. The file list goes first when the terminal is narrow.
172
+ // The idle widget exists only while files are tracked. An unborn root (no
173
+ // commits yet) is silence here: /gentle:changes explains the state on demand,
174
+ // where the message has room and context.
175
+ //
176
+ // Both surfaces that paint it (the sidebar part and the below-input header
177
+ // row) re-render every frame, so the paint is memoized per digest, width and
178
+ // theme identity -- one stored frame, like CodemodeCard's cachedFrame. The
179
+ // digest already rolls up every per-file detail the paint consumes (path,
180
+ // status, counts, diffRevision, countsUnavailable), so a digest hit skips the
181
+ // per-file join entirely; a digest-less (hand-built) model never caches, and
182
+ // a new theme object repaints.
183
+ type WidgetFrame = { digest: string; width: number; theme: ChangesTheme; lines: string[] };
184
+ let widgetFrame: WidgetFrame | undefined;
185
+
137
186
  export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
138
187
  if (model.files.length === 0) return [];
188
+ if (model.digest !== undefined && widgetFrame?.digest === model.digest && widgetFrame.width === width && widgetFrame.theme === theme) return widgetFrame.lines;
139
189
  const noun = model.files.length === 1 ? "file" : "files";
140
190
  const dot = theme.fg("muted", "·");
141
191
  const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
142
192
  const hint = theme.fg("dim", CHANGES_COMMAND);
143
193
  const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
144
194
  const left = `${head} ${dot} ${list}`;
195
+ let lines: string[];
145
196
  const gap = width - visibleWidth(left) - visibleWidth(hint);
146
- if (gap >= 2) return [`${left}${" ".repeat(gap)}${hint}`];
147
- const headGap = width - visibleWidth(head) - visibleWidth(hint);
148
- if (headGap >= 2) return [`${head}${" ".repeat(headGap)}${hint}`];
149
- return [truncateToWidth(head, width, "…")];
197
+ if (gap >= 2) lines = [`${left}${" ".repeat(gap)}${hint}`];
198
+ else {
199
+ const headGap = width - visibleWidth(head) - visibleWidth(hint);
200
+ lines = headGap >= 2 ? [`${head}${" ".repeat(headGap)}${hint}`] : [truncateToWidth(head, width, "…")];
201
+ }
202
+ if (model.digest === undefined) return lines;
203
+ widgetFrame = { digest: model.digest, width, theme, lines };
204
+ return lines;
150
205
  }
151
206
 
152
207
  export interface GitResult {
@@ -199,12 +254,24 @@ export async function foreignRootBranch(git: GitRunner): Promise<string | undefi
199
254
  return verified.code === 0 ? branch : "no commits yet";
200
255
  }
201
256
 
257
+ // A root whose HEAD is unborn (no commits yet) has no baseline: `git diff
258
+ // HEAD` is meaningless and `git status` reports every file as untracked.
259
+ // Worktree-holder folders -- repositories that exist only to parent linked
260
+ // worktrees -- look exactly like this, and scanning them floods Changes with
261
+ // thousands of phantom untracked files (the whole holder's contents). One
262
+ // cheap verify answers "is there anything to diff against" before a scan.
263
+ const HEAD_EXISTS_ARGS = ["rev-parse", "--verify", "-q", "HEAD"];
264
+
202
265
  // /gentle:changes: one group per registered root holding its working tree
203
266
  // against HEAD. A root whose Git fails (timed out, rejected) has no group but
204
267
  // is listed in failedRoots: a failed scan is never reported as a clean root.
268
+ // A root with no commits has no group either, listed in unbornRoots: there is
269
+ // nothing to track yet, and the bar says so instead of scanning the holder.
205
270
  export class WorktreeChangesTracker {
206
271
  worktrees: WorktreeChanges[] = [];
207
272
  failedRoots: string[] = [];
273
+ /** Registered roots whose HEAD is unborn: skipped, never scanned. */
274
+ unbornRoots: string[] = [];
208
275
  scannedRootCount = 0;
209
276
  private inFlight: Promise<ChangesModel> | undefined;
210
277
  private readonly discover: GitRunner;
@@ -266,9 +333,17 @@ export class WorktreeChangesTracker {
266
333
  const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
267
334
  // Roots scan in parallel so one slow root does not delay the others.
268
335
  const failed: string[] = [];
336
+ const unborn: string[] = [];
269
337
  const scanned = await Promise.all([...roots].map(async (root): Promise<WorktreeChanges | undefined> => {
270
338
  const known = metadata.get(root);
271
339
  try {
340
+ // Only an answered "no" means unborn: a rejected verify cannot tell
341
+ // unborn from broken Git, so the scan below still decides.
342
+ const head = await this.gitForRoot(root)(HEAD_EXISTS_ARGS).catch(() => undefined);
343
+ if (head && head.code !== 0) {
344
+ unborn.push(root);
345
+ return undefined;
346
+ }
272
347
  const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root), this.revisionForRoot(root));
273
348
  await tracker.start();
274
349
  if (!tracker.statusRead) {
@@ -301,6 +376,7 @@ export class WorktreeChangesTracker {
301
376
  if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
302
377
  this.worktrees = trees;
303
378
  this.failedRoots = failed;
379
+ this.unbornRoots = unborn;
304
380
  this.scannedRootCount = roots.size;
305
381
  return this.model;
306
382
  }
@@ -20,10 +20,38 @@ const GAUGE_EMPTY_ROLE = "border";
20
20
  const WARNING_THRESHOLD = 80;
21
21
  const ERROR_THRESHOLD = 95;
22
22
 
23
- export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS): string {
23
+ // Gauge-specific theme roles (issue #2222). Every field is optional and falls
24
+ // back to the shared role the gauge painted with before gauge roles existed,
25
+ // so themes without a gauge block render byte-identically to today.
26
+ export interface GaugePaint {
27
+ /** Filled cells below the warning threshold; falls back to `accent`. */
28
+ ok?: string;
29
+ /** Filled cells from 80%; falls back to `warning`. */
30
+ warning?: string;
31
+ /** Filled cells from 95%; falls back to `error`. */
32
+ error?: string;
33
+ /** Empty cells; falls back to `border`. */
34
+ empty?: string;
35
+ /** Filled-cell glyph; falls back to `▰`. */
36
+ filled?: string;
37
+ /** Empty-cell glyph; falls back to `▱`. */
38
+ emptyGlyph?: string;
39
+ }
40
+
41
+ export interface GaugeGlyphs {
42
+ filled: string;
43
+ empty: string;
44
+ }
45
+
46
+ export function gaugeGlyphs(glyphs?: Partial<GaugeGlyphs>): GaugeGlyphs {
47
+ return { filled: glyphs?.filled ?? GAUGE_FILLED, empty: glyphs?.empty ?? GAUGE_EMPTY };
48
+ }
49
+
50
+ export function renderGauge(percent: number | null, cells: number = GAUGE_CELLS, glyphs?: Partial<GaugeGlyphs>): string {
24
51
  const clamped = Math.max(0, Math.min(100, percent ?? 0));
25
52
  const filled = Math.round((clamped / 100) * cells);
26
- return GAUGE_FILLED.repeat(filled) + GAUGE_EMPTY.repeat(cells - filled);
53
+ const [filledGlyph, emptyGlyph] = [glyphs?.filled ?? GAUGE_FILLED, glyphs?.empty ?? GAUGE_EMPTY];
54
+ return filledGlyph.repeat(filled) + emptyGlyph.repeat(cells - filled);
27
55
  }
28
56
 
29
57
  export function gaugeTone(percent: number | null): GaugeTone {
@@ -33,8 +61,23 @@ export function gaugeTone(percent: number | null): GaugeTone {
33
61
  return GAUGE_TONE.ACCENT;
34
62
  }
35
63
 
36
- export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS): string {
37
- const gauge = renderGauge(percent, cells);
38
- const filled = gauge.replace(new RegExp(`${GAUGE_EMPTY}+$`), "");
39
- return theme.fg(gaugeTone(percent), filled) + theme.fg(GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
64
+ export function paintGauge(percent: number | null, theme: GaugeTheme, cells: number = GAUGE_CELLS, paint?: GaugePaint): string {
65
+ const glyphs = gaugeGlyphs(paint && { filled: paint.filled, empty: paint.emptyGlyph });
66
+ const gauge = renderGauge(percent, cells, glyphs);
67
+ const filled = stripTrailing(gauge, glyphs.empty);
68
+ // Token names go through theme.fg; raw ANSI prefixes (resolved from direct
69
+ // colors by gauge-theme.ts) paint directly, so the gauges alone recolor.
70
+ const isAnsi = (role: string | undefined): role is string => typeof role === "string" && role.startsWith("\x1b[");
71
+ const paintFg = (role: string | undefined, fallback: string, text: string): string => (isAnsi(role) ? `${role}${text}\x1b[39m` : theme.fg(role ?? fallback, text));
72
+ const tone = gaugeTone(percent);
73
+ const filledRole = tone === GAUGE_TONE.ACCENT ? paint?.ok : tone === GAUGE_TONE.WARNING ? paint?.warning : tone === GAUGE_TONE.ERROR ? paint?.error : undefined;
74
+ return paintFg(filledRole, tone, filled) + paintFg(paint?.empty, GAUGE_EMPTY_ROLE, gauge.slice(filled.length));
75
+ }
76
+
77
+ // Strips the maximal run of trailing empty glyphs without building a RegExp
78
+ // from theme-authored strings, which could contain regex metacharacters.
79
+ function stripTrailing(gauge: string, empty: string): string {
80
+ let end = gauge.length;
81
+ while (end >= empty.length && end > 0 && gauge.endsWith(empty, end)) end -= empty.length;
82
+ return gauge.slice(0, end);
40
83
  }
@@ -23,10 +23,10 @@ const PETAL_TONE = {
23
23
 
24
24
  export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
25
25
 
26
- // Keep Pi's original loader cadence while carrying the banner's reflected-light
27
- // language into the compact working labels. The highlight enters before the
28
- // word, crosses it as a soft symmetric wave, then exits before wrapping.
29
- export const SHELL_PULSE_MS = 80;
26
+ // Render-driven animation (gentle-shell#994): the petal advances inside the
27
+ // prompt's own frames while streaming, so the only timer left is a watchdog
28
+ // that repaints silent working phases (long tool runs with no output).
29
+ export const SHELL_PULSE_WATCHDOG_MS = 1000;
30
30
  export const SHELL_SCANNER_STEPS = 15;
31
31
  const SCANNER_ENTRY_OFFSET = 3;
32
32