@linxiraos/pi-tui 1.2.1 → 1.3.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 (274) hide show
  1. package/CHANGELOG.md +188 -125
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +21 -20
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +26 -6
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/annotation-overlay.ts +422 -168
  157. package/src/overlays/approval-dialog.ts +389 -0
  158. package/src/overlays/ask-dialog.ts +244 -105
  159. package/src/overlays/btw-history-panel.ts +208 -147
  160. package/src/overlays/extensions/extension-list.ts +4 -6
  161. package/src/overlays/hook-editor.ts +90 -32
  162. package/src/overlays/hook-input.ts +105 -19
  163. package/src/overlays/job-tree.ts +177 -0
  164. package/src/overlays/jobs-panel.ts +222 -142
  165. package/src/overlays/login-dialog.ts +201 -91
  166. package/src/overlays/mcp-add-wizard.ts +562 -279
  167. package/src/overlays/model-browser.ts +32 -7
  168. package/src/overlays/model-hub.ts +4 -10
  169. package/src/overlays/model-picker.ts +58 -21
  170. package/src/overlays/pause-screen.ts +81 -23
  171. package/src/overlays/plan-review-overlay.ts +16 -0
  172. package/src/overlays/plugin-selector.ts +17 -1
  173. package/src/overlays/queue-mode-selector.ts +10 -0
  174. package/src/overlays/report-panel.ts +6 -2
  175. package/src/overlays/reset-usage-selector.ts +130 -21
  176. package/src/overlays/session-account-selector.ts +36 -0
  177. package/src/overlays/session-info-overlay.ts +199 -76
  178. package/src/overlays/session-selector.ts +15 -10
  179. package/src/overlays/settings-selector.ts +3 -0
  180. package/src/overlays/show-images-selector.ts +1 -0
  181. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  182. package/src/overlays/theme-selector.ts +6 -1
  183. package/src/overlays/thinking-selector.ts +8 -4
  184. package/src/overlays/tree-selector.ts +117 -2
  185. package/src/overlays/usage-dashboard.ts +498 -144
  186. package/src/prompt/composer-cache.ts +1 -1
  187. package/src/prompt/composer.ts +194 -74
  188. package/src/prompt/custom-editor.ts +168 -65
  189. package/src/prompt/image-references.d.ts +22 -0
  190. package/src/prompt/image-references.ts +79 -2
  191. package/src/prompt/internal-url-autocomplete.ts +1 -0
  192. package/src/prompt/macos-spelling.ts +28 -5
  193. package/src/prompt/prose-gate.ts +3 -0
  194. package/src/prompt/queued-messages.ts +9 -2
  195. package/src/prompt/tips.txt +1 -2
  196. package/src/prompt/typing-word.ts +59 -0
  197. package/src/prompt/welcome.ts +97 -45
  198. package/src/render/code-cell.ts +18 -5
  199. package/src/render/hanging-wrap.d.ts +7 -0
  200. package/src/render/hanging-wrap.ts +58 -0
  201. package/src/render/hyperlink.d.ts +2 -0
  202. package/src/render/hyperlink.ts +5 -0
  203. package/src/render/render-utils.d.ts +9 -1
  204. package/src/render/render-utils.ts +16 -2
  205. package/src/render/utils.d.ts +1 -1
  206. package/src/render/utils.ts +1 -1
  207. package/src/render/width-aware-text.ts +6 -5
  208. package/src/setup/scenes/splash.ts +22 -8
  209. package/src/status-line/component.ts +23 -10
  210. package/src/status-line/context-usage.ts +176 -73
  211. package/src/status-line/footer.ts +18 -35
  212. package/src/status-line/host.ts +6 -1
  213. package/src/status-line/segments.ts +16 -5
  214. package/src/status-line/startup.ts +6 -6
  215. package/src/status-line/types.ts +1 -0
  216. package/src/stdin-buffer.d.ts +7 -0
  217. package/src/stdin-buffer.ts +11 -1
  218. package/src/terminal-capabilities.d.ts +16 -1
  219. package/src/terminal-capabilities.ts +66 -28
  220. package/src/terminal-multiplexer.d.ts +7 -0
  221. package/src/terminal-multiplexer.ts +14 -0
  222. package/src/terminal.d.ts +8 -4
  223. package/src/terminal.ts +160 -46
  224. package/src/theme/symbols.d.ts +1 -1
  225. package/src/theme/symbols.ts +1 -0
  226. package/src/theme/theme-class.ts +1 -0
  227. package/src/theme/theme.d.ts +11 -0
  228. package/src/theme/theme.ts +46 -14
  229. package/src/thinking.d.ts +5 -0
  230. package/src/thinking.ts +9 -1
  231. package/src/tmux.d.ts +39 -4
  232. package/src/tmux.ts +110 -16
  233. package/src/tools/ask.ts +20 -9
  234. package/src/tools/ast-edit.d.ts +2 -2
  235. package/src/tools/ast-edit.ts +30 -7
  236. package/src/tools/ast-grep.d.ts +2 -2
  237. package/src/tools/ast-grep.ts +39 -35
  238. package/src/tools/bash.d.ts +6 -1
  239. package/src/tools/bash.ts +15 -9
  240. package/src/tools/debug.ts +2 -1
  241. package/src/tools/default-renderer.ts +3 -2
  242. package/src/tools/edit.d.ts +2 -0
  243. package/src/tools/edit.ts +100 -21
  244. package/src/tools/eval.d.ts +53 -4
  245. package/src/tools/eval.ts +173 -137
  246. package/src/tools/find.d.ts +14 -4
  247. package/src/tools/find.ts +71 -18
  248. package/src/tools/glob.d.ts +3 -3
  249. package/src/tools/glob.ts +51 -17
  250. package/src/tools/grep.d.ts +2 -2
  251. package/src/tools/grep.ts +99 -22
  252. package/src/tools/grouped-file-output.d.ts +9 -4
  253. package/src/tools/grouped-file-output.ts +21 -6
  254. package/src/tools/lsp.ts +30 -18
  255. package/src/tools/mcp.ts +2 -2
  256. package/src/tools/native-view.d.ts +1 -1
  257. package/src/tools/native-view.ts +2 -2
  258. package/src/tools/read.d.ts +32 -10
  259. package/src/tools/read.ts +89 -43
  260. package/src/tools/renderer.d.ts +2 -0
  261. package/src/tools/renderer.ts +2 -0
  262. package/src/tools/task.d.ts +31 -0
  263. package/src/tools/task.ts +40 -7
  264. package/src/tools/todo.ts +3 -2
  265. package/src/tools/wait.ts +16 -30
  266. package/src/tools/web-search.ts +2 -1
  267. package/src/tools/write.d.ts +2 -0
  268. package/src/tools/write.ts +36 -12
  269. package/src/tools/yield.ts +2 -2
  270. package/src/tui.d.ts +20 -3
  271. package/src/tui.ts +140 -36
  272. package/src/utils.ts +5 -3
  273. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  274. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -378,7 +378,7 @@ export function buildHeatmapLayout(points: DailyActivityPoint[], weeks: number,
378
378
  const NATIVE_HEATMAP_WEEKS = 53;
379
379
  /** Span tokens for heatmap intensity levels 1..4 (the `table` fallback). */
380
380
  const HEAT_LEVEL_TOKENS = ["dim", "accent dim", "accent", "accent strong"];
381
- /** Heatmap row labels, Monday first like omp's grid: M/W/F only. */
381
+ /** Heatmap row labels, Monday first like zeta's grid: M/W/F only. */
382
382
  const HEATMAP_ROW_LABELS = ["M", "", "W", "", "F", "", ""];
383
383
  const WEEKDAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
384
384
 
@@ -390,7 +390,7 @@ function statusTone(status: UsageLimit["status"]): TspTone {
390
390
  return "muted";
391
391
  }
392
392
 
393
- /** Status dot of a provider frame, toned by the card's aggregate status. */
393
+ /** Status dot before a provider's name, toned by the card's aggregate status. */
394
394
  function statusDot(status: UsageLimit["status"]): NativeNode {
395
395
  const token =
396
396
  status === "exhausted" ? "error" : status === "warning" ? "warning" : status === "ok" ? "success" : "dim";
@@ -402,7 +402,7 @@ function statusDot(status: UsageLimit["status"]): NativeNode {
402
402
  : status === "ok"
403
403
  ? "OK"
404
404
  : "Unknown";
405
- return text([span("●", token)], { title: label, aria: label });
405
+ return text([span("●", token)], { role: "zeta.usage.dot", title: label, aria: label });
406
406
  }
407
407
 
408
408
  /**
@@ -415,11 +415,6 @@ function usageMeter(fraction: number, status: UsageLimit["status"], meter: boole
415
415
  return meter ? node("meter", { value, style: "bar", size: "md", tone }) : node("progress", { value, tone, grow: 1 });
416
416
  }
417
417
 
418
- /** `62% left` for a used fraction (overage reads as 0%). */
419
- function leftText(fraction: number): string {
420
- return `${Math.max(0, Math.round((1 - fraction) * 100))}% left`;
421
- }
422
-
423
418
  /** `Mon 28 Sep` for a local date. */
424
419
  function shortDate(date: Date): string {
425
420
  return `${WEEKDAY_NAMES[date.getDay()]} ${date.getDate()} ${MONTH_NAMES[date.getMonth()]}`;
@@ -455,24 +450,61 @@ function mutedText(content: string, wrap = false): NativeNode {
455
450
  }
456
451
 
457
452
  /**
458
- * A quiet line under the provider grid naming the providers that get no
459
- * frame (`Untouched: Kimi Code, Zai`); `title` says why on hover.
453
+ * A quiet row under the provider rows naming the providers that get none
454
+ * (`Untouched Kimi Code, Zai`): the label sits in the provider column, the
455
+ * names in the quota column; `title` says why on hover.
460
456
  */
461
457
  function footnote(key: string, label: string, cards: readonly ProviderCard[], title?: string): NativeNode {
462
458
  const names = cards.map(card => card.name).join(", ");
463
459
  return node(
464
- "text",
465
- {
466
- spans: [span(`${label}: `, "dim"), span(names)],
467
- wrap: "word",
468
- role: "zeta.usage.footnote",
469
- ...(title ? { title: sanitizeDisplayLine(title) } : {}),
470
- },
471
- undefined,
460
+ "row",
461
+ { role: "zeta.usage.footnote", align: "baseline", ...(title ? { title: sanitizeDisplayLine(title) } : {}) },
462
+ [text([span(label, "dim")], { role: "zeta.usage.footnote.k" }), text([span(names)], { wrap: "word" })],
472
463
  key,
473
464
  );
474
465
  }
475
466
 
467
+ /** A section's title over its rows (`Quotas`, `Activity`) with quiet facts at its right end. */
468
+ function sectionHead(title: string, meta: readonly NativeChild[]): NativeNode {
469
+ return node(
470
+ "row",
471
+ { role: "zeta.usage.section", gap: "sm", align: "center" },
472
+ [text([span(title)], { role: "zeta.usage.section.title" }), node("spacer", { grow: 1 }), ...meta],
473
+ "head",
474
+ );
475
+ }
476
+
477
+ /**
478
+ * One headline figure of the summary strip: a small label, the figure (with an
479
+ * optional chart beside it), and a line of context under it.
480
+ */
481
+ function statTile(
482
+ key: string,
483
+ label: string,
484
+ value: TspText,
485
+ sub: TspText,
486
+ options: { chart?: NativeNode; title?: string } = {},
487
+ ): NativeNode {
488
+ const figure: NativeChild[] = [text(value, { role: "zeta.usage.stat.value", truncate: "end" })];
489
+ if (options.chart) figure.push(options.chart);
490
+ return node(
491
+ "col",
492
+ { role: "zeta.usage.stat", gap: "xs", ...(options.title ? { title: options.title } : {}) },
493
+ [
494
+ text([span(label)], { role: "zeta.usage.stat.label", truncate: "end" }),
495
+ node("row", { gap: "md", align: "end" }, figure, "figure"),
496
+ text(sub, { role: "zeta.usage.stat.sub", truncate: "end" }),
497
+ ],
498
+ key,
499
+ );
500
+ }
501
+
502
+ /** `62%` in mono, toned like the window's status, then a quiet `left`. */
503
+ function leftSpans(fraction: number, status: UsageLimit["status"]): TspSpan[] {
504
+ const token = status === "exhausted" ? "mono error" : status === "warning" ? "mono warning" : "mono";
505
+ return [span(`${Math.max(0, Math.round((1 - fraction) * 100))}%`, token), span(" left", "dim")];
506
+ }
507
+
476
508
  /** Stable, human account name for a report in the detail view. */
477
509
  function reportAccountLabel(report: UsageReport, limit: UsageLimit | undefined, index: number): string {
478
510
  const metadata = report.metadata;
@@ -499,6 +531,81 @@ function detailWindowLabel(label: string, limit: UsageLimit): string | undefined
499
531
  const WHOLE_DOLLARS = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
500
532
  const COMPACT_COUNT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
501
533
 
534
+ /** `$1,234`, `$759`, `$84.20`, `$0.13`: cents only below $100. */
535
+ function formatCost(cost: number): string {
536
+ if (cost <= 0) return "$0";
537
+ return cost >= 100 ? `$${WHOLE_DOLLARS.format(cost)}` : `$${cost.toFixed(2)}`;
538
+ }
539
+
540
+ function requestsText(requests: number): string {
541
+ return `${COMPACT_COUNT.format(requests)} request${requests === 1 ? "" : "s"}`;
542
+ }
543
+
544
+ /** Days the spend spark and the "Last 30 days" figure cover. */
545
+ const MONTH_DAYS = 30;
546
+
547
+ interface ActivityDay {
548
+ date: Date;
549
+ cost: number;
550
+ requests: number;
551
+ }
552
+
553
+ /** Headline figures of the activity history behind the summary strip and the calendar's foot. */
554
+ interface ActivityFigures {
555
+ /** Whether any day in range carries a price (else figures fall back to request counts). */
556
+ priced: boolean;
557
+ /** The last {@link MONTH_DAYS} days, oldest first. */
558
+ month: ActivityDay[];
559
+ monthCost: number;
560
+ monthRequests: number;
561
+ busiest?: ActivityDay;
562
+ activeDays: number;
563
+ longestStreak: number;
564
+ }
565
+
566
+ /** Derive the headline figures from the daily points over the heatmap's range. */
567
+ function activityFigures(
568
+ points: readonly DailyActivityPoint[],
569
+ layout: HeatmapLayout,
570
+ today = new Date(),
571
+ ): ActivityFigures {
572
+ const byDay = new Map(points.map(point => [point.day, point]));
573
+ const today0 = new Date(today.getFullYear(), today.getMonth(), today.getDate());
574
+ const priced = layout.totalCost > 0;
575
+ const weight = (day: ActivityDay): number => (priced ? day.cost : day.requests);
576
+ const month: ActivityDay[] = [];
577
+ for (let back = MONTH_DAYS - 1; back >= 0; back--) {
578
+ const date = addDays(today0, -back);
579
+ const point = byDay.get(localIso(date));
580
+ month.push({ date, cost: point?.cost ?? 0, requests: point?.requests ?? 0 });
581
+ }
582
+ let busiest: ActivityDay | undefined;
583
+ let activeDays = 0;
584
+ let streak = 0;
585
+ let longestStreak = 0;
586
+ for (let date = layout.start; date <= today0; date = addDays(date, 1)) {
587
+ const point = byDay.get(localIso(date));
588
+ if (!point || (point.cost <= 0 && point.requests <= 0)) {
589
+ streak = 0;
590
+ continue;
591
+ }
592
+ activeDays++;
593
+ streak++;
594
+ longestStreak = Math.max(longestStreak, streak);
595
+ const day = { date, cost: point.cost, requests: point.requests };
596
+ if (!busiest || weight(day) > weight(busiest)) busiest = day;
597
+ }
598
+ return {
599
+ priced,
600
+ month,
601
+ monthCost: month.reduce((sum, day) => sum + day.cost, 0),
602
+ monthRequests: month.reduce((sum, day) => sum + day.requests, 0),
603
+ busiest,
604
+ activeDays,
605
+ longestStreak,
606
+ };
607
+ }
608
+
502
609
  /** `$1,234 · 5.6K requests` totals for the activity summary. */
503
610
  function formatActivityTotals(layout: HeatmapLayout): string {
504
611
  const cost =
@@ -962,12 +1069,12 @@ export class UsageDashboardComponent implements Component {
962
1069
  // ---------------------------------------------------------------------------
963
1070
 
964
1071
  /**
965
- * The sheet body (the terminal's `lg` overlay is the frame): a head row
966
- * (checked ago, Overview/Details tabs, Refresh), then either the provider
967
- * grid of frames with window meters and the activity heatmap, or the
968
- * per-provider detail tables, then Close like the other report sheets.
969
- * `meter`/`chart` fall back to `progress`/`table` on terminals without
970
- * them. Rebuilt only when its inputs change.
1072
+ * The sheet body (the terminal's `lg` overlay is the frame): a toolbar
1073
+ * (Overview/Details, freshness, Refresh), then either the overview — a
1074
+ * strip of headline figures, the quota rows per provider, the activity
1075
+ * calendar — or the per-provider detail tables, then Close like the other
1076
+ * report sheets. `meter`/`chart` fall back to `progress`/`table` on
1077
+ * terminals without them. Rebuilt only when its inputs change.
971
1078
  */
972
1079
  describe(cx: DescribeContext): NativeNode {
973
1080
  const meter = cx.supports("meter");
@@ -979,9 +1086,14 @@ export class UsageDashboardComponent implements Component {
979
1086
  const body = this.#view === "detail" ? this.#describeDetail() : this.#describeOverview(meter, chart);
980
1087
  // Esc leaves Details for Overview first, so only Overview's Close wears its keycap.
981
1088
  const close = actionButton("Close", "close", this.#view === "overview" ? { keys: "escape" } : {});
982
- const root = col([this.#describeHead(), node("col", { gap: "lg" }, body, this.#view), actionBar([null, close])], {
983
- gap: "lg",
984
- });
1089
+ const root = col(
1090
+ [
1091
+ this.#describeHead(),
1092
+ node("col", { role: "zeta.usage.body", gap: "lg" }, body, this.#view),
1093
+ actionBar([null, close]),
1094
+ ],
1095
+ { gap: "lg" },
1096
+ );
985
1097
  this.#nativeCache = { revision: this.#revision, meter, chart, node: root };
986
1098
  return root;
987
1099
  }
@@ -1000,27 +1112,27 @@ export class UsageDashboardComponent implements Component {
1000
1112
  }
1001
1113
  }
1002
1114
 
1115
+ /** The toolbar: the two views as a segmented control, then how fresh the reports are and Refresh. */
1003
1116
  #describeHead(): NativeNode {
1004
1117
  const checked: NativeChild[] = [];
1005
1118
  const latestFetchedAt = Math.max(0, ...this.#reports.map(report => report.fetchedAt ?? 0));
1006
1119
  if (this.#refreshing) {
1007
- checked.push(node("spinner", { label: [span("Refreshing…", "dim")] }));
1120
+ checked.push(node("spinner", { style: "dots", label: [span("Refreshing…", "dim")] }));
1008
1121
  } else if (this.#refreshError) {
1009
1122
  checked.push(
1010
1123
  text([span(`Refresh failed: ${sanitizeDisplayLine(this.#refreshError)}`, "warning")], {
1011
1124
  truncate: "end",
1125
+ title: sanitizeDisplayLine(this.#refreshError),
1012
1126
  }),
1013
1127
  );
1014
1128
  } else if (latestFetchedAt) {
1015
1129
  checked.push(
1016
- text([span("checked", "dim")]),
1130
+ text([span("Updated", "dim")]),
1017
1131
  elapsed(Date.now() - latestFetchedAt),
1018
1132
  text([span("ago", "dim")]),
1019
1133
  );
1020
1134
  }
1021
1135
  const children: NativeChild[] = [
1022
- node("row", { gap: "xs", align: "baseline", shrink: 1 }, checked, "checked"),
1023
- node("spacer", { grow: 1 }),
1024
1136
  node(
1025
1137
  "tabs",
1026
1138
  {
@@ -1033,88 +1145,244 @@ export class UsageDashboardComponent implements Component {
1033
1145
  undefined,
1034
1146
  "tabs",
1035
1147
  ),
1148
+ node("spacer", { grow: 1 }),
1149
+ node("row", { gap: "xs", align: "baseline", shrink: 1 }, checked, "checked"),
1036
1150
  ];
1037
1151
  if (this.#options.refresh) children.push(actionButton("Refresh", "refresh", { keys: "r" }));
1038
- return node("row", { role: "zeta.usage.head", gap: "sm", align: "center" }, children, "head");
1152
+ return node("row", { role: "zeta.usage.head", gap: "md", align: "center" }, children, "head");
1039
1153
  }
1040
1154
 
1155
+ /**
1156
+ * The overview: headline figures, then a row per provider with something
1157
+ * to show (the rest named in footnote rows), then the activity calendar.
1158
+ * With nothing used anywhere it is one empty state instead.
1159
+ */
1041
1160
  #describeOverview(meter: boolean, chart: boolean): NativeChild[] {
1042
- const children: NativeChild[] = [];
1161
+ // Only providers with something to show get a row: the rest are named
1162
+ // in footnotes, whether untouched, unreadable or quota-less.
1163
+ const framed: ProviderCard[] = [];
1164
+ const untouched: ProviderCard[] = [];
1165
+ const unreported: ProviderCard[] = [];
1166
+ const unmetered: ProviderCard[] = [];
1167
+ for (const entry of this.#cards) {
1168
+ const shown =
1169
+ entry.windows.length > 0 || entry.resetCredits !== undefined || entry.daybreakAccounts !== undefined;
1170
+ if (!shown) (entry.unavailableAccounts.length > 0 ? unreported : unmetered).push(entry);
1171
+ else if (entry.idle) untouched.push(entry);
1172
+ else framed.push(entry);
1173
+ }
1174
+ const notes: NativeNode[] = [];
1175
+ if (untouched.length > 0) notes.push(footnote("idle", "Untouched", untouched));
1176
+ if (unreported.length > 0) {
1177
+ const accounts = unreported.flatMap(entry =>
1178
+ entry.unavailableAccounts.map(account => `${entry.name} (${account})`),
1179
+ );
1180
+ notes.push(
1181
+ footnote(
1182
+ "unreported",
1183
+ "No usage data",
1184
+ unreported,
1185
+ `No usage report came back for ${accounts.join(", ")}: the sign-in expired, the request failed, or the plan has no quotas`,
1186
+ ),
1187
+ );
1188
+ }
1189
+ if (unmetered.length > 0) {
1190
+ const why = unmetered.flatMap(entry => entry.notes.map(note => `${entry.name}: ${note}`));
1191
+ notes.push(
1192
+ footnote(
1193
+ "unmetered",
1194
+ "No quotas reported",
1195
+ unmetered,
1196
+ why.length > 0 ? why.join(" • ") : "These providers report no quota windows to track",
1197
+ ),
1198
+ );
1199
+ }
1200
+
1201
+ const points = this.#activity;
1202
+ const layout = points ? buildHeatmapLayout(points, NATIVE_HEATMAP_WEEKS) : undefined;
1203
+ const figures = points && layout ? activityFigures(points, layout) : undefined;
1204
+ const quiet = layout !== undefined && layout.totalCost <= 0 && layout.totalRequests <= 0;
1205
+ if (framed.length === 0 && quiet && !this.#activityError) {
1206
+ const children: NativeChild[] = [
1207
+ node("icon", { name: "gauge" }, undefined, "icon"),
1208
+ text([span("No usage yet")], { role: "zeta.usage.empty.title" }),
1209
+ text(
1210
+ [
1211
+ span(
1212
+ "Quotas show here once a signed-in provider reports usage, and the calendar fills in as you work.",
1213
+ "muted",
1214
+ ),
1215
+ ],
1216
+ { role: "zeta.usage.empty.body", wrap: "word" },
1217
+ ),
1218
+ ];
1219
+ if (notes.length > 0) children.push(node("col", { gap: "xs" }, notes, "footnotes"));
1220
+ return [node("col", { role: "zeta.usage.empty", gap: "sm", align: "center" }, children, "empty")];
1221
+ }
1222
+
1223
+ const quotas: NativeChild[] = [sectionHead("Quotas", this.#quotaMeta(framed))];
1224
+ for (const entry of framed) quotas.push(this.#describeProvider(entry, meter));
1043
1225
  if (this.#cards.length === 0) {
1044
- children.push(
1045
- node("text", { spans: [span("No usage data available.")], role: "zeta.usage.footnote" }, undefined, "none"),
1226
+ quotas.push(text([span("No usage data available.", "muted")], { role: "zeta.usage.none", wrap: "word" }));
1227
+ } else if (framed.length === 0) {
1228
+ quotas.push(
1229
+ text([span("Nothing used on any quota yet.", "muted")], { role: "zeta.usage.none", wrap: "word" }),
1046
1230
  );
1047
- } else {
1048
- // Only providers with something to show get a frame: the rest are
1049
- // named in footnotes, whether untouched, unreadable or quota-less.
1050
- const framed: ProviderCard[] = [];
1051
- const untouched: ProviderCard[] = [];
1052
- const unreported: ProviderCard[] = [];
1053
- const unmetered: ProviderCard[] = [];
1054
- for (const entry of this.#cards) {
1055
- const shown =
1056
- entry.windows.length > 0 || entry.resetCredits !== undefined || entry.daybreakAccounts !== undefined;
1057
- if (!shown) (entry.unavailableAccounts.length > 0 ? unreported : unmetered).push(entry);
1058
- else if (entry.idle) untouched.push(entry);
1059
- else framed.push(entry);
1060
- }
1061
- if (framed.length > 0) {
1062
- children.push(
1063
- node(
1064
- "row",
1065
- { wrap: true, gap: "md", role: "zeta.usage.grid" },
1066
- framed.map(entry => this.#describeCard(entry, meter)),
1067
- "providers",
1068
- ),
1069
- );
1070
- }
1071
- const notes: NativeNode[] = [];
1072
- if (untouched.length > 0) notes.push(footnote("idle", "Untouched", untouched));
1073
- if (unreported.length > 0) {
1074
- const accounts = unreported.flatMap(entry =>
1075
- entry.unavailableAccounts.map(account => `${entry.name} (${account})`),
1076
- );
1077
- notes.push(
1078
- footnote(
1079
- "unreported",
1080
- "No usage data",
1081
- unreported,
1082
- `No usage report came back for ${accounts.join(", ")}: the sign-in expired, the request failed, or the plan has no quotas`,
1083
- ),
1084
- );
1085
- }
1086
- if (unmetered.length > 0) {
1087
- const why = unmetered.flatMap(entry => entry.notes.map(note => `${entry.name}: ${note}`));
1088
- notes.push(
1089
- footnote(
1090
- "unmetered",
1091
- "No quotas reported",
1092
- unmetered,
1093
- why.length > 0 ? why.join(" • ") : "These providers report no quota windows to track",
1094
- ),
1095
- );
1231
+ }
1232
+ quotas.push(...notes);
1233
+ return [
1234
+ this.#describeStats(framed, layout, figures, chart),
1235
+ node("col", { role: "zeta.usage.quotas" }, quotas, "quotas"),
1236
+ this.#describeActivity(chart, layout, figures),
1237
+ ];
1238
+ }
1239
+
1240
+ /** `3 providers · 10 accounts` over the quota rows. */
1241
+ #quotaMeta(framed: readonly ProviderCard[]): NativeChild[] {
1242
+ if (framed.length === 0) return [];
1243
+ const accounts = framed.reduce((sum, entry) => sum + entry.accounts, 0);
1244
+ const parts = [`${framed.length} provider${framed.length === 1 ? "" : "s"}`];
1245
+ if (accounts > framed.length) parts.push(`${accounts} accounts`);
1246
+ return [
1247
+ text([span(parts.join(" · "), "dim")], {
1248
+ role: "zeta.usage.section.meta",
1249
+ title: "Each quota shows the mean across a provider's accounts; it resets with the fullest one",
1250
+ }),
1251
+ ];
1252
+ }
1253
+
1254
+ /**
1255
+ * The summary strip: the tightest quota (what stops work first), the
1256
+ * year's spend, the last 30 days with a spend spark, and the busiest day.
1257
+ */
1258
+ #describeStats(
1259
+ framed: readonly ProviderCard[],
1260
+ layout: HeatmapLayout | undefined,
1261
+ figures: ActivityFigures | undefined,
1262
+ chart: boolean,
1263
+ ): NativeNode {
1264
+ const tiles: NativeNode[] = [];
1265
+ let tightest: { entry: ProviderCard; window: CardWindowRow; fraction: number } | undefined;
1266
+ for (const entry of framed) {
1267
+ for (const window of entry.windows) {
1268
+ if (window.fraction === undefined) continue;
1269
+ if (!tightest || window.fraction > tightest.fraction)
1270
+ tightest = { entry, window, fraction: window.fraction };
1096
1271
  }
1097
- if (notes.length > 0) children.push(node("col", { gap: "xs" }, notes, "footnotes"));
1098
1272
  }
1099
- children.push(this.#describeActivity(chart));
1100
- return children;
1273
+ if (tightest) {
1274
+ const where = `${sanitizeDisplayLine(tightest.window.label)} · ${tightest.entry.name}`;
1275
+ const reset =
1276
+ tightest.window.resetMs !== undefined ? resetLabel(this.#nowMs, tightest.window.resetMs) : undefined;
1277
+ tiles.push(
1278
+ statTile(
1279
+ "quota",
1280
+ "Tightest quota",
1281
+ leftSpans(tightest.fraction, tightest.window.status),
1282
+ [span(where, "dim")],
1283
+ {
1284
+ title: reset ? `${where} · ${reset.title}` : where,
1285
+ },
1286
+ ),
1287
+ );
1288
+ } else {
1289
+ tiles.push(
1290
+ statTile(
1291
+ "quota",
1292
+ "Tightest quota",
1293
+ [span("—", "dim")],
1294
+ [span(this.#cards.length > 0 ? "Every quota untouched" : "No quotas reported", "dim")],
1295
+ ),
1296
+ );
1297
+ }
1298
+
1299
+ if (!figures || !layout) {
1300
+ const sub = [span(this.#activityError ? "History unavailable" : "Loading…", "dim")];
1301
+ tiles.push(
1302
+ statTile("year", "Past year", [span("—", "dim")], sub),
1303
+ statTile("month", "Last 30 days", [span("—", "dim")], sub),
1304
+ statTile("busiest", "Busiest day", [span("—", "dim")], sub),
1305
+ );
1306
+ return node("row", { role: "zeta.usage.stats", wrap: true }, tiles, "stats");
1307
+ }
1308
+
1309
+ const amount = (cost: number, requests: number): TspSpan[] =>
1310
+ figures.priced ? [span(formatCost(cost))] : [span(COMPACT_COUNT.format(requests))];
1311
+ const counted = (requests: number): string =>
1312
+ figures.priced ? requestsText(requests) : `request${requests === 1 ? "" : "s"}`;
1313
+ tiles.push(
1314
+ statTile("year", "Past year", amount(layout.totalCost, layout.totalRequests), [
1315
+ span(counted(layout.totalRequests), "dim"),
1316
+ ]),
1317
+ );
1318
+
1319
+ const spark =
1320
+ chart && (figures.monthCost > 0 || figures.monthRequests > 0)
1321
+ ? node(
1322
+ "chart",
1323
+ {
1324
+ kind: "spark",
1325
+ token: "accent",
1326
+ series: figures.month.map(day => ({
1327
+ value: figures.priced ? day.cost : day.requests,
1328
+ title: `${shortDate(day.date)} · ${formatCost(day.cost)} · ${requestsText(day.requests)}`,
1329
+ })),
1330
+ },
1331
+ undefined,
1332
+ "spark",
1333
+ )
1334
+ : undefined;
1335
+ tiles.push(
1336
+ statTile(
1337
+ "month",
1338
+ "Last 30 days",
1339
+ amount(figures.monthCost, figures.monthRequests),
1340
+ [span(counted(figures.monthRequests), "dim")],
1341
+ spark ? { chart: spark } : {},
1342
+ ),
1343
+ );
1344
+
1345
+ const busiest = figures.busiest;
1346
+ tiles.push(
1347
+ busiest
1348
+ ? statTile("busiest", "Busiest day", amount(busiest.cost, busiest.requests), [
1349
+ span(
1350
+ figures.priced
1351
+ ? `${shortDate(busiest.date)} · ${requestsText(busiest.requests)}`
1352
+ : `requests · ${shortDate(busiest.date)}`,
1353
+ "dim",
1354
+ ),
1355
+ ])
1356
+ : statTile("busiest", "Busiest day", [span("—", "dim")], [span("No activity yet", "dim")]),
1357
+ );
1358
+ return node("row", { role: "zeta.usage.stats", wrap: true }, tiles, "stats");
1101
1359
  }
1102
1360
 
1103
- #describeCard(entry: ProviderCard, meter: boolean): NativeNode {
1361
+ /**
1362
+ * One provider: its name, status and accounts in the left column, one row
1363
+ * per quota window on the right (label · meter · left · reset), aligned
1364
+ * with every other provider's.
1365
+ */
1366
+ #describeProvider(entry: ProviderCard, meter: boolean): NativeNode {
1104
1367
  const cardStatus =
1105
1368
  entry.unavailableAccounts.length > 0
1106
1369
  ? statusWithUnavailableAccounts(entry.windows)
1107
1370
  : entry.unlimited
1108
1371
  ? "ok"
1109
1372
  : aggregateStatus(entry.windows);
1110
- const head: NativeChild[] = [text([span(entry.name, "strong")], { truncate: "end" })];
1111
- if (entry.accounts > 1) head.push(text([span(`${entry.accounts} accounts`, "muted")]));
1112
- head.push(node("spacer", { grow: 1 }), statusDot(cardStatus));
1113
- const children: NativeChild[] = [
1114
- node("row", { role: "zeta.usage.provider.head", gap: "sm", align: "center" }, head, "title"),
1373
+ const who: NativeChild[] = [
1374
+ node(
1375
+ "row",
1376
+ { gap: "sm", align: "center" },
1377
+ [
1378
+ statusDot(cardStatus),
1379
+ text([span(entry.name)], { role: "zeta.usage.provider.name", truncate: "end", title: entry.name }),
1380
+ ],
1381
+ "name",
1382
+ ),
1115
1383
  ];
1116
- for (const account of entry.daybreakAccounts ?? []) {
1117
- children.push(text([span(`Daybreak · ${sanitizeDisplayLine(account)}`, "success")], { truncate: "end" }));
1384
+ if (entry.accounts > 1) {
1385
+ who.push(text([span(`${entry.accounts} accounts`, "dim")], { role: "zeta.usage.provider.meta" }));
1118
1386
  }
1119
1387
  if (entry.resetCredits) {
1120
1388
  const resets = entry.resetCredits;
@@ -1129,83 +1397,155 @@ export class UsageDashboardComponent implements Component {
1129
1397
  details.push(`unavailable: ${sanitizeDisplayLine(resets.unavailableReasons.join(" • "))}`);
1130
1398
  }
1131
1399
  const label = `${resets.bankedCount} reset${resets.bankedCount === 1 ? "" : "s"} banked`;
1132
- children.push(
1133
- node(
1134
- "row",
1135
- { gap: "sm", align: "center" },
1136
- [
1137
- node("badge", {
1138
- text: label,
1139
- tone: resets.redeemableCount > 0 ? "success" : "warning",
1140
- title: details.length > 0 ? `${label} · ${details.join(" · ")}` : label,
1141
- }),
1142
- ],
1143
- "resets",
1144
- ),
1400
+ who.push(
1401
+ text([span(label, resets.redeemableCount > 0 ? "success" : "warning")], {
1402
+ role: "zeta.usage.provider.meta",
1403
+ title: details.length > 0 ? `${label} · ${details.join(" · ")}` : label,
1404
+ }),
1405
+ );
1406
+ }
1407
+ for (const account of entry.daybreakAccounts ?? []) {
1408
+ const name = sanitizeDisplayLine(account);
1409
+ who.push(
1410
+ text([span(`Daybreak · ${name}`, "success")], {
1411
+ role: "zeta.usage.provider.meta",
1412
+ truncate: "middle",
1413
+ title: `Daybreak access · ${name}`,
1414
+ }),
1145
1415
  );
1146
1416
  }
1147
1417
  for (const account of entry.unavailableAccounts) {
1148
- children.push(mutedText(`${sanitizeDisplayLine(account)}: no usage data`, true));
1418
+ const name = sanitizeDisplayLine(account);
1419
+ who.push(
1420
+ text([span(`${name}: no usage data`, "warning")], {
1421
+ role: "zeta.usage.provider.meta",
1422
+ truncate: "middle",
1423
+ title: `${name}: no usage data`,
1424
+ }),
1425
+ );
1149
1426
  }
1427
+
1428
+ const rows: NativeChild[] = [];
1150
1429
  if (entry.unlimited) {
1151
- children.push(mutedText("No quotas reported"));
1430
+ rows.push(text([span("No quotas reported", "muted")], { role: "zeta.usage.none" }));
1152
1431
  } else {
1153
1432
  for (const [index, window] of entry.windows.slice(0, CARD_MAX_WINDOWS).entries()) {
1154
1433
  const label: TspSpan[] = [span(sanitizeDisplayLine(window.label))];
1155
1434
  if (window.windowTag) label.push(span(` ${sanitizeDisplayLine(window.windowTag)}`, "dim"));
1156
1435
  const cells: NativeChild[] = [
1157
- text(label, { role: "zeta.usage.label", truncate: "middle", title: sanitizeDisplayLine(window.label) }),
1436
+ text(label, { role: "zeta.usage.label", truncate: "end", title: sanitizeDisplayLine(window.label) }),
1158
1437
  ];
1159
1438
  if (window.fraction === undefined) {
1160
1439
  cells.push(
1161
1440
  text([span(window.usedText ?? "No data", "muted")], { role: "zeta.usage.amount", truncate: "end" }),
1162
1441
  );
1163
1442
  } else {
1164
- const token =
1165
- window.status === "exhausted" ? "error" : window.status === "warning" ? "warning" : undefined;
1166
1443
  cells.push(
1167
1444
  usageMeter(window.fraction, window.status, meter),
1168
- text([span(leftText(window.fraction), token)], { role: "zeta.usage.pct" }),
1445
+ text(leftSpans(window.fraction, window.status), { role: "zeta.usage.pct" }),
1169
1446
  );
1170
1447
  if (window.resetMs !== undefined) {
1171
1448
  const reset = resetLabel(this.#nowMs, window.resetMs);
1172
1449
  cells.push(text([span(reset.text, "dim")], { role: "zeta.usage.reset", title: reset.title }));
1173
1450
  }
1174
1451
  }
1175
- children.push(node("row", { role: "zeta.usage.window", gap: "sm", align: "center" }, cells, `w${index}`));
1452
+ rows.push(node("row", { role: "zeta.usage.window", gap: "md", align: "center" }, cells, `w${index}`));
1176
1453
  }
1177
1454
  const hidden = entry.windows.length - CARD_MAX_WINDOWS;
1178
- if (hidden > 0) children.push(mutedText(`+${hidden} more`));
1455
+ if (hidden > 0) {
1456
+ rows.push(
1457
+ text([span(`${hidden} more in Details`, "dim")], {
1458
+ role: "zeta.usage.more",
1459
+ title: "Details lists every quota per account ⏎",
1460
+ }),
1461
+ );
1462
+ }
1179
1463
  }
1180
1464
  return node(
1181
- "card",
1182
- { role: "zeta.usage.provider", grow: 1, min: { w: `${CARD_MIN_WIDTH}ch` } },
1183
- children,
1465
+ "row",
1466
+ { role: "zeta.usage.provider", gap: "lg", align: "start" },
1467
+ [
1468
+ node("col", { role: "zeta.usage.provider.head", gap: "xs" }, who, "who"),
1469
+ node("col", { role: "zeta.usage.windows", grow: 1 }, rows, "windows"),
1470
+ ],
1184
1471
  entry.provider,
1185
1472
  );
1186
1473
  }
1187
1474
 
1188
- #describeActivity(chart: boolean): NativeNode {
1189
- const head: NativeChild[] = [text([span("Activity", "strong")])];
1190
- if (this.#syncing && this.#activity) head.push(node("spinner", { label: [span("Syncing", "dim")] }));
1191
- const children: NativeChild[] = [node("row", { gap: "sm", align: "center" }, head, "head")];
1475
+ /**
1476
+ * The activity calendar: a year of daily spend as a heatmap (per-day
1477
+ * tooltips), then active days and the longest streak beside a Less–More
1478
+ * legend; loading, failure and a quiet year each say so in its place.
1479
+ */
1480
+ #describeActivity(
1481
+ chart: boolean,
1482
+ layout: HeatmapLayout | undefined,
1483
+ figures: ActivityFigures | undefined,
1484
+ ): NativeNode {
1485
+ const meta: NativeChild[] = [];
1486
+ if (this.#syncing && this.#activity) {
1487
+ meta.push(node("spinner", { style: "dots", label: [span("Syncing", "dim")] }));
1488
+ } else if (layout) {
1489
+ const today = new Date();
1490
+ const range = `${layout.start.getDate()} ${MONTH_NAMES[layout.start.getMonth()]} ${layout.start.getFullYear()} – ${today.getDate()} ${MONTH_NAMES[today.getMonth()]} ${today.getFullYear()}`;
1491
+ meta.push(text([span(range, "dim")], { role: "zeta.usage.section.meta" }));
1492
+ }
1493
+ const children: NativeChild[] = [sectionHead("Activity", meta)];
1192
1494
  const points = this.#activity;
1193
1495
  if (this.#activityError) {
1194
1496
  const detail = formatActivityErrorDetail(this.#activityError);
1195
1497
  children.push(
1196
- mutedText(detail ? `Usage history unavailable (${detail}).` : "Usage history unavailable.", true),
1498
+ text([span(detail ? `Usage history unavailable (${detail}).` : "Usage history unavailable.", "muted")], {
1499
+ role: "zeta.usage.none",
1500
+ wrap: "word",
1501
+ }),
1502
+ );
1503
+ } else if (!points || !layout || !figures) {
1504
+ children.push(
1505
+ node("spinner", { style: "dots", label: [span("Loading usage history…", "muted")] }, undefined, "loading"),
1197
1506
  );
1198
- } else if (!points) {
1199
- children.push(node("spinner", { label: [span("Loading usage history…", "muted")] }));
1200
1507
  } else {
1201
- const layout = buildHeatmapLayout(points, NATIVE_HEATMAP_WEEKS);
1202
1508
  const summary =
1203
1509
  layout.totalRequests > 0 || layout.totalCost > 0
1204
1510
  ? `${formatActivityTotals(layout)} · last ${NATIVE_HEATMAP_WEEKS} weeks`
1205
1511
  : `No activity in the last ${NATIVE_HEATMAP_WEEKS} weeks`;
1206
1512
  children.push(chart ? this.#heatmapChart(layout, points, summary) : this.#heatmapTable(layout, summary));
1513
+ const foot: NativeChild[] = [
1514
+ text(
1515
+ [
1516
+ span(
1517
+ figures.activeDays > 0
1518
+ ? `${figures.activeDays} active day${figures.activeDays === 1 ? "" : "s"} · longest streak ${figures.longestStreak} day${figures.longestStreak === 1 ? "" : "s"}`
1519
+ : `No activity in the last ${NATIVE_HEATMAP_WEEKS} weeks`,
1520
+ "dim",
1521
+ ),
1522
+ ],
1523
+ { truncate: "end" },
1524
+ ),
1525
+ ];
1526
+ if (chart) {
1527
+ foot.push(
1528
+ node("spacer", { grow: 1 }),
1529
+ node(
1530
+ "row",
1531
+ { role: "zeta.usage.legend", gap: "sm", align: "center" },
1532
+ [
1533
+ text([span("Less", "dim")]),
1534
+ node(
1535
+ "chart",
1536
+ { kind: "heatmap", cells: [[0, 0.25, 0.5, 0.75, 1]], token: "accent", size: "md" },
1537
+ undefined,
1538
+ "scale",
1539
+ ),
1540
+ text([span("More", "dim")]),
1541
+ ],
1542
+ "legend",
1543
+ ),
1544
+ );
1545
+ }
1546
+ children.push(node("row", { role: "zeta.usage.activity.foot", gap: "md", align: "center" }, foot, "foot"));
1207
1547
  }
1208
- return node("col", { role: "zeta.usage.activity", gap: "sm" }, children, "activity");
1548
+ return node("col", { role: "zeta.usage.activity" }, children, "activity");
1209
1549
  }
1210
1550
 
1211
1551
  /** The heatmap as a `chart`: 0–1 intensities, month columns, M/W/F rows, per-day tooltips. */
@@ -1260,7 +1600,11 @@ export class UsageDashboardComponent implements Component {
1260
1600
  );
1261
1601
  }
1262
1602
 
1263
- /** Per provider: notes and saved resets as a `kv`, the limit buckets as a `table` (one row per account). */
1603
+ /**
1604
+ * Per provider: a head like the overview's provider column, notes and
1605
+ * saved resets as a `kv`, the limit buckets as a `table` (one row per
1606
+ * account).
1607
+ */
1264
1608
  #describeDetail(): NativeChild[] {
1265
1609
  const nowMs = this.#nowMs;
1266
1610
  const grouped = new Map<string, UsageReport[]>();
@@ -1273,7 +1617,22 @@ export class UsageDashboardComponent implements Component {
1273
1617
  const sections: NativeChild[] = [];
1274
1618
  for (const entry of this.#cards) {
1275
1619
  const reports = grouped.get(entry.provider) ?? [];
1276
- const children: NativeChild[] = [];
1620
+ const cardStatus =
1621
+ entry.unavailableAccounts.length > 0
1622
+ ? statusWithUnavailableAccounts(entry.windows)
1623
+ : entry.unlimited
1624
+ ? "ok"
1625
+ : aggregateStatus(entry.windows);
1626
+ const head: NativeChild[] = [
1627
+ statusDot(cardStatus),
1628
+ text([span(entry.name)], { role: "zeta.usage.provider.name", truncate: "end" }),
1629
+ ];
1630
+ if (entry.accounts > 1) {
1631
+ head.push(text([span(`${entry.accounts} accounts`, "dim")], { role: "zeta.usage.provider.meta" }));
1632
+ }
1633
+ const children: NativeChild[] = [
1634
+ node("row", { role: "zeta.usage.report.head", gap: "sm", align: "baseline" }, head, "head"),
1635
+ ];
1277
1636
  for (const account of unavailable) {
1278
1637
  if (account.provider !== entry.provider) continue;
1279
1638
  children.push(mutedText(`${sanitizeDisplayLine(account.label)}: no usage data`, true));
@@ -1312,7 +1671,9 @@ export class UsageDashboardComponent implements Component {
1312
1671
  v: value,
1313
1672
  });
1314
1673
  }
1315
- if (facts.length > 0) children.push(node("kv", { items: facts, layout: "grid" }, undefined, "facts"));
1674
+ if (facts.length > 0) {
1675
+ children.push(node("kv", { items: facts, layout: "grid", role: "zeta.usage.facts" }, undefined, "facts"));
1676
+ }
1316
1677
 
1317
1678
  const buckets = new Map<string, { label: string; window?: string; rows: Record<string, TspText>[] }>();
1318
1679
  const limitNotes: string[] = [];
@@ -1326,15 +1687,13 @@ export class UsageDashboardComponent implements Component {
1326
1687
  buckets.set(key, bucket);
1327
1688
  }
1328
1689
  const fraction = resolveUsedFraction(limit);
1329
- const token =
1330
- limit.status === "exhausted" ? "error" : limit.status === "warning" ? "warning" : undefined;
1331
1690
  const resetsAt = limit.window?.resetsAt;
1332
1691
  bucket.rows.push({
1333
1692
  account: [span(reportAccountLabel(report, limit, index), "muted")],
1334
1693
  left:
1335
1694
  fraction === undefined
1336
1695
  ? [span(formatAbsoluteOnlyAmount([limit]) ?? "No data", "muted")]
1337
- : [span(leftText(fraction), token)],
1696
+ : leftSpans(fraction, limit.status),
1338
1697
  reset:
1339
1698
  resetsAt !== undefined && resetsAt > nowMs
1340
1699
  ? [
@@ -1365,21 +1724,16 @@ export class UsageDashboardComponent implements Component {
1365
1724
  { id: "left", head: "Left", align: "end", priority: 3 },
1366
1725
  { id: "reset", head: "Resets", align: "end", priority: 2 },
1367
1726
  ];
1368
- children.push(node("table", { cols, rows }, undefined, "limits"));
1727
+ children.push(node("table", { cols, rows, role: "zeta.usage.limits" }, undefined, "limits"));
1369
1728
  for (const note of new Set(limitNotes)) children.push(mutedText(note, true));
1370
1729
  } else if (unavailable.every(account => account.provider !== entry.provider)) {
1371
1730
  children.push(mutedText("No quotas reported"));
1372
1731
  }
1373
- sections.push(
1374
- node(
1375
- "section",
1376
- { head: [span(entry.name, "strong")], role: "zeta.usage.report" },
1377
- [col(children, { gap: "sm" })],
1378
- entry.provider,
1379
- ),
1380
- );
1732
+ sections.push(node("col", { role: "zeta.usage.report", gap: "sm" }, children, entry.provider));
1733
+ }
1734
+ if (sections.length === 0) {
1735
+ sections.push(text([span("No usage data available.", "muted")], { role: "zeta.usage.none" }));
1381
1736
  }
1382
- if (sections.length === 0) sections.push(mutedText("No usage data available."));
1383
1737
  return sections;
1384
1738
  }
1385
1739