gentle-pi 3.6.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 (320) 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 +95 -30
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +117 -235
  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 +509 -275
  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 +105 -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/foreign-target-grants.ts +32 -0
  55. package/lib/gentle-ai-elapsed-store.ts +87 -0
  56. package/lib/gentle-ai-renderer.ts +196 -39
  57. package/lib/gentle-shell-launcher.ts +24 -13
  58. package/lib/gentle-shell-resume-hint.ts +176 -0
  59. package/lib/history-capture-policy.ts +95 -0
  60. package/lib/model-routing-authority.ts +5 -1
  61. package/lib/nan-provider.ts +227 -0
  62. package/lib/native-review-cli.ts +53 -108
  63. package/lib/odd-phase-inference.ts +231 -0
  64. package/lib/odd-phase.ts +141 -0
  65. package/lib/overlay-repaint.ts +26 -0
  66. package/lib/pi-tui-keys.ts +53 -0
  67. package/lib/review-candidate-view-owner.ts +67 -17
  68. package/lib/review-candidate-view.ts +112 -25
  69. package/lib/review-reminder-receipt.ts +48 -8
  70. package/lib/review-risk-assessment.ts +156 -11
  71. package/lib/review-sidebar-state.ts +223 -0
  72. package/lib/selection-engine.ts +515 -0
  73. package/lib/session-change-capture.ts +10 -1
  74. package/lib/session-messaging-grants.ts +135 -0
  75. package/lib/session-worktree-registry.ts +14 -2
  76. package/lib/shell-bar.ts +179 -24
  77. package/lib/shell-card.ts +287 -18
  78. package/lib/shell-changes-view.ts +2 -1
  79. package/lib/shell-prompt.ts +102 -6
  80. package/lib/shell-sidebar-layout.ts +78 -14
  81. package/lib/shell-sidebar.ts +15 -1
  82. package/lib/shell-todo.ts +23 -13
  83. package/lib/shell-usage-view.ts +9 -4
  84. package/lib/shell-usage.ts +66 -10
  85. package/lib/stats-collector.ts +381 -0
  86. package/lib/stats-view.ts +431 -0
  87. package/lib/theme-customization.ts +52 -0
  88. package/lib/vim-editor-adapter.ts +379 -0
  89. package/lib/vim-normal-engine.ts +154 -0
  90. package/lib/vim-operator-engine.ts +416 -0
  91. package/lib/vim-policy.ts +49 -0
  92. package/lib/vim-visual-engine.ts +107 -0
  93. package/lib/visual-customization-policy.ts +108 -0
  94. package/lib/visual-customize-view.ts +330 -0
  95. package/lib/visual-profiles.ts +228 -0
  96. package/lib/yolo-session-policy.ts +240 -0
  97. package/package.json +20 -8
  98. package/runtime/gentle-shell-launcher.mjs +23 -12
  99. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  100. package/runtime/native-review-cli.mjs +53 -108
  101. package/runtime/review-risk-assessment.mjs +154 -9
  102. package/scripts/build-runtime-modules.mjs +1 -0
  103. package/scripts/gentle-ai-installer.mjs +14 -13
  104. package/scripts/mirror-odd-routing.mjs +2 -2
  105. package/scripts/run-test-suite.mjs +76 -0
  106. package/scripts/test-packed-runner.mjs +31 -14
  107. package/scripts/verify-package-files.mjs +11 -22
  108. package/skills/branch-pr/SKILL.md +24 -52
  109. package/skills/chained-pr/SKILL.md +31 -15
  110. package/skills/chained-pr/references/chaining-details.md +31 -20
  111. package/skills/gentle-ai/SKILL.md +9 -15
  112. package/skills/issue-creation/SKILL.md +8 -2
  113. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  114. package/skills/work-unit-commits/SKILL.md +4 -3
  115. package/tests/agent-profiles.test.ts +18 -0
  116. package/tests/agents-fake-child.ts +2 -2
  117. package/tests/agents-message-delivery.test.ts +106 -0
  118. package/tests/agents-protocol.test.ts +40 -0
  119. package/tests/agents-runner.test.ts +400 -89
  120. package/tests/agents-view-thread-identity.test.ts +169 -0
  121. package/tests/agents-view.test.ts +8 -2
  122. package/tests/agents-widget.test.ts +154 -15
  123. package/tests/append-system-prompt-route.test.ts +160 -0
  124. package/tests/append-system-prompt.test.ts +46 -0
  125. package/tests/artifact-language.test.ts +19 -213
  126. package/tests/ask-user-question.test.ts +44 -1
  127. package/tests/asset-installation-runtime.test.ts +5 -16
  128. package/tests/autonomous-guard.test.ts +69 -1
  129. package/tests/bounded-writer-admission.test.ts +95 -0
  130. package/tests/branch-pr-skill.test.ts +43 -0
  131. package/tests/card-style-policy.test.ts +55 -0
  132. package/tests/chained-pr-skill.test.ts +124 -0
  133. package/tests/child-context-files.test.ts +255 -0
  134. package/tests/child-safety.test.ts +82 -0
  135. package/tests/codemode-rendering.test.ts +491 -0
  136. package/tests/command-palette.test.ts +39 -3
  137. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  138. package/tests/destructive-command-guard.test.ts +84 -0
  139. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  140. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  145. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  146. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  148. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  149. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  150. package/tests/foreign-target-grants.test.ts +58 -0
  151. package/tests/generic-agent-tools.test.ts +54 -0
  152. package/tests/gentle-agents.test.ts +1411 -256
  153. package/tests/gentle-ai-binary.test.ts +3 -3
  154. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  155. package/tests/gentle-ai-installer.test.ts +68 -54
  156. package/tests/gentle-ai-renderer.test.ts +487 -8
  157. package/tests/gentle-ai.test.ts +288 -65
  158. package/tests/gentle-card-text.ts +2 -1
  159. package/tests/gentle-shell-bin.test.ts +651 -114
  160. package/tests/gentle-shell-launcher.test.ts +99 -52
  161. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  162. package/tests/gentle-shell.test.ts +3839 -187
  163. package/tests/gentle-stats.test.ts +152 -0
  164. package/tests/gentle-theme.test.ts +4 -1
  165. package/tests/gentle-todo.test.ts +80 -8
  166. package/tests/history-atomic-write.test.ts +57 -0
  167. package/tests/history-capture-policy.test.ts +102 -0
  168. package/tests/history-command-registration.test.ts +164 -0
  169. package/tests/history-dedupe-entries.test.ts +123 -0
  170. package/tests/history-delete-backfill.test.ts +190 -0
  171. package/tests/history-delete-confirm.test.ts +460 -0
  172. package/tests/history-dispatch.test.ts +180 -0
  173. package/tests/history-drain-hidden.test.ts +110 -0
  174. package/tests/history-drain-order.test.ts +98 -0
  175. package/tests/history-expanded-globals.test.ts +62 -0
  176. package/tests/history-gc.test.ts +832 -0
  177. package/tests/history-header-layout.test.ts +265 -0
  178. package/tests/history-hide-prompts.test.ts +275 -0
  179. package/tests/history-lazy-windowing.test.ts +508 -0
  180. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  181. package/tests/history-load-shared-history.test.ts +53 -0
  182. package/tests/history-max-results-cap.test.ts +76 -0
  183. package/tests/history-multi-reader.test.ts +203 -0
  184. package/tests/history-off-path.test.ts +170 -0
  185. package/tests/history-openflow-integration.test.ts +173 -0
  186. package/tests/history-overlay-margin.test.ts +326 -0
  187. package/tests/history-preview-layout.test.ts +93 -0
  188. package/tests/history-registry.test.ts +143 -0
  189. package/tests/history-scope-delete.test.ts +411 -0
  190. package/tests/history-search-caret-keys.test.ts +142 -0
  191. package/tests/history-seed-bootstrap.test.ts +170 -0
  192. package/tests/history-seed-regen.test.ts +129 -0
  193. package/tests/history-selector-windowing.test.ts +94 -0
  194. package/tests/history-session-scan-directory.test.ts +87 -0
  195. package/tests/history-session-scan-extract.test.ts +583 -0
  196. package/tests/history-session-writer.test.ts +351 -0
  197. package/tests/history-store-paths.test.ts +79 -0
  198. package/tests/history-tombstone-exact.test.ts +139 -0
  199. package/tests/history-wheel-mouse.test.ts +242 -0
  200. package/tests/inprocess-reviewer.test.ts +29 -19
  201. package/tests/issue-creation-skill.test.ts +61 -0
  202. package/tests/model-routing-authority.test.ts +16 -0
  203. package/tests/nan-provider.test.ts +471 -0
  204. package/tests/native-review-capability-contract.test.ts +13 -1
  205. package/tests/native-review-cli.test.ts +6 -120
  206. package/tests/native-review-parity-runtime.test.ts +100 -3
  207. package/tests/odd-integration.test.ts +67 -0
  208. package/tests/odd-phase-inference.test.ts +213 -0
  209. package/tests/odd-phase-loader.test.ts +253 -0
  210. package/tests/odd-phase.test.ts +307 -0
  211. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  212. package/tests/odd-routing-contract.test.ts +86 -35
  213. package/tests/orchestrator-budget.test.ts +14 -39
  214. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  215. package/tests/overlay-repaint.test.ts +74 -0
  216. package/tests/package-manifest.test.ts +252 -115
  217. package/tests/packed-runner-owned-path.test.ts +46 -0
  218. package/tests/persona-single-channel.test.ts +6 -6
  219. package/tests/provider-defect-handoff.test.ts +3 -11
  220. package/tests/quiet-bash-runtime.test.ts +76 -0
  221. package/tests/quiet-tool-rendering.test.ts +409 -184
  222. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  223. package/tests/rdd-status-line.test.ts +9 -4
  224. package/tests/resume-hint-extension.test.ts +122 -0
  225. package/tests/review-agent-end-preflight.test.ts +176 -12
  226. package/tests/review-candidate-owner-retry.test.ts +22 -1
  227. package/tests/review-candidate-view.test.ts +298 -0
  228. package/tests/review-contract-prompt.test.ts +108 -43
  229. package/tests/review-controller-lock-status.test.ts +0 -1
  230. package/tests/review-controller-native-routing.test.ts +611 -5
  231. package/tests/review-controller-workspace-root.test.ts +163 -4
  232. package/tests/review-host-relay-routing.test.ts +338 -2
  233. package/tests/review-integration-v2-forward.test.ts +200 -0
  234. package/tests/review-ledger-contract.test.ts +10 -34
  235. package/tests/review-reminder-receipt.test.ts +47 -1
  236. package/tests/review-risk-assessment.test.ts +498 -6
  237. package/tests/review-sidebar-state.test.ts +402 -0
  238. package/tests/run-test-suite.test.ts +124 -0
  239. package/tests/runtime-harness.mjs +145 -786
  240. package/tests/runtime-metrics-children.test.ts +16 -23
  241. package/tests/selection-engine.test.ts +421 -0
  242. package/tests/session-change-capture.test.ts +12 -1
  243. package/tests/session-messaging-grants.test.ts +255 -0
  244. package/tests/session-worktree-registry.test.ts +77 -0
  245. package/tests/shell-bar.test.ts +382 -1
  246. package/tests/shell-card.test.ts +353 -1
  247. package/tests/shell-changes-view.test.ts +52 -0
  248. package/tests/shell-prompt.test.ts +94 -2
  249. package/tests/shell-sidebar-layout.test.ts +325 -21
  250. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  251. package/tests/shell-todo.test.ts +87 -1
  252. package/tests/shell-usage-view.test.ts +27 -0
  253. package/tests/shell-usage.test.ts +73 -0
  254. package/tests/skill-registry.test.ts +50 -1
  255. package/tests/startup-banner.test.ts +130 -2
  256. package/tests/stats-collector.test.ts +195 -0
  257. package/tests/stats-view.test.ts +202 -0
  258. package/tests/telemetry-trigger.test.ts +81 -20
  259. package/tests/theme-customization.test.ts +72 -0
  260. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  261. package/tests/vim-editor-adapter.test.ts +804 -0
  262. package/tests/vim-normal-engine.test.ts +101 -0
  263. package/tests/vim-operator-engine.test.ts +215 -0
  264. package/tests/vim-policy.test.ts +19 -0
  265. package/tests/vim-visual-engine.test.ts +52 -0
  266. package/tests/visual-customization-policy.test.ts +110 -0
  267. package/tests/visual-customize-view.test.ts +418 -0
  268. package/tests/visual-profiles.test.ts +87 -0
  269. package/tests/yolo-customize.test.ts +256 -0
  270. package/tests/yolo-mode-runtime.test.ts +161 -0
  271. package/tests/yolo-mode.test.ts +261 -0
  272. package/tests/yolo-session-policy.test.ts +59 -0
  273. package/themes/Gentle.json +2 -1
  274. package/themes/Gentleman-Cute.json +2 -1
  275. package/themes/Gentleman-Sexy.json +2 -1
  276. package/assets/agents/sdd-apply.md +0 -159
  277. package/assets/agents/sdd-archive.md +0 -228
  278. package/assets/agents/sdd-design.md +0 -49
  279. package/assets/agents/sdd-explore.md +0 -48
  280. package/assets/agents/sdd-init.md +0 -56
  281. package/assets/agents/sdd-onboard.md +0 -52
  282. package/assets/agents/sdd-proposal.md +0 -64
  283. package/assets/agents/sdd-remediate.md +0 -37
  284. package/assets/agents/sdd-research.md +0 -49
  285. package/assets/agents/sdd-spec.md +0 -192
  286. package/assets/agents/sdd-status.md +0 -54
  287. package/assets/agents/sdd-tasks.md +0 -108
  288. package/assets/agents/sdd-verify.md +0 -124
  289. package/assets/chains/sdd-full.chain.md +0 -83
  290. package/assets/chains/sdd-plan.chain.md +0 -56
  291. package/assets/chains/sdd-verify.chain.md +0 -43
  292. package/assets/sdd-orchestrator-workflow.md +0 -319
  293. package/assets/support/sdd-status-contract.md +0 -77
  294. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  295. package/extensions/sdd-init.ts +0 -816
  296. package/lib/openspec-deltas.ts +0 -156
  297. package/lib/sdd-preflight.ts +0 -1066
  298. package/lib/sdd-research-capabilities.ts +0 -94
  299. package/lib/sdd-status.ts +0 -26
  300. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  301. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  302. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  303. package/tests/openspec-deltas.test.ts +0 -209
  304. package/tests/sdd-agent-tools.test.ts +0 -156
  305. package/tests/sdd-archive-replay.test.ts +0 -82
  306. package/tests/sdd-classical-continuation.test.ts +0 -74
  307. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  308. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  309. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  310. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  311. package/tests/sdd-odd-integration.test.ts +0 -33
  312. package/tests/sdd-optional-research.test.ts +0 -124
  313. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  314. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  315. package/tests/sdd-preflight.test.ts +0 -541
  316. package/tests/sdd-research-capabilities.test.ts +0 -114
  317. package/tests/sdd-research-live.test.ts +0 -241
  318. package/tests/sdd-selection-transport.test.ts +0 -653
  319. package/tests/sdd-status.test.ts +0 -9
  320. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,431 @@
