gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -1,7 +1,9 @@
1
1
  import { ScrollView, VStack, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
2
  import { sidebarState, type SidebarRail } from "./shell-sidebar.ts";
3
3
  import type { ShellBarTheme } from "./shell-bar.ts";
4
+ import { CARD_STYLE, cardStyle, type CardStyle } from "./shell-card.ts";
4
5
  import { renderSidebarBanner } from "./shell-sidebar-banner.ts";
6
+ import type { Density, HeaderPlacement, StatusPlacement } from "./visual-customization-policy.ts";
5
7
 
6
8
  export const SIDEBAR_BREAKPOINT = 140;
7
9
  const RAIL_WIDTH = 50;
@@ -12,6 +14,8 @@ const RAIL_PADDING = 1;
12
14
  // the card instead of touching the terminal edge.
13
15
  const HEADER_RIGHT_INSET = RAIL_PADDING + 1;
14
16
  const GAP = 3;
17
+ /** Right-edge columns the painting rail takes from the editor column; published as `railColumns`. */
18
+ export const SIDEBAR_RAIL_COLUMNS = RAIL_WIDTH + GAP;
15
19
  // Experimental Pi 0.85.1 internals. Only the fullscreen layout tree is adapted;
16
20
  // regular mode keeps native scrollback and the original bottom components.
17
21
  const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
@@ -27,6 +31,8 @@ type PreparedRail = {
27
31
  revision: number;
28
32
  width: number;
29
33
  mode: string | undefined;
34
+ headerPlacement: HeaderPlacement;
35
+ cardStyle: CardStyle;
30
36
  root: LayoutRoot;
31
37
  theme: ShellBarTheme;
32
38
  parts: Array<[string, SidebarRail]>;
@@ -68,8 +74,28 @@ function railDigest(rail: SidebarRail): string | undefined {
68
74
  }
69
75
  }
70
76
 
77
+ export const STATUS_OWNER = { HEADER: "header", BOTTOM: "bottom" } as const;
78
+ export type StatusOwner = (typeof STATUS_OWNER)[keyof typeof STATUS_OWNER];
79
+ export interface StatusOwnerInput {
80
+ mode: string | undefined;
81
+ columns: number;
82
+ statusPlacement: StatusPlacement;
83
+ headerPlacement: HeaderPlacement;
84
+ }
85
+
86
+ /**
87
+ * Which single status row owns a narrow fullscreen terminal, so the header and
88
+ * the bottom bar never both paint there. A configured top header wins;
89
+ * otherwise the bottom bar does, unless Status is hidden and the below-input
90
+ * header is all that is left. Wide and regular layouts keep their own rules.
91
+ */
92
+ export function narrowStatusOwner(input: StatusOwnerInput): StatusOwner | undefined {
93
+ if (input.mode !== "fullscreen" || input.columns >= SIDEBAR_BREAKPOINT) return undefined;
94
+ return input.headerPlacement === "top" || input.statusPlacement === "hidden" ? STATUS_OWNER.HEADER : STATUS_OWNER.BOTTOM;
95
+ }
96
+
71
97
  /** Installs the fullscreen rail: wraps the host layout root with the [rail, transcript] hstack and returns a disposer restoring the original layout. */
72
- export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
98
+ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () => StatusPlacement = () => "auto", headerPlacement: () => HeaderPlacement = () => "top", density: () => Density = () => "comfortable"): () => void {
73
99
  if (!tui.terminal) return () => {};
74
100
  const host = tui as Host;
75
101
  const state = sidebarState(tui);
@@ -89,7 +115,17 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
89
115
  // revision counter, exactly like the whole-rail memo already did.
90
116
  const sectionCache = new Map<string, SectionCacheEntry>();
91
117
  state.active = false;
92
- state.ownsHost = () => !stopped && host.mode === "fullscreen" && !!host.layoutRoot && roots.has(host.layoutRoot);
118
+ // Hidden removes Status everywhere, including regular mode where the rail
119
+ // never mounts, so it is published independently of the fullscreen layout.
120
+ state.statusHidden = () => !stopped && placement() === "hidden";
121
+ // A narrow top header is the only status row there. Whether it paints comes
122
+ // from the last layout pass (a blank or failed header never swallows the
123
+ // bottom bar); the geometry is read live so a resize applies before the next pass.
124
+ const headerOwnsStatus = () => !stopped && !failed && headerLines.length > 0 && headerPlacement() === "top" &&
125
+ narrowStatusOwner({ mode: host.mode, columns: tui.terminal.columns, statusPlacement: placement(), headerPlacement: headerPlacement() }) === STATUS_OWNER.HEADER;
126
+ state.headerOwnsStatus = headerOwnsStatus;
127
+ state.railColumns = SIDEBAR_RAIL_COLUMNS;
128
+ state.ownsHost = () => !stopped && host.mode === "fullscreen" && tui.terminal.columns >= SIDEBAR_BREAKPOINT && (placement() === "auto" || placement() === "right") && !!host.layoutRoot && roots.has(host.layoutRoot);
93
129
  const rail: Component = {
94
130
  render: () => railLines,
95
131
  invalidate() {
@@ -156,14 +192,30 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
156
192
  };
157
193
  const prepare = (width: number, root: LayoutRoot): boolean => {
158
194
  state.active = false;
159
- if (stopped || failed || host.mode !== "fullscreen" || width < SIDEBAR_BREAKPOINT) {
195
+ if (stopped || failed || host.mode !== "fullscreen") {
160
196
  prepared = undefined;
197
+ headerLines = [];
161
198
  return false;
162
199
  }
200
+ const railEligible = width >= SIDEBAR_BREAKPOINT && placement() !== "bottom" && placement() !== "hidden";
201
+ if (!railEligible) {
202
+ prepared = undefined;
203
+ try {
204
+ const headerPart = state.parts.get("header");
205
+ headerLines = headerPlacement() === "top" && headerPart
206
+ ? [...(headerPart.render(Math.max(0, width)) ?? [])] : [];
207
+ if (!headerLines.some((line) => line.trim() !== "")) headerLines = [];
208
+ } catch {
209
+ failed = true;
210
+ headerLines = [];
211
+ }
212
+ return false;
213
+ }
214
+ const style = cardStyle();
163
215
  const parts = [...state.parts.entries()];
164
216
  const digests = parts.map(([, rail]) => railDigest(rail));
165
217
  const unchanged = prepared?.revision === cache.revision &&
166
- prepared.width === width && prepared.mode === host.mode && prepared.root === root && prepared.theme === theme &&
218
+ prepared.width === width && prepared.mode === host.mode && prepared.headerPlacement === headerPlacement() && prepared.cardStyle === style && prepared.root === root && prepared.theme === theme &&
167
219
  prepared.parts.length === parts.length && prepared.parts.every(([key, part], index) => parts[index]?.[0] === key && parts[index]?.[1] === part) &&
168
220
  prepared.digests.length === digests.length && prepared.digests.every((digest, index) => digest === digests[index]);
169
221
  if (unchanged) {
@@ -179,7 +231,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
179
231
  const preparedHeaderLines = [...(headerPart?.render(Math.max(0, width - HEADER_RIGHT_INSET)) ?? [])];
180
232
  const headerActive = headerPart !== undefined && preparedHeaderLines.some((line) => line.trim() !== "");
181
233
  const contentWidth = scroll.getContentWidth(RAIL_WIDTH);
182
- const sections = ["footer", "agents", "todo"].map((key) => {
234
+ const sections = ["footer", "agents", "todo"].filter((key) => key !== "todo" || state.visibility?.todo !== false).map((key) => {
183
235
  const component = state.parts.get(key);
184
236
  if (!component) {
185
237
  sectionCache.delete(key);
@@ -204,23 +256,23 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
204
256
  railLines = [];
205
257
  if (sections.length && branding.length) {
206
258
  railLines.push(...branding.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
207
- } else if (sections.length && headerActive) {
208
- // The banner used to hold the first card off the top; the header
209
- // took its place, so keep one blank row between them.
259
+ } else if (sections.length && headerActive && density() === "comfortable" && style === CARD_STYLE.NEON) {
260
+ // Preserve neon's header gap. Float's painted top padding starts
261
+ // on the same body row as the transcript, without an external gap.
210
262
  railLines.push("");
211
263
  }
212
264
  for (const section of sections) {
213
265
  // One blank row separates a section from the banner or the
214
- // previous section; the header gap above is not a section.
215
- if (hits.length > 0 || branding.length > 0) railLines.push("");
266
+ // previous section; neon's header gap above is not a section.
267
+ if ((hits.length > 0 || branding.length > 0) && density() === "comfortable") railLines.push("");
216
268
  const startY = railLines.length;
217
269
  railLines.push(...section.lines.map((line) => " ".repeat(RAIL_PADDING) + line + " ".repeat(RAIL_PADDING)));
218
270
  hits.push({ key: section.key, component: section.component, startY, height: section.lines.length, width: contentWidth - RAIL_PADDING * 2 });
219
271
  }
220
272
  // Height is owned by the native ScrollView, never by the transcript.
221
273
  const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
222
- headerLines = active && headerActive ? preparedHeaderLines : [];
223
- prepared = { revision: cache.revision, width, mode: host.mode, root, theme, parts, digests, contentWidth, active, lines: railLines, hits, headerLines, headerActive: headerLines.length > 0 };
274
+ headerLines = headerActive && headerPlacement() === "top" ? preparedHeaderLines : [];
275
+ prepared = { revision: cache.revision, width, mode: host.mode, headerPlacement: headerPlacement(), cardStyle: style, root, theme, parts, digests, contentWidth, active, lines: railLines, hits, headerLines, headerActive: headerLines.length > 0 };
224
276
  state.active = active;
225
277
  return active;
226
278
  } catch {
@@ -264,6 +316,11 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
264
316
  }
265
317
  return { ...node, entries: entries.map((entry, index) => index === entries.length - 1 ? { ...entry, component: wrapped! } : entry) };
266
318
  };
319
+ // Without a rail the native layout stays in place; a hidden Status, or a
320
+ // narrow top header owning it, only frees the footer's reserved dock row,
321
+ // exactly as the rail does.
322
+ const nativeLayout = () => placement() === "hidden" || headerOwnsStatus() ? reclaimFooterRow(original.call(root)) : original.call(root);
323
+ const nativeHost = { render: () => [], invalidate() {}, [NODE]: nativeLayout };
267
324
  const left = { render: () => [], invalidate() {}, [NODE]: () => reclaimFooterRow(original.call(root)) };
268
325
  // Stable component wrapping the [left, scroll] hstack behind its own
269
326
  // NODE, exactly like `left` wraps the native transcript: the header
@@ -278,10 +335,17 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
278
335
  ] }),
279
336
  };
280
337
  const replacement = () => {
281
- if (!prepare(tui.terminal.columns, root)) return original.call(root);
338
+ if (!prepare(tui.terminal.columns, root)) {
339
+ if (failed || stopped || host.mode !== "fullscreen") return original.call(root);
340
+ if (!headerLines.length) return nativeLayout();
341
+ return { type: "vstack", gap: 0, align: "stretch", entries: [
342
+ { component: header, basis: "auto", grow: 0, shrink: 0, minSize: 1 },
343
+ { component: nativeHost, basis: 0, grow: 1, shrink: 1, minSize: 1 },
344
+ ] };
345
+ }
282
346
  const current = prepared!;
283
347
  if (current.presentation?.scrollTop === scroll.scrollTop) return current.presentation.output;
284
- const output: LayoutNode = current.headerActive
348
+ const output: LayoutNode = current.headerActive && headerPlacement() !== "below-input"
285
349
  ? { type: "vstack", gap: 0, align: "stretch", entries: [
286
350
  { component: header, basis: "auto", grow: 0, shrink: 0, minSize: 1 },
287
351
  { component: hstackHost, basis: 0, grow: 1, shrink: 1, minSize: 1 },
@@ -3,9 +3,23 @@ import type { Component, TUI } from "@earendil-works/pi-tui";
3
3
  // Store on the terminal, not a module singleton: extension loaders may isolate
4
4
  // modules, while Pi keeps this terminal across regular/fullscreen transitions.
5
5
  const STATE = Symbol.for("gentle-pi.experimental-sidebar.state");
6
+ /** In-process notification after a visual preference is persisted. */
7
+ export const VISUAL_SETTINGS_CHANGED = "gentle-pi.visual-settings-changed";
6
8
  export interface SidebarState {
7
9
  active: boolean;
10
+ visibility?: { todo?: boolean };
8
11
  ownsHost?: () => boolean;
12
+ /**
13
+ * Columns the fullscreen rail reserves at the right edge (rail plus gap)
14
+ * while `active && ownsHost()`. Overlays read it to stay inside the editor
15
+ * column (the prompt-history picker's right margin) without importing
16
+ * gentle-shell.
17
+ */
18
+ railColumns?: number;
19
+ /** True while Status placement is "hidden": the bottom Status bar paints nothing at any width or mode. */
20
+ statusHidden?: () => boolean;
21
+ /** True while a painting top header is the only status row of a narrow fullscreen terminal: the bottom Status bar steps aside. */
22
+ headerOwnsStatus?: () => boolean;
9
23
  parts: Map<string, SidebarRail>;
10
24
  }
11
25
 
@@ -32,7 +46,7 @@ export function sidebarPart<T extends Component & { dispose?(): void }>(tui: TUI
32
46
  state.parts.set(key, rail);
33
47
  return {
34
48
  ...bottom,
35
- render: (width: number) => state.active && state.ownsHost?.() ? [] : bottom.render(width),
49
+ render: (width: number) => (key === "todo" && state.visibility?.todo === false) || (key === "footer" && (state.statusHidden?.() || state.headerOwnsStatus?.())) || (state.active && state.ownsHost?.()) ? [] : bottom.render(width),
36
50
  dispose() {
37
51
  if (state.parts.get(key) === rail) state.parts.delete(key);
38
52
  bottom.dispose?.();
package/lib/shell-todo.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { CARD_TONE, cardInnerWidth, renderCard, type CardTheme } from "./shell-card.ts";
2
+ import { CARD_TONE, panelHeaderRow, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
3
3
  import { sanitizeTerminalText } from "./terminal-theme.ts";
4
4
  import { paintHoverable } from "./shell-hover.ts";
5
5
 
@@ -244,11 +244,11 @@ export function todoPromptBlock(state: TodoState, stale: number): string | undef
244
244
  ].join("\n") + staleLine;
245
245
  }
246
246
 
247
- function taskRow(task: TodoTask, theme: TodoTheme, width: number): string {
247
+ function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
248
248
  if (task.status === TODO_STATUS.DONE) {
249
249
  // Wrap before applying SGR 9: every physical title closes its own style
250
250
  // before renderCard adds padding and rails.
251
- return wrapTextWithAnsi(task.title, Math.max(1, cardInnerWidth(width) - 2))
251
+ return wrapTextWithAnsi(task.title, Math.max(1, inner - 2))
252
252
  .map((line, index) => `${index === 0 ? theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status]) : " "} ${theme.fg(STATUS_ROLE[task.status], theme.strikethrough(line))}`)
253
253
  .join("\n");
254
254
  }
@@ -259,27 +259,32 @@ function taskRow(task: TodoTask, theme: TodoTheme, width: number): string {
259
259
 
260
260
  // Long lists keep the card short: done tasks fold into "✓ N done" and the
261
261
  // open ones fill the remaining rows, with a trailing count for the rest.
262
- function bodyRows(state: TodoState, theme: TodoTheme, width: number): string[] {
263
- if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme, width));
262
+ function bodyRows(state: TodoState, theme: TodoTheme, inner: number): string[] {
263
+ if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme, inner));
264
264
  const { done } = todoSummary(state);
265
265
  const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE);
266
266
  const rows: string[] = [];
267
267
  if (done > 0) rows.push(`${theme.fg(GLYPH_ROLE[TODO_STATUS.DONE], STATUS_GLYPH[TODO_STATUS.DONE])} ${theme.fg(NOTE_ROLE, `${done} done`)}`);
268
268
  const room = ROW_CAP - rows.length - (open.length > ROW_CAP - rows.length ? 1 : 0);
269
- for (const task of open.slice(0, room)) rows.push(taskRow(task, theme, width));
269
+ for (const task of open.slice(0, room)) rows.push(taskRow(task, theme, inner));
270
270
  if (open.length > room) rows.push(theme.fg(NOTE_ROLE, `… ${open.length - room} more`));
271
271
  return rows;
272
272
  }
273
273
 
274
- function collapsedRow(state: TodoState, theme: TodoTheme, width: number): string {
274
+ function collapsedRow(state: TodoState, theme: TodoTheme, inner: number): string {
275
275
  const active = state.tasks.find((task) => task.status === TODO_STATUS.IN_PROGRESS);
276
- if (active) return taskRow(active, theme, width);
276
+ if (active) return taskRow(active, theme, inner);
277
277
  const pending = state.tasks.find((task) => task.status === TODO_STATUS.PENDING);
278
- if (pending) return taskRow(pending, theme, width);
278
+ if (pending) return taskRow(pending, theme, inner);
279
279
  const { open } = todoSummary(state);
280
280
  return `${theme.fg(GLYPH_ROLE[TODO_STATUS.PENDING], STATUS_GLYPH[TODO_STATUS.PENDING])} ${theme.fg(NOTE_ROLE, `${open} open`)}`;
281
281
  }
282
282
 
283
+ /** The tone the Todos card renders in: warning once the list went stale, info otherwise. */
284
+ export function todoCardTone(staleTurns: number): CardTone {
285
+ return staleTurns >= STALE_AFTER_TURNS ? CARD_TONE.WARNING : CARD_TONE.INFO;
286
+ }
287
+
283
288
  export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number, options: TodoRenderOptions): string[] {
284
289
  if (state.tasks.length === 0) return [];
285
290
  const { done, total } = todoSummary(state);
@@ -288,16 +293,21 @@ export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number
288
293
  const actionLabel = action[0]!.toUpperCase() + action.slice(1);
289
294
  const icon = options.collapsed ? "▸" : "▾";
290
295
  const control = `${icon} ${width >= 28 ? actionLabel : ""}`.trimEnd();
291
- const hint = options.collapseKey ? `${options.collapseKey} ${action}` : undefined;
292
- const rows = options.collapsed ? [collapsedRow(state, theme, width)] : options.scrollable ? state.tasks.map((task) => taskRow(task, theme, width)) : bodyRows(state, theme, width);
296
+ const tone = todoCardTone(options.staleTurns);
297
+ const hint = options.collapseKey
298
+ ? panelHeaderRow(theme, width, tone) ? options.collapseKey : `${options.collapseKey} ${action}`
299
+ : undefined;
300
+ // Content columns of the card body in the active style (float is narrower).
301
+ const inner = panelInnerWidth(theme, width, tone);
302
+ const rows = options.collapsed ? [collapsedRow(state, theme, inner)] : options.scrollable ? state.tasks.map((task) => taskRow(task, theme, inner)) : bodyRows(state, theme, inner);
293
303
  const body = stale ? [theme.fg(NOTE_ROLE, `stale · ${options.staleTurns} turns`), ...rows] : rows;
294
304
  // The clickable control paints the shared hover role while hovered --
295
305
  // same treatment every other clickable surface uses -- instead of its
296
306
  // ordinary accent role.
297
307
  return renderCard(
298
- { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${total}`, body, tone: stale ? CARD_TONE.WARNING : CARD_TONE.INFO, glyph: TODO_GLYPH },
308
+ { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${total}`, body, tone, glyph: TODO_GLYPH },
299
309
  theme,
300
310
  width,
301
- { expanded: true, hint },
311
+ { expanded: true, hint, panel: true },
302
312
  );
303
313
  }
@@ -1,5 +1,5 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
- import { renderUsagePanel, type ActiveProvider, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
2
+ import { renderUsagePanel, type ActiveProvider, type UsagePanelScope, type UsageSourceRegistry, type UsageStore, type UsageTheme } from "./shell-usage.ts";
3
3
  import { paintHoverable } from "./shell-hover.ts";
4
4
 
5
5
  // Gentle Shell subscriptions overlay: a framed panel over the usage store.
@@ -12,6 +12,10 @@ export interface UsageViewDeps {
12
12
  // Optional: lets the panel resolve the pending note of a provider whose
13
13
  // usage source was registered at runtime instead of built in.
14
14
  registry?(): UsageSourceRegistry | undefined;
15
+ // Optional: the targeted routing scope. Without it the panel draws every
16
+ // provider the store holds; with it, exactly the targeted providers, in
17
+ // scope order, explaining the ones without data.
18
+ scope?(): UsagePanelScope | undefined;
15
19
  onRefresh(): Promise<void>;
16
20
  onClose(): void;
17
21
  requestRender(): void;
@@ -80,8 +84,9 @@ export class UsageView {
80
84
  // A failing usage fetch is the store's problem to report (its rows already
81
85
  // carry the last error); the panel only clears its "refreshing" state. The
82
86
  // rejection must never leave this method: an unhandled rejection is fatal
83
- // to the whole shell on current Node.
84
- private refresh(): void {
87
+ // to the whole shell on current Node. Public so the overlay can dispatch
88
+ // its opening refresh instead of making the open wait for it.
89
+ refresh(): void {
85
90
  if (this.refreshing) return;
86
91
  this.refreshing = true;
87
92
  this.deps.requestRender();
@@ -101,7 +106,7 @@ export class UsageView {
101
106
  const inner = width - 2;
102
107
  const title = this.refreshing ? REFRESHING : TITLE;
103
108
  const top = theme.fg(FRAME_ROLE, "╭─ ") + theme.fg(TITLE_ROLE, title) + theme.fg(FRAME_ROLE, ` ${rule(inner - visibleWidth(title) - 3)}╮`);
104
- const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.()).map(
109
+ const body = renderUsagePanel(this.store.all(), theme, inner - 2, this.deps.now(), this.deps.active(), this.deps.registry?.(), this.deps.scope?.()).map(
105
110
  (line) => `${theme.fg(FRAME_ROLE, "│")} ${fit(line, inner - 2)} ${theme.fg(FRAME_ROLE, "│")}`,
106
111
  );
107
112
  const hints = KEYS.map(([key, label]) => ({ key, label, text: `${key} ${label}`, action: (key === "r" ? "refresh" : "close") as HintAction }));
@@ -1,4 +1,5 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { parseModelRef } from "./agents-config.ts";
2
3
  import { paintGauge } from "./shell-gauge.ts";
3
4
 
4
5
  // Gentle Shell subscription usage: the rate-limit windows each connected
@@ -125,6 +126,10 @@ const PENDING_NOTE: Record<string, string> = {
125
126
  };
126
127
  const UNSUPPORTED_NOTE = "no subscription usage for this provider";
127
128
  const ACTIVE_MARK = "✿";
129
+ // What a targeted provider with no snapshot says after a refresh actually ran
130
+ // and answered nothing. Generic on purpose: it names no endpoint, key, or
131
+ // account detail, exactly like the other notes here.
132
+ export const USAGE_FETCH_FAILED_NOTE = "fetch failed · r to retry";
128
133
 
129
134
  export interface ActiveProvider {
130
135
  provider: string;
@@ -289,7 +294,7 @@ export function parseCodexUsage(payload: unknown, now: number): ProviderUsage {
289
294
  return { provider: CODEX_PROVIDER, plan: typeof raw.plan_type === "string" ? raw.plan_type : undefined, limits, fetchedAt: now };
290
295
  }
291
296
 
292
- function headerWindow(headers: Record<string, string>, kind: "primary" | "secondary", now: number): UsageWindow | undefined {
297
+ function headerWindow(headers: Record<string, string>, kind: "primary" | "secondary"): UsageWindow | undefined {
293
298
  const used = Number.parseFloat(headers[`${HEADER_PREFIX}${kind}-used-percent`] ?? "");
294
299
  if (!Number.isFinite(used)) return undefined;
295
300
  const minutes = Number.parseInt(headers[`${HEADER_PREFIX}${kind}-window-minutes`] ?? "", 10);
@@ -299,7 +304,7 @@ function headerWindow(headers: Record<string, string>, kind: "primary" | "second
299
304
  }
300
305
 
301
306
  export function parseCodexHeaders(headers: Record<string, string>, now: number): ProviderUsage | undefined {
302
- const windows = [headerWindow(headers, "primary", now), headerWindow(headers, "secondary", now)].filter((window): window is UsageWindow => window !== undefined);
307
+ const windows = [headerWindow(headers, "primary"), headerWindow(headers, "secondary")].filter((window): window is UsageWindow => window !== undefined);
303
308
  if (windows.length === 0) return undefined;
304
309
  const reached = headers[`${HEADER_PREFIX}rate-limit-reached-type`];
305
310
  return { provider: CODEX_PROVIDER, plan: undefined, limits: [{ name: CODEX_MAIN_LIMIT, windows, limitReached: Boolean(reached) }], fetchedAt: now };
@@ -527,18 +532,66 @@ function updatedAgo(fetchedAt: number, now: number): string {
527
532
  return minutes < 1 ? "updated just now" : `updated ${minutes}m ago`;
528
533
  }
529
534
 
535
+ // The providers one refresh targets: the session's own provider first, then
536
+ // every provider the active profile's subagent routing names, deduplicated in
537
+ // first-seen order. A qualified ref ("provider/id") resolves directly; a bare
538
+ // model id resolves through the registry callback only when that callback can
539
+ // name exactly one provider, so an ambiguous or unknown ref never guesses one
540
+ // and is dropped instead.
541
+ export function usageScopeProviders(
542
+ mainProvider: string | undefined,
543
+ routingModels: ReadonlyArray<string | undefined> | undefined,
544
+ resolveBareModel?: (modelId: string) => string | undefined,
545
+ ): string[] {
546
+ const providers = new Set<string>();
547
+ if (mainProvider) providers.add(mainProvider);
548
+ for (const model of routingModels ?? []) {
549
+ const ref = parseModelRef(model);
550
+ if (!ref) continue;
551
+ const provider = ref.provider ?? resolveBareModel?.(ref.id);
552
+ if (provider) providers.add(provider);
553
+ }
554
+ return [...providers];
555
+ }
556
+
557
+ // The current routing scope the panel draws: which providers are targeted, and
558
+ // which of them failed their latest settled refresh. A provider recorded
559
+ // earlier but no longer targeted is simply absent — it is not current scope.
560
+ export interface UsagePanelScope {
561
+ providers: readonly string[];
562
+ // Actual failures only: a refresh that settled without a snapshot. A
563
+ // provider still fetching is never in here, so an in-flight refresh cannot
564
+ // read as failed, and a successful refresh removes the provider again.
565
+ failed: ReadonlySet<string>;
566
+ }
567
+
530
568
  // The active provider comes first, marked with the petal, and explains
531
569
  // itself when it has no data yet. Other providers seen this session follow.
532
- export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry): string[] {
570
+ // With a scope, the panel draws exactly the targeted providers in scope order:
571
+ // one with no snapshot explains itself (its latest refresh failed, or its
572
+ // pending note otherwise), one whose latest refresh failed after a good
573
+ // snapshot keeps that snapshot and says so beside it, and a provider recorded
574
+ // under a previous profile's routing is never presented as current scope after
575
+ // a profile switch.
576
+ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, width: number, now: number, active?: ActiveProvider, registry?: UsageSourceRegistry, scope?: UsagePanelScope): string[] {
533
577
  const activeUsage = active ? usages.find((usage) => usage.provider === active.provider) : undefined;
534
- const others = usages.filter((usage) => usage !== activeUsage);
535
- if (!active && usages.length === 0) return [truncateToWidth(USAGE_EMPTY_MESSAGE, width, "…")];
578
+ const byProvider = new Map(usages.map((usage) => [usage.provider, usage] as const));
579
+ const rows = scope
580
+ ? scope.providers.map((provider) => ({ provider, usage: byProvider.get(provider) }))
581
+ : [...(active ? [{ provider: active.provider, usage: activeUsage }] : []), ...usages.filter((usage) => usage !== activeUsage).map((usage) => ({ provider: usage.provider, usage }))];
582
+ if (rows.length === 0) return [truncateToWidth(USAGE_EMPTY_MESSAGE, width, "…")];
536
583
  const lines: string[] = [];
537
- if (active && !activeUsage) {
538
- lines.push(`${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ${theme.fg(ROLE.PROVIDER, active.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, providerNote(active.provider, registry))}`);
539
- }
540
- for (const usage of [...(activeUsage ? [activeUsage] : []), ...others]) {
541
- const mark = usage === activeUsage ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
584
+ for (const row of rows) {
585
+ const mark = active?.provider === row.provider ? `${theme.fg(ROLE.LIMIT, ACTIVE_MARK)} ` : "";
586
+ if (!row.usage) {
587
+ // A settled refresh that answered nothing is a failure; a provider
588
+ // whose usage arrives with responses (Anthropic) or that has not
589
+ // failed keeps its pending note.
590
+ const note = scope?.failed.has(row.provider) ? USAGE_FETCH_FAILED_NOTE : providerNote(row.provider, registry);
591
+ lines.push(`${mark}${theme.fg(ROLE.PROVIDER, row.provider)} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, note)}`);
592
+ continue;
593
+ }
594
+ const usage = row.usage;
542
595
  const plan = usage.plan ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.PLAN, usage.plan)}` : "";
543
596
  lines.push(`${mark}${theme.fg(ROLE.PROVIDER, usage.provider)}${plan} ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, updatedAgo(usage.fetchedAt, now))}`);
544
597
  // One row per window: the limit name, its meter, its percentage and the reset
@@ -555,6 +608,9 @@ export function renderUsagePanel(usages: ProviderUsage[], theme: UsageTheme, wid
555
608
  const tail = reset.length > 0 ? ` ${theme.fg(ROLE.SEPARATOR, "·")} ${theme.fg(ROLE.RESET, reset)}` : "";
556
609
  lines.push(` ${theme.fg(ROLE.LABEL, row.name.padEnd(nameWidth))} ${paintMeter(row.window.usedPercent, PANEL_METER_CELLS, theme)} ${theme.fg(ROLE.PERCENT, percent)}${tail}`);
557
610
  }
611
+ // A failure after a good snapshot never hides either fact: the last good
612
+ // snapshot stays on its rows and the failed refresh says so beside it.
613
+ if (scope?.failed.has(row.provider)) lines.push(` ${theme.fg(ROLE.RESET, USAGE_FETCH_FAILED_NOTE)}`);
558
614
  }
559
615
  return lines.map((line) => truncateToWidth(line, width, "…"));
560
616
  }