1
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import { formatElapsed } from "./agents-widget.ts";
3
+ import { formatCost, formatTokens } from "./shell-bar.ts";
4
+ import { paintHoverable } from "./shell-hover.ts";
5
+ import { aggregateStats, daysBetween, shiftDay, STATS_RANGE, STATS_SCOPE, weekdayOf, type CurrentSessionStats, type SessionRecord, type StatsRange, type StatsScope, type StatsSummary, type TokenTotals } from "./stats-collector.ts";
6
+
7
+ // Gentle Stats overlay: historical usage from local Pi sessions in three
8
+ // tabs. Overview is a weekday-by-week heatmap plus headline figures, Models
9
+ // splits tokens and cost per model, Session describes the live session. All
10
+ // colors come from theme roles so the panel follows the active Gentle theme.
11
+
12
+ export interface StatsViewTheme {
13
+ fg(role: string, text: string): string;
14
+ }
15
+
16
+ export interface StatsViewDeps {
17
+ theme: StatsViewTheme;
18
+ rows: number | (() => number);
19
+ cwd: string;
20
+ now(): number;
21
+ dayKey?: (ms: number) => string;
22
+ load(): Promise<readonly SessionRecord[]>;
23
+ current(): CurrentSessionStats;
24
+ onClose(): void;
25
+ requestRender(): void;
26
+ }
27
+
28
+ export const STATS_TAB = {
29
+ OVERVIEW: "overview",
30
+ MODELS: "models",
31
+ SESSION: "session",
32
+ } as const;
33
+ export type StatsTab = (typeof STATS_TAB)[keyof typeof STATS_TAB];
34
+
35
+ const TABS: ReadonlyArray<{ id: StatsTab; label: string }> = [
36
+ { id: STATS_TAB.OVERVIEW, label: "Overview" },
37
+ { id: STATS_TAB.MODELS, label: "Models" },
38
+ { id: STATS_TAB.SESSION, label: "Session" },
39
+ ];
40
+ const RANGES: readonly StatsRange[] = [STATS_RANGE.ALL, STATS_RANGE.WEEK, STATS_RANGE.MONTH];
41
+ const RANGE_LABEL: Record<StatsRange, string> = { [STATS_RANGE.ALL]: "All time", [STATS_RANGE.WEEK]: "Last 7 days", [STATS_RANGE.MONTH]: "Last 30 days" };
42
+ const SCOPE_LABEL: Record<StatsScope, string> = { [STATS_SCOPE.ALL]: "All projects", [STATS_SCOPE.PROJECT]: "This project" };
43
+
44
+ const ROLE = {
45
+ FRAME: "border",
46
+ TITLE: "customMessageLabel",
47
+ ACTIVE: "accent",
48
+ INACTIVE: "muted",
49
+ LABEL: "muted",
50
+ VALUE: "text",
51
+ META: "dim",
52
+ KEY: "accent",
53
+ KEY_TEXT: "dim",
54
+ HEAT: "accent",
55
+ HEAT_IDLE: "borderMuted",
56
+ BAR_EMPTY: "borderMuted",
57
+ ADDED: "toolDiffAdded",
58
+ REMOVED: "toolDiffRemoved",
59
+ ERROR: "error",
60
+ } as const;
61
+
62
+ const TITLE = "✿ Stats";
63
+ const CLOSE_BUTTON = "[× Close]";
64
+ const CLOSE_BUTTON_NARROW = "[×]";
65
+ const FOOTNOTE = "Local Pi sessions on this machine · subagent runs not included";
66
+ const LOADING = "Loading local sessions…";
67
+ const KEYS = [
68
+ ["tab", "view", "tab"],
69
+ ["r", "range", "range"],
70
+ ["s", "scope", "scope"],
71
+ ["q", "close", "close"],
72
+ ] as const;
73
+ const HINT_GAP = " ";
74
+ // The frame draws "│ " before the fitted content of every body row.
75
+ const CONTENT_OFFSET = 2;
76
+ const LABEL_WIDTH = 16;
77
+ const TWO_COLUMN_MIN = 64;
78
+ const HEATMAP_WEEKS = 52;
79
+ const HEATMAP_LABEL_WIDTH = 4;
80
+ const HEAT_CELL = 2;
81
+ const HEAT_IDLE = "·";
82
+ const HEAT_SHADES = ["░", "▒", "▓", "█"];
83
+ const WEEKDAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""];
84
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
85
+ const BAR_FILLED = "█";
86
+ const BAR_EMPTY = "░";
87
+ // Don Quixote runs to roughly 430k words, about 500k tokens: a playful yardstick, not a measurement.
88
+ const DON_QUIXOTE_TOKENS = 500_000;
89
+
90
+ type Action = `tab:${StatsTab}` | "tab" | "range" | "scope" | "close";
91
+
92
+ interface Span {
93
+ row: number;
94
+ start: number;
95
+ end: number;
96
+ action: Action;
97
+ }
98
+
99
+ type LoadState = { kind: "loading" } | { kind: "ready"; sessions: readonly SessionRecord[] } | { kind: "error"; message: string };
100
+
101
+ function rule(length: number): string {
102
+ return "─".repeat(Math.max(0, length));
103
+ }
104
+
105
+ function fit(text: string, width: number): string {
106
+ const clipped = truncateToWidth(text, Math.max(0, width), "…");
107
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
108
+ }
109
+
110
+ function padLeft(text: string, width: number): string {
111
+ return " ".repeat(Math.max(0, width - visibleWidth(text))) + text;
112
+ }
113
+
114
+ function monthDay(day: string): string {
115
+ const [, month, date] = day.split("-").map(Number);
116
+ return `${MONTHS[month - 1]} ${date}`;
117
+ }
118
+
119
+ function plural(count: number, noun: string): string {
120
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
121
+ }
122
+
123
+ function percent(share: number): string {
124
+ return `${Math.round(share * 100)}%`;
125
+ }
126
+
127
+ export function tokenBreakdown(totals: TokenTotals): string {
128
+ return `in ${formatTokens(totals.input)} · out ${formatTokens(totals.output)} · cache read ${formatTokens(totals.cacheRead)} · cache write ${formatTokens(totals.cacheWrite)}`;
129
+ }
130
+
131
+ export function literaryComparison(tokens: number): string {
132
+ const ratio = tokens / DON_QUIXOTE_TOKENS;
133
+ if (ratio >= 1) return `✿ That's ~${ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)}x the tokens in Don Quixote.`;
134
+ const share = ratio * 100;
135
+ return `✿ That's ~${share >= 10 ? Math.round(share) : share.toFixed(1)}% of the tokens in Don Quixote.`;
136
+ }
137
+
138
+ export class StatsView {
139
+ private readonly deps: StatsViewDeps;
140
+ private tab: StatsTab = STATS_TAB.OVERVIEW;
141
+ private range: StatsRange = STATS_RANGE.ALL;
142
+ private scope: StatsScope = STATS_SCOPE.ALL;
143
+ private state: LoadState = { kind: "loading" };
144
+ private summary: { key: string; stats: StatsSummary } | undefined;
145
+ private spans: Span[] = [];
146
+ private layout: { width: number; height: number } | undefined;
147
+ private hovered: Action | undefined;
148
+ private closed = false;
149
+
150
+ constructor(deps: StatsViewDeps) {
151
+ this.deps = deps;
152
+ }
153
+
154
+ /** Load (or reload) the session records; never rejects. */
155
+ async load(): Promise<void> {
156
+ this.state = { kind: "loading" };
157
+ this.summary = undefined;
158
+ try {
159
+ this.state = { kind: "ready", sessions: await this.deps.load() };
160
+ } catch (error) {
161
+ this.state = { kind: "error", message: error instanceof Error ? error.message : String(error) };
162
+ }
163
+ this.summary = undefined;
164
+ if (!this.closed) this.deps.requestRender();
165
+ }
166
+
167
+ handleInput(data: string): void {
168
+ if (this.closed) return;
169
+ if (data === "q" || matchesKey(data, Key.escape)) return this.close();
170
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) this.shiftTab(1);
171
+ else if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) this.shiftTab(-1);
172
+ else if (data === "1" || data === "2" || data === "3") this.setTab(TABS[Number(data) - 1].id);
173
+ else if (data === "r") this.cycleRange();
174
+ else if (data === "s") this.toggleScope();
175
+ else return;
176
+ this.deps.requestRender();
177
+ }
178
+
179
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
180
+ if (this.closed) return undefined;
181
+ const layout = this.layout;
182
+ const span = layout && event.width === layout.width && event.height === layout.height
183
+ ? this.spans.find((candidate) => candidate.row === event.y && event.x >= candidate.start && event.x < candidate.end)
184
+ : undefined;
185
+ if (event.type === "move" && event.button === "none") {
186
+ if (span?.action === this.hovered) return span ? { handled: true } : undefined;
187
+ this.hovered = span?.action;
188
+ return { handled: true, render: true };
189
+ }
190
+ if (event.type !== "click" || event.button !== "left" || !span) return undefined;
191
+ this.run(span.action);
192
+ return { handled: true, render: true };
193
+ }
194
+
195
+ invalidate(): void {
196
+ this.layout = undefined;
197
+ this.spans = [];
198
+ this.hovered = undefined;
199
+ }
200
+
201
+ dispose(): void {
202
+ this.closed = true;
203
+ }
204
+
205
+ render(width: number): string[] {
206
+ const height = Math.max(0, typeof this.deps.rows === "function" ? this.deps.rows() : this.deps.rows);
207
+ const theme = this.deps.theme;
208
+ const inner = Math.max(0, width - 2);
209
+ const content = Math.max(0, width - 4);
210
+ this.spans = [];
211
+ const frame = (line: string) => `${theme.fg(ROLE.FRAME, "│")} ${fit(line, content)} ${theme.fg(ROLE.FRAME, "│")}`;
212
+ const body = this.body(content);
213
+ const room = Math.max(0, height - 5);
214
+ const shown = body.slice(0, room);
215
+ while (shown.length < room) shown.push("");
216
+ const lines = [
217
+ this.top(width),
218
+ frame(this.tabsLine(content, 1)),
219
+ ...shown.map(frame),
220
+ frame(theme.fg(ROLE.META, FOOTNOTE)),
221
+ frame(this.footer(content, height - 2)),
222
+ theme.fg(ROLE.FRAME, `╰${rule(inner)}╯`),
223
+ ].slice(0, height);
224
+ this.layout = { width, height: lines.length };
225
+ return lines;
226
+ }
227
+
228
+ private top(width: number): string {
229
+ const theme = this.deps.theme;
230
+ const close = width >= 60 ? CLOSE_BUTTON : CLOSE_BUTTON_NARROW;
231
+ const fill = width - 8 - visibleWidth(TITLE) - close.length;
232
+ if (fill < 1) return theme.fg(ROLE.FRAME, "╭─ ") + fit(theme.fg(ROLE.TITLE, TITLE), Math.max(0, width - 5)) + theme.fg(ROLE.FRAME, " ╮");
233
+ this.spans.push({ row: 0, start: width - 3 - close.length, end: width - 3, action: "close" });
234
+ return theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, TITLE) + theme.fg(ROLE.FRAME, ` ${rule(fill)} `) +
235
+ paintHoverable(theme, close, this.hovered === "close", ROLE.KEY) + theme.fg(ROLE.FRAME, " ─╮");
236
+ }
237
+
238
+ private tabsLine(width: number, row: number): string {
239
+ const theme = this.deps.theme;
240
+ let plain = "";
241
+ let painted = "";
242
+ const spans: Span[] = [];
243
+ for (const { id, label } of TABS) {
244
+ if (plain) { plain += " "; painted += " "; }
245
+ const text = id === this.tab ? `[${label}]` : ` ${label} `;
246
+ const action: Action = `tab:${id}`;
247
+ spans.push({ row, start: CONTENT_OFFSET + plain.length, end: CONTENT_OFFSET + plain.length + text.length, action });
248
+ plain += text;
249
+ painted += paintHoverable(theme, text, this.hovered === action, id === this.tab ? ROLE.ACTIVE : ROLE.INACTIVE);
250
+ }
251
+ if (plain.length <= width) this.spans.push(...spans);
252
+ const filters = this.tab === STATS_TAB.SESSION ? "This session" : `${RANGE_LABEL[this.range]} · ${SCOPE_LABEL[this.scope]}`;
253
+ const gap = width - plain.length - filters.length;
254
+ return gap >= 2 ? painted + " ".repeat(gap) + theme.fg(ROLE.META, filters) : painted;
255
+ }
256
+
257
+ private footer(width: number, row: number): string {
258
+ const theme = this.deps.theme;
259
+ const plainWidth = KEYS.reduce((sum, [key, label]) => sum + key.length + 1 + label.length, 0) + HINT_GAP.length * (KEYS.length - 1);
260
+ let cursor = CONTENT_OFFSET;
261
+ const parts: string[] = [];
262
+ for (const [key, label, action] of KEYS) {
263
+ const text = `${key} ${label}`;
264
+ if (plainWidth <= width) this.spans.push({ row, start: cursor, end: cursor + text.length, action });
265
+ cursor += text.length + HINT_GAP.length;
266
+ parts.push(this.hovered === action ? paintHoverable(theme, text, true) : `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, label)}`);
267
+ }
268
+ return parts.join(HINT_GAP);
269
+ }
270
+
271
+ private body(width: number): string[] {
272
+ if (this.tab === STATS_TAB.SESSION) return ["", ...this.sessionTab(width)];
273
+ const theme = this.deps.theme;
274
+ if (this.state.kind === "loading") return ["", theme.fg(ROLE.META, LOADING)];
275
+ if (this.state.kind === "error") return ["", theme.fg(ROLE.ERROR, `Could not read local sessions: ${this.state.message}`)];
276
+ const stats = this.stats(this.state.sessions);
277
+ if (stats.sessions === 0) {
278
+ const hint = this.range === STATS_RANGE.ALL && this.scope === STATS_SCOPE.ALL ? "." : " for this view · press r or s to widen it.";
279
+ return ["", theme.fg(ROLE.META, `No usage recorded yet${hint}`)];
280
+ }
281
+ return ["", ...(this.tab === STATS_TAB.MODELS ? this.modelsTab(stats, width) : this.overviewTab(stats, width))];
282
+ }
283
+
284
+ private stats(sessions: readonly SessionRecord[]): StatsSummary {
285
+ const key = `${this.range}|${this.scope}`;
286
+ if (this.summary?.key !== key) {
287
+ this.summary = { key, stats: aggregateStats(sessions, { range: this.range, scope: this.scope, cwd: this.deps.cwd, now: this.deps.now(), dayKey: this.deps.dayKey }) };
288
+ }
289
+ return this.summary.stats;
290
+ }
291
+
292
+ private overviewTab(stats: StatsSummary, width: number): string[] {
293
+ const theme = this.deps.theme;
294
+ const pairs: Array<[string, string]> = [
295
+ ["Favorite model", stats.models[0]?.model ?? "—"],
296
+ ["Total tokens", formatTokens(stats.totals.total)],
297
+ ["Sessions", String(stats.sessions)],
298
+ ["Longest session", stats.longestSession ? formatElapsed(stats.longestSession.durationMs) : "—"],
299
+ ["Active days", `${stats.activeDays}/${stats.daysInRange}`],
300
+ ["Longest streak", plural(stats.longestStreak, "day")],
301
+ ["Most active day", stats.mostActiveDay ? monthDay(stats.mostActiveDay.day) : "—"],
302
+ ["Current streak", plural(stats.currentStreak, "day")],
303
+ ];
304
+ return [
305
+ ...this.heatmap(stats, width),
306
+ "",
307
+ ...this.grid(pairs, width),
308
+ this.field("Tokens", tokenBreakdown(stats.totals)),
309
+ this.field("Cost", formatCost(stats.totals.cost)),
310
+ "",
311
+ theme.fg(ROLE.ACTIVE, literaryComparison(stats.totals.total)),
312
+ ];
313
+ }
314
+
315
+ private heatmap(stats: StatsSummary, width: number): string[] {
316
+ const theme = this.deps.theme;
317
+ const { today, firstDay } = stats;
318
+ const mondayOffset = (day: string) => (weekdayOf(day) + 6) % 7;
319
+ const lastMonday = shiftDay(today, -mondayOffset(today));
320
+ const needed = this.range === STATS_RANGE.ALL ? HEATMAP_WEEKS : Math.ceil((daysBetween(shiftDay(firstDay, -mondayOffset(firstDay)), today) + 1) / 7);
321
+ const columns = Math.max(1, Math.min(needed, Math.floor((width - HEATMAP_LABEL_WIDTH) / HEAT_CELL)));
322
+ const mondays = Array.from({ length: columns }, (_, column) => shiftDay(lastMonday, -7 * (columns - 1 - column)));
323
+ const max = Math.max(0, ...Object.values(stats.days));
324
+ const months = Array.from({ length: HEATMAP_LABEL_WIDTH + columns * HEAT_CELL }, () => " ");
325
+ let previousMonth = "";
326
+ let freeFrom = 0;
327
+ mondays.forEach((monday, column) => {
328
+ const month = monday.slice(0, 7);
329
+ const at = HEATMAP_LABEL_WIDTH + column * HEAT_CELL;
330
+ if (month !== previousMonth && at >= freeFrom && at + 3 <= months.length) {
331
+ MONTHS[Number(month.slice(5)) - 1].split("").forEach((char, offset) => { months[at + offset] = char; });
332
+ freeFrom = at + 4;
333
+ }
334
+ previousMonth = month;
335
+ });
336
+ const rows = WEEKDAY_LABELS.map((label, weekday) => {
337
+ const cells = mondays.map((monday) => {
338
+ const day = shiftDay(monday, weekday);
339
+ if (day > today || (this.range !== STATS_RANGE.ALL && day < firstDay)) return " ".repeat(HEAT_CELL);
340
+ const tokens = stats.days[day];
341
+ if (tokens === undefined) return theme.fg(ROLE.HEAT_IDLE, HEAT_IDLE) + " ";
342
+ const level = max > 0 ? Math.min(HEAT_SHADES.length, Math.max(1, Math.ceil((HEAT_SHADES.length * tokens) / max))) : 1;
343
+ return theme.fg(ROLE.HEAT, HEAT_SHADES[level - 1]) + " ";
344
+ });
345
+ return theme.fg(ROLE.META, label.padEnd(HEATMAP_LABEL_WIDTH)) + cells.join("");
346
+ });
347
+ const legend = " ".repeat(HEATMAP_LABEL_WIDTH) + theme.fg(ROLE.META, "Less ") + [theme.fg(ROLE.HEAT_IDLE, HEAT_IDLE), ...HEAT_SHADES.map((shade) => theme.fg(ROLE.HEAT, shade))].join(" ") + theme.fg(ROLE.META, " More");
348
+ return [theme.fg(ROLE.META, months.join("").trimEnd()), ...rows, legend];
349
+ }
350
+
351
+ private modelsTab(stats: StatsSummary, width: number): string[] {
352
+ const theme = this.deps.theme;
353
+ const nameWidth = Math.min(28, Math.max(5, ...stats.models.map((model) => model.model.length)));
354
+ const tail = 4 + 2 + 7 + 2 + 8 + 2 + 5;
355
+ const barWidth = Math.max(4, Math.min(24, width - nameWidth - tail - 2));
356
+ const header = theme.fg(ROLE.LABEL, `${fit("Model", nameWidth)} ${fit("Share", barWidth + 5)} ${padLeft("Tokens", 7)} ${padLeft("Cost", 8)} ${padLeft("Msgs", 5)}`);
357
+ const rows = stats.models.map((model) => {
358
+ const filled = Math.round(model.share * barWidth);
359
+ const bar = theme.fg(ROLE.ACTIVE, BAR_FILLED.repeat(filled)) + theme.fg(ROLE.BAR_EMPTY, BAR_EMPTY.repeat(barWidth - filled));
360
+ return `${theme.fg(ROLE.VALUE, fit(model.model, nameWidth))} ${bar} ${padLeft(percent(model.share), 4)} ${padLeft(formatTokens(model.tokens), 7)} ${padLeft(formatCost(model.cost), 8)} ${padLeft(String(model.messages), 5)}`;
361
+ });
362
+ return [header, ...rows, "", theme.fg(ROLE.META, `${formatTokens(stats.totals.total)} tokens · ${formatCost(stats.totals.cost)} across ${plural(stats.sessions, "session")}`)];
363
+ }
364
+
365
+ private sessionTab(_width: number): string[] {
366
+ const theme = this.deps.theme;
367
+ const current = this.deps.current();
368
+ const lines = current.lines
369
+ ? `${theme.fg(ROLE.ADDED, `+${current.lines.added}`)} ${theme.fg(ROLE.REMOVED, `−${current.lines.removed}`)}`
370
+ : theme.fg(ROLE.META, "no captured file changes");
371
+ return [
372
+ this.field("Model", current.model ?? "—"),
373
+ this.field("Cost", formatCost(current.totals.cost)),
374
+ this.field("Duration", formatElapsed(current.durationMs)),
375
+ this.field("Messages", String(current.messages)),
376
+ this.field("Tokens", `${formatTokens(current.totals.total)} total · ${tokenBreakdown(current.totals)}`),
377
+ this.field("Lines", lines),
378
+ "",
379
+ theme.fg(ROLE.META, "Wall time since the session's first entry; range and scope do not apply here."),
380
+ ];
381
+ }
382
+
383
+ private field(label: string, value: string): string {
384
+ return this.deps.theme.fg(ROLE.LABEL, label.padEnd(LABEL_WIDTH)) + this.deps.theme.fg(ROLE.VALUE, value);
385
+ }
386
+
387
+ private grid(pairs: ReadonlyArray<[string, string]>, width: number): string[] {
388
+ if (width < TWO_COLUMN_MIN) return pairs.map(([label, value]) => this.field(label, value));
389
+ const column = Math.floor(width / 2);
390
+ const rows: string[] = [];
391
+ for (let index = 0; index < pairs.length; index += 2) {
392
+ const [left, right] = [pairs[index], pairs[index + 1]];
393
+ rows.push(fit(this.field(left[0], left[1]), column) + (right ? this.field(right[0], right[1]) : ""));
394
+ }
395
+ return rows;
396
+ }
397
+
398
+ private run(action: Action): void {
399
+ if (action === "close") return this.close();
400
+ if (action === "tab") this.shiftTab(1);
401
+ else if (action === "range") this.cycleRange();
402
+ else if (action === "scope") this.toggleScope();
403
+ else this.setTab(action.slice("tab:".length) as StatsTab);
404
+ this.deps.requestRender();
405
+ }
406
+
407
+ private shiftTab(delta: number): void {
408
+ const index = TABS.findIndex((tab) => tab.id === this.tab);
409
+ this.setTab(TABS[(index + delta + TABS.length) % TABS.length].id);
410
+ }
411
+
412
+ private setTab(tab: StatsTab): void {
413
+ this.tab = tab;
414
+ this.hovered = undefined;
415
+ }
416
+
417
+ private cycleRange(): void {
418
+ this.range = RANGES[(RANGES.indexOf(this.range) + 1) % RANGES.length];
419
+ }
420
+
421
+ private toggleScope(): void {
422
+ this.scope = this.scope === STATS_SCOPE.ALL ? STATS_SCOPE.PROJECT : STATS_SCOPE.ALL;
423
+ }
424
+
425
+ private close(): void {
426
+ if (this.closed) return;
427
+ this.closed = true;
428
+ this.spans = [];
429
+ this.deps.onClose();
430
+ }
431
+ }
@@ -0,0 +1,52 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import { colorToRgb, parseColor } from "@earendil-works/pi-tui";
4
+
5
+ const MAX_BYTES = 256_000;
6
+ const HEX = /^#[0-9a-fA-F]{6}$/;
7
+ const REFERENCE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
8
+
9
+ /** Read only the installed source supplied by Pi; never use the active theme as a fallback. */
10
+ export function sourcePalettePreview(name: string, sourcePath: string | undefined): { title: string; sample: string } {
11
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) || !sourcePath || !isAbsolute(sourcePath)) throw new Error("Installed theme source unavailable.");
12
+ // Pi's installed themes can live behind platform symlinks. Resolve the
13
+ // Pi-supplied path, then open the target without following another symlink.
14
+ const path = realpathSync(sourcePath);
15
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
16
+ let text: string;
17
+ try {
18
+ const stat = fstatSync(fd);
19
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("Theme source unavailable or too large.");
20
+ const bytes = readFileSync(fd);
21
+ if (bytes.length > MAX_BYTES) throw new Error("Theme source too large.");
22
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
23
+ } finally { closeSync(fd); }
24
+ const document: unknown = JSON.parse(text);
25
+ if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Invalid theme source.");
26
+ const { name: sourceName, colors, vars } = document as Record<string, unknown>;
27
+ if (sourceName !== name || !colors || typeof colors !== "object" || Array.isArray(colors)) throw new Error("Theme source does not match installed theme.");
28
+ if (vars !== undefined && (!vars || typeof vars !== "object" || Array.isArray(vars))) throw new Error("Invalid theme variables.");
29
+ const palette = colors as Record<string, unknown>;
30
+ const variables = (vars ?? {}) as Record<string, unknown>;
31
+ const escape = (role: "accent" | "text", background: boolean): string => {
32
+ let value: unknown = palette[role];
33
+ const seen = new Set<string>();
34
+ while (typeof value === "string" && REFERENCE.test(value)) {
35
+ if (seen.has(value) || !Object.hasOwn(variables, value)) throw new Error("Invalid theme color reference.");
36
+ seen.add(value);
37
+ value = variables[value];
38
+ }
39
+ const prefix = background ? 48 : 38;
40
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255) return `\x1b[${prefix};5;${value}m`;
41
+ if (typeof value === "string" && HEX.test(value)) return `\x1b[${prefix};2;${parseInt(value.slice(1, 3), 16)};${parseInt(value.slice(3, 5), 16)};${parseInt(value.slice(5, 7), 16)}m`;
42
+ // Pi 0.99.1's built-in palettes use OKHSL. Delegate the exact color
43
+ // grammar, finite/range checks and conversion to the public host utility;
44
+ // do not broaden this source-only preview to arbitrary CSS colors.
45
+ if (typeof value === "string" && value.startsWith("okhsl(")) {
46
+ const { r, g, b } = colorToRgb(parseColor(value));
47
+ return `\x1b[${prefix};2;${r};${g};${b}m`;
48
+ }
49
+ throw new Error("Invalid theme palette color.");
50
+ };
51
+ return { title: `${name} · source palette`, sample: `${escape("accent", true)} \x1b[0m ${escape("text", false)}Aa sample text\x1b[0m` };
52
+ }