@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1,675 @@
1
+ // @ts-check
2
+ /**
3
+ * Git / PR board — the crtr `git-pr` view (the monitor archetype, sibling of
4
+ * `canvas`).
5
+ *
6
+ * Self-contained ESM. Imports its data layer from `./client.mjs` (which shells
7
+ * `git` + `gh` over the view's cwd) and the shared state helpers from
8
+ * `../_lib/states.mjs`. It imports NOTHING from crtr — the host injects the
9
+ * `Draw` + `ViewHost` API and dynamically `import()`s this module's DEFAULT
10
+ * EXPORT.
11
+ *
12
+ * A READ-ONLY instrument cluster for the repo at the view's cwd. Composition
13
+ * (single pane — the two domains are stacked, not split, so the eye reads top to
14
+ * bottom without hunting across a rule; a vline is reserved for genuinely
15
+ * co-equal live panes and these aren't — git local state is the headline, PRs
16
+ * are the trailing gauge):
17
+ *
18
+ * 1. HEADER ZONE (always-on gauges, 2 rows): branch → upstream with ahead/
19
+ * behind, a right-flushed working-tree chip; then the last commit + a
20
+ * right-flushed relative age. These are the dials you glance at first.
21
+ * 2. BOARD (the scrollable body, one list with two labeled sections):
22
+ * "Working tree" — changed files (status glyph + XY code + path + churn),
23
+ * then "Pull requests" — open PRs (number + title + review/CI rollup +
24
+ * age). gh failure degrades THIS SECTION to a guided note; the git header +
25
+ * file list still render (graceful partial failure, principle 5).
26
+ *
27
+ * VISUAL LANGUAGE (crtr-views-visual-design §2/§3/§4): hierarchy by weight + hue
28
+ * + position, never boxes. Hues are NUMERIC SGR only — green=clean/passing/
29
+ * staged/ahead (32), yellow=attention/pending/modified/behind (33), red=failing/
30
+ * conflicted (31), cyan=identity/branch/PR# (36), grey=metadata (90), bright-
31
+ * yellow=flag counts (93). Every hue is paired with a glyph + the git-native XY
32
+ * code so it survives NO_COLOR. The one state chip is host-derived from data
33
+ * freshness (busy→working; git source down→error/blocked; conflicts or failing
34
+ * CI / changes-requested→action/attention; else ready), driven by toggling
35
+ * banners + the busy lane. The four standard states come from `_lib/states.mjs`.
36
+ *
37
+ * @module git-pr/view
38
+ */
39
+
40
+ import { fetchGit, fetchPrs } from './client.mjs';
41
+ import { loadingState, emptyState, notReadyState } from '../_lib/states.mjs';
42
+
43
+ /** @typedef {import('./client.mjs').GitState} GitState */
44
+ /** @typedef {import('./client.mjs').ChangedFile} ChangedFile */
45
+ /** @typedef {import('./client.mjs').Pr} Pr */
46
+
47
+ /**
48
+ * One logical board row (built once per refresh; re-rendered on resize without a
49
+ * re-fetch). The cursor moves over all of them — label/spacer rows are inert.
50
+ * @typedef {{kind:'label', text:string}
51
+ * | {kind:'file', file:ChangedFile}
52
+ * | {kind:'clean'}
53
+ * | {kind:'spacer'}
54
+ * | {kind:'pr', pr:Pr}
55
+ * | {kind:'note', text:string}} BoardRow
56
+ */
57
+
58
+ /**
59
+ * The view's single mutable state object. The view owns it; hooks mutate it in
60
+ * place.
61
+ * @typedef {Object} GitPrState
62
+ * @property {GitState|null} git Local git state, or null when unavailable.
63
+ * @property {string|null} gitError Cause string when the git read failed.
64
+ * @property {string|null} gitErrorKind 'not-a-repo' | 'git-missing' | 'git-failed'.
65
+ * @property {Pr[]} prs Open PRs (empty when none / gh degraded).
66
+ * @property {string|null} prNote gh degradation guidance (null ⇒ gh ok).
67
+ * @property {BoardRow[]} board Flattened board rows (render source).
68
+ * @property {number} cursor Read cursor into board (j/k).
69
+ * @property {number} scroll draw.list scroll, stored back each frame.
70
+ * @property {number} lastFetch Epoch ms of the last completed refresh.
71
+ */
72
+
73
+ // ── Status vocabulary (triple-coded: hue + glyph + the git-native XY code) ─────
74
+
75
+ /**
76
+ * File class → glyph + NUMERIC hue. Shapes differ (filled/hollow/?/✗) so the
77
+ * class survives mono; the XY code (index vs worktree column) is the third code.
78
+ * @type {Record<string,{glyph:string, fg:string}>}
79
+ */
80
+ const FILE_GLYPH = {
81
+ staged: { glyph: '●', fg: '32' }, // green — in the index, ready
82
+ modified: { glyph: '○', fg: '33' }, // yellow — unstaged worktree edit
83
+ untracked: { glyph: '?', fg: '90' }, // grey — not tracked
84
+ conflict: { glyph: '✗', fg: '31' }, // red — merge conflict
85
+ };
86
+
87
+ /** PR review decision → glyph + hue. Pending review is neutral (grey), not red. */
88
+ const REVIEW = {
89
+ approved: { glyph: '✓', fg: '32' }, // green
90
+ changes: { glyph: '✗', fg: '31' }, // red — changes requested (attention)
91
+ review: { glyph: '◌', fg: '90' }, // grey — review required / pending
92
+ };
93
+
94
+ /** PR CI rollup → glyph + hue. */
95
+ const CI = {
96
+ pass: { glyph: '✓', fg: '32' }, // green
97
+ fail: { glyph: '✗', fg: '31' }, // red
98
+ pending: { glyph: '⟳', fg: '33' }, // yellow
99
+ none: { glyph: '·', fg: '90' }, // grey — no checks
100
+ };
101
+
102
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
103
+
104
+ /**
105
+ * Relative-age ladder (design §5): `now` (<60s), `{m}m` (<60m), `{h}h` (<24h),
106
+ * `{d}d` (<7d), else `Mon D` (`Mar 4`), prior-year `Mon ʼYY`. Max ~5 cols.
107
+ * @param {string} iso @param {number} now @returns {string}
108
+ */
109
+ function relAge(iso, now) {
110
+ const t = Date.parse(iso);
111
+ if (Number.isNaN(t)) return '';
112
+ const s = Math.max(0, Math.floor((now - t) / 1000));
113
+ if (s < 60) return 'now';
114
+ const m = Math.floor(s / 60);
115
+ if (m < 60) return `${m}m`;
116
+ const h = Math.floor(m / 60);
117
+ if (h < 24) return `${h}h`;
118
+ const d = Math.floor(h / 24);
119
+ if (d < 7) return `${d}d`;
120
+ const date = new Date(t);
121
+ const mon = MONTHS[date.getMonth()] || '?';
122
+ if (date.getFullYear() === new Date(now).getFullYear()) return `${mon} ${date.getDate()}`;
123
+ return `${mon} ʼ${String(date.getFullYear()).slice(-2)}`;
124
+ }
125
+
126
+ /** @param {number} n @param {string} w @returns {string} */
127
+ function plural(n, w) {
128
+ return `${n} ${w}${n === 1 ? '' : 's'}`;
129
+ }
130
+
131
+ /** Total changed files. @param {GitState} g @returns {number} */
132
+ function changedCount(g) {
133
+ return g.files.length;
134
+ }
135
+
136
+ // ── Board model (built in refresh; mapped to spans in render) ──────────────────
137
+
138
+ /**
139
+ * Flatten git + PR state into the ordered board rows. Each section degrades
140
+ * inline (a clean reward row / a guided gh note / a "no open PRs" note) so the
141
+ * board is never a void.
142
+ * @param {GitPrState} state @returns {BoardRow[]}
143
+ */
144
+ function buildBoard(state) {
145
+ /** @type {BoardRow[]} */
146
+ const rows = [];
147
+ const g = state.git;
148
+ rows.push({ kind: 'label', text: 'Working tree' });
149
+ if (!g || g.files.length === 0) rows.push({ kind: 'clean' });
150
+ else for (const f of g.files) rows.push({ kind: 'file', file: f });
151
+
152
+ rows.push({ kind: 'spacer' });
153
+ rows.push({ kind: 'label', text: 'Pull requests' });
154
+ if (state.prNote) rows.push({ kind: 'note', text: state.prNote });
155
+ else if (state.prs.length === 0) rows.push({ kind: 'note', text: 'No open pull requests.' });
156
+ else for (const pr of state.prs) rows.push({ kind: 'pr', pr });
157
+ return rows;
158
+ }
159
+
160
+ // ── Chrome copy (subtitle / footer / attention / dump) ─────────────────────────
161
+
162
+ /**
163
+ * Working-tree state as one short phrase, for the subtitle + dump.
164
+ * @param {GitState} g @returns {string}
165
+ */
166
+ function treePhrase(g) {
167
+ const c = g.counts;
168
+ if (g.files.length === 0) return 'clean';
169
+ /** @type {string[]} */
170
+ const parts = [];
171
+ if (c.conflict) parts.push(`${c.conflict} conflict`);
172
+ if (c.staged) parts.push(`${c.staged} staged`);
173
+ if (c.modified) parts.push(`${c.modified} modified`);
174
+ if (c.untracked) parts.push(`${c.untracked} untracked`);
175
+ return parts.join(' · ') || plural(g.files.length, 'change');
176
+ }
177
+
178
+ /**
179
+ * Live title subtitle — branch + tracking delta + tree state. `null` ⇒ no repo.
180
+ * @param {GitPrState} state @returns {string|null}
181
+ */
182
+ function subtitleFor(state) {
183
+ const g = state.git;
184
+ if (!g) return null;
185
+ let s = g.branch;
186
+ if (g.ahead) s += ` ↑${g.ahead}`;
187
+ if (g.behind) s += ` ↓${g.behind}`;
188
+ s += ` · ${treePhrase(g)}`;
189
+ return s;
190
+ }
191
+
192
+ /**
193
+ * Footer status (left, transient) — rendered scope. `null` ⇒ a state speaks.
194
+ * @param {GitPrState} state @returns {string|null}
195
+ */
196
+ function footerSummary(state) {
197
+ const g = state.git;
198
+ if (!g) return null;
199
+ const files = g.files.length === 0 ? 'clean' : plural(changedCount(g), 'change');
200
+ const prs = state.prNote ? 'PRs n/a' : plural(state.prs.length, 'PR');
201
+ return `${files} · ${prs}`;
202
+ }
203
+
204
+ /**
205
+ * The one thing that wants a human's eyes → an ACTION banner (host derives the
206
+ * yellow attention chip). Conflicts, a failing PR check, or changes-requested.
207
+ * `null` ⇒ nothing pressing (host derives ready). Only PRs we actually fetched
208
+ * count (gh degraded ⇒ unknown ⇒ not flagged).
209
+ * @param {GitPrState} state @returns {string|null}
210
+ */
211
+ function attentionFor(state) {
212
+ const g = state.git;
213
+ /** @type {string[]} */
214
+ const parts = [];
215
+ if (g && g.counts.conflict) parts.push(`${plural(g.counts.conflict, 'conflict')}`);
216
+ if (!state.prNote) {
217
+ const failing = state.prs.filter((p) => p.ci === 'fail').length;
218
+ const changes = state.prs.filter((p) => p.review === 'changes').length;
219
+ if (failing) parts.push(`${failing} PR${failing === 1 ? '' : 's'} failing CI`);
220
+ if (changes) parts.push(`${changes} PR${changes === 1 ? '' : 's'} need changes`);
221
+ }
222
+ return parts.length ? `${parts.join(' · ')} — needs attention` : null;
223
+ }
224
+
225
+ // ── Row → ListItemRow (left spans + right-flushed metadata) ────────────────────
226
+
227
+ /**
228
+ * Build one board list row. A 1-cell left gutter rides the cursor bg (§2).
229
+ * @param {BoardRow} row @param {number} now
230
+ * @returns {import('../../core/tui/draw.js').ListItemRow}
231
+ */
232
+ function rowToItem(row, now) {
233
+ /** @type {import('../../core/tui/draw.js').Span[]} */
234
+ const spans = [{ text: ' ', style: undefined }];
235
+
236
+ if (row.kind === 'label') {
237
+ spans.push({ text: row.text, style: { fg: '90', dim: true } });
238
+ return { spans };
239
+ }
240
+ if (row.kind === 'spacer') {
241
+ return { spans: [{ text: '', style: undefined }] };
242
+ }
243
+ if (row.kind === 'clean') {
244
+ spans.push({ text: '✓', style: { fg: '32' } });
245
+ spans.push({ text: ' nothing to commit, working tree clean', style: { dim: true } });
246
+ return { spans };
247
+ }
248
+ if (row.kind === 'note') {
249
+ spans.push({ text: '·', style: { fg: '90', dim: true } });
250
+ spans.push({ text: ' ' + row.text, style: { dim: true } });
251
+ return { spans };
252
+ }
253
+ if (row.kind === 'file') {
254
+ const f = row.file;
255
+ const g = FILE_GLYPH[f.cls] || FILE_GLYPH.modified;
256
+ spans.push({ text: g.glyph, style: { fg: g.fg } }); // hue + shape (mono carrier)
257
+ spans.push({ text: ' ', style: undefined });
258
+ spans.push({ text: f.xy.replace(/ /g, '·'), style: { fg: '90', dim: true } }); // git-native XY code
259
+ spans.push({ text: ' ', style: undefined });
260
+ spans.push({ text: f.path, style: f.cls === 'conflict' ? { fg: '31' } : undefined });
261
+ if (f.add || f.del) {
262
+ /** @type {import('../../core/tui/draw.js').Span[]} */
263
+ const right = [
264
+ { text: `+${f.add}`, style: { fg: '90', dim: true } },
265
+ { text: ' ', style: undefined },
266
+ { text: `−${f.del}`, style: { fg: '90', dim: true } },
267
+ ];
268
+ return { spans, right };
269
+ }
270
+ return { spans };
271
+ }
272
+ // row.kind === 'pr'
273
+ const pr = row.pr;
274
+ const rv = REVIEW[pr.review] || REVIEW.review;
275
+ const ci = CI[pr.ci] || CI.none;
276
+ spans.push({ text: `#${pr.number}`, style: { fg: '36', bold: pr.current } }); // cyan identity; current branch bold
277
+ spans.push({ text: ' ', style: undefined });
278
+ spans.push({ text: pr.title, style: pr.isDraft ? { dim: true } : undefined });
279
+ if (pr.isDraft) spans.push({ text: ' (draft)', style: { fg: '90', dim: true } });
280
+
281
+ const age = relAge(pr.updatedAt, now);
282
+ /** @type {import('../../core/tui/draw.js').Span[]} */
283
+ const right = [
284
+ { text: rv.glyph, style: { fg: rv.fg } }, // review (color + glyph)
285
+ { text: ' ', style: undefined },
286
+ { text: ci.glyph, style: { fg: ci.fg } }, // CI rollup (color + glyph)
287
+ ];
288
+ if (age) {
289
+ right.push({ text: ' ', style: undefined });
290
+ right.push({ text: age, style: { fg: '90', dim: true } });
291
+ }
292
+ return { spans, right };
293
+ }
294
+
295
+ // ── Header zone (drawn manually above the board list) ──────────────────────────
296
+
297
+ /**
298
+ * Visible (column) width of a span group — ANSI-free cell count. Mirrors
299
+ * draw.ts's internal `spanWidth` so the header can reserve room for a
300
+ * right-flushed group before clipping the left line (Array.from counts code
301
+ * points, so wide/combined glyphs count as one cell each, like the renderer).
302
+ * @param {import('../../core/tui/draw.js').Span[]} spans @returns {number}
303
+ */
304
+ function spanWidth(spans) {
305
+ let n = 0;
306
+ for (const s of spans) n += Array.from(s.text).length;
307
+ return n;
308
+ }
309
+
310
+ /**
311
+ * Paint the always-on header gauges into the top of `content`: branch line then
312
+ * commit line, with right-flushed metadata. Returns the number of rows consumed
313
+ * (incl. the trailing hairline) so render can place the board below it.
314
+ * @param {GitState} g
315
+ * @param {import('../../core/tui/draw.js').Draw} draw
316
+ * @param {import('../../core/tui/draw.js').Rect} content
317
+ * @param {number} now
318
+ * @returns {number} rows consumed
319
+ */
320
+ function drawHeader(g, draw, content, now) {
321
+ const right = content.col + content.width; // exclusive right edge for spansRight
322
+ let r = content.row;
323
+
324
+ // ── Branch line: ⎇ branch → upstream ↑N ↓M ····· [tree chip right-flush]
325
+ /** @type {import('../../core/tui/draw.js').Span[]} */
326
+ const left = [
327
+ { text: ' ', style: undefined },
328
+ { text: '⎇ ', style: { fg: '36' } },
329
+ { text: g.branch, style: { fg: '36', bold: true } }, // cyan identity
330
+ ];
331
+ if (g.upstream) {
332
+ left.push({ text: ` → ${g.upstream}`, style: { fg: '90', dim: true } });
333
+ if (g.ahead) left.push({ text: ` ↑${g.ahead}`, style: { fg: '32' } }); // green ahead-ok
334
+ if (g.behind) left.push({ text: ` ↓${g.behind}`, style: { fg: '33' } }); // yellow attention
335
+ if (!g.ahead && !g.behind) left.push({ text: ' ✓ up to date', style: { fg: '32' } });
336
+ } else {
337
+ left.push({ text: ' · no upstream', style: { fg: '90', dim: true } });
338
+ }
339
+ // Tree chip, right-flushed on the same row (clean = green ✓, else colored counts).
340
+ /** @type {import('../../core/tui/draw.js').Span[]} */
341
+ const chip = [];
342
+ if (g.files.length === 0) {
343
+ chip.push({ text: '✓ clean', style: { fg: '32' } });
344
+ } else {
345
+ const c = g.counts;
346
+ /** @param {number} n @param {string} word @param {string} glyph @param {string} fg */
347
+ const seg = (n, word, glyph, fg) => {
348
+ if (!n) return;
349
+ if (chip.length) chip.push({ text: ' ', style: undefined });
350
+ chip.push({ text: `${glyph} ${n} ${word}`, style: { fg } });
351
+ };
352
+ seg(c.conflict, 'conflict', '✗', '31');
353
+ seg(c.staged, 'staged', '●', '32');
354
+ seg(c.modified, 'modified', '○', '33');
355
+ seg(c.untracked, 'untracked', '?', '90');
356
+ }
357
+ // Clip the left line so the right-flushed chip never overpaints its tail
358
+ // (mirror draw.list's leftLimit = width − rightWidth − 1).
359
+ const chipW = spanWidth(chip);
360
+ draw.spans(r, content.col, left, chipW ? Math.max(0, content.width - chipW - 1) : content.width);
361
+ draw.spansRight(r, right, chip);
362
+ r++;
363
+
364
+ // ── Commit line: ⊙ sha subject ............................ age (right-flush)
365
+ if (g.lastCommit) {
366
+ /** @type {import('../../core/tui/draw.js').Span[]} */
367
+ const commit = [
368
+ { text: ' ', style: undefined },
369
+ { text: '⊙ ', style: { fg: '90', dim: true } },
370
+ { text: g.lastCommit.sha, style: { fg: '33' } }, // yellow sha (git-native)
371
+ { text: ' ', style: undefined },
372
+ { text: g.lastCommit.subject, style: undefined },
373
+ ];
374
+ // Build the right-flushed age first, then clip the subject so the age never
375
+ // overpaints it (mirror draw.list's leftLimit).
376
+ const age = relAge(g.lastCommit.when, now);
377
+ /** @type {import('../../core/tui/draw.js').Span[]} */
378
+ const ageSpans = age ? [{ text: age, style: { fg: '90', dim: true } }] : [];
379
+ const ageW = spanWidth(ageSpans);
380
+ draw.spans(r, content.col, commit, ageW ? Math.max(0, content.width - ageW - 1) : content.width);
381
+ if (age) draw.spansRight(r, right, ageSpans);
382
+ } else {
383
+ draw.spans(r, content.col, [
384
+ { text: ' ', style: undefined },
385
+ { text: '⊙ ', style: { fg: '90', dim: true } },
386
+ { text: 'no commits yet', style: { dim: true } },
387
+ ]);
388
+ }
389
+ r++;
390
+
391
+ // Hairline separating the gauges from the board (figure-ground, §3).
392
+ draw.hline(r, content.col, content.col + content.width);
393
+ r++;
394
+
395
+ return r - content.row;
396
+ }
397
+
398
+ // ── Refresh (data lane) ────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Read git + PR state, rebuild the board. Runs in the host's single-flight lane.
402
+ * Maps any git failure to guidance (a banner + the freshness chip) and KEEPS the
403
+ * last-known board on a transient git-failed. PR failures degrade the PR section
404
+ * only (an inline note) — the git section always renders.
405
+ * @param {GitPrState} state
406
+ * @param {import('../../core/tui/contract.js').ViewHost} host
407
+ * @returns {Promise<void>}
408
+ */
409
+ async function refresh(state, host) {
410
+ host.setStatus('Reading git…');
411
+
412
+ const g = await fetchGit();
413
+ if (!g.ok) {
414
+ state.gitError = g.error.message;
415
+ state.gitErrorKind = g.error.kind;
416
+ if (g.error.kind === 'git-failed' && state.git) {
417
+ // Transient — keep the last-known board; raise the cause as a banner.
418
+ host.setError(state.gitError);
419
+ } else {
420
+ // Hard not-ready (no repo / no git binary / first-load failure): drop data
421
+ // so render() shows the guided takeover. The takeover owns the whole rect
422
+ // (design §3) and already names the cause + next step, so DON'T stack a
423
+ // banner under it — clear any stale one. (Soft/partial cases — a transient
424
+ // git-failed that keeps the board, or gh-down — keep their banner below.)
425
+ state.git = null;
426
+ state.prs = [];
427
+ state.prNote = null;
428
+ state.board = buildBoard(state);
429
+ host.setError(null);
430
+ }
431
+ host.setStatus(null);
432
+ host.setSubtitle(subtitleFor(state));
433
+ state.lastFetch = Date.now();
434
+ return;
435
+ }
436
+ state.gitError = null;
437
+ state.gitErrorKind = null;
438
+ state.git = g.data;
439
+
440
+ // PRs — best-effort. A typed error becomes the PR section's guided note.
441
+ const p = await fetchPrs(g.data.branch);
442
+ if (p.ok) {
443
+ state.prs = p.data;
444
+ state.prNote = null;
445
+ } else {
446
+ state.prs = [];
447
+ state.prNote = p.error.message;
448
+ }
449
+
450
+ state.board = buildBoard(state);
451
+ if (state.cursor >= state.board.length) state.cursor = Math.max(0, state.board.length - 1);
452
+ state.lastFetch = Date.now();
453
+
454
+ host.setSubtitle(subtitleFor(state));
455
+
456
+ // Data-freshness → state chip. Something that needs eyes (conflicts / failing
457
+ // CI / changes-requested) → ACTION banner ⇒ attention (yellow). Else clear ⇒
458
+ // ready (green). (busy→working is automatic.)
459
+ const attn = attentionFor(state);
460
+ if (attn) host.setBanner(attn, 'action');
461
+ else host.setError(null);
462
+
463
+ host.setStatus(footerSummary(state));
464
+ }
465
+
466
+ // ── ViewModule ─────────────────────────────────────────────────────────────────
467
+
468
+ /** @type {import('../../core/tui/contract.js').ViewModule<GitPrState>} */
469
+ const view = {
470
+ manifest: {
471
+ id: 'git-pr',
472
+ title: 'Git / PR',
473
+ description: 'Local git state + GitHub PR/CI status for the repo at this cwd',
474
+ refreshMs: 5000,
475
+ keymap: [
476
+ { keys: 'j/k', label: 'move' },
477
+ { keys: 'g', label: 'refresh' },
478
+ { keys: 'q', label: 'quit' },
479
+ ],
480
+ },
481
+
482
+ /**
483
+ * Cheap + synchronous initial state — NO slow fetch (the host paints a loading
484
+ * frame, then calls refresh()).
485
+ * @returns {GitPrState}
486
+ */
487
+ init() {
488
+ return {
489
+ git: null,
490
+ gitError: null,
491
+ gitErrorKind: null,
492
+ prs: [],
493
+ prNote: null,
494
+ board: [],
495
+ cursor: 0,
496
+ scroll: 0,
497
+ lastFetch: 0,
498
+ };
499
+ },
500
+
501
+ refresh,
502
+
503
+ /**
504
+ * Paint the header + board, or one of the four standard states. Pure (reads
505
+ * state, calls draw.*); the only write is storing draw.list's adjusted scroll.
506
+ * @param {GitPrState} state
507
+ * @param {import('../../core/tui/draw.js').Draw} draw
508
+ * @param {import('../../core/tui/draw.js').Rect} content
509
+ */
510
+ render(state, draw, content) {
511
+ if (content.width <= 0 || content.height <= 0) return;
512
+
513
+ // ── Whole-screen takeovers (no git data to anchor a header) ──
514
+ if (!state.git) {
515
+ if (state.gitErrorKind === 'not-a-repo') {
516
+ notReadyState(draw, content, {
517
+ glyph: '⊙',
518
+ glyphFg: '33', // yellow — pairs with the action chip
519
+ headline: 'Not a git repository',
520
+ explanation: 'This view monitors a git repo, and the current directory is not one.',
521
+ nextStep: 'cd into a repository (or run `git init`), then press g.',
522
+ });
523
+ return;
524
+ }
525
+ if (state.gitErrorKind === 'git-missing') {
526
+ notReadyState(draw, content, {
527
+ glyph: '⚠',
528
+ glyphFg: '31', // red — pairs with the blocked chip
529
+ headline: 'git not found',
530
+ explanation: 'crtr could not find the git binary on PATH.',
531
+ nextStep: 'Install git, then press g.',
532
+ });
533
+ return;
534
+ }
535
+ if (state.lastFetch === 0) {
536
+ loadingState(draw, content, { rows: Math.min(5, content.height), label: 'Reading git…' });
537
+ return;
538
+ }
539
+ // git-failed on the first load (nothing to keep) → guided takeover.
540
+ notReadyState(draw, content, {
541
+ glyph: '⚠',
542
+ glyphFg: '31',
543
+ headline: 'Git unavailable',
544
+ explanation: state.gitError || 'A git command failed.',
545
+ nextStep: 'Press g to retry.',
546
+ });
547
+ return;
548
+ }
549
+
550
+ const now = Date.now();
551
+ const g = state.git;
552
+
553
+ // ── Header gauges (always on when we have git data) ──
554
+ // Need ≥3 rows for the 2-row header + hairline; below that, skip straight to
555
+ // the board so a tiny pane still shows the changes.
556
+ let bodyRow = content.row;
557
+ let bodyHeight = content.height;
558
+ if (content.height >= 4) {
559
+ const used = drawHeader(g, draw, content, now);
560
+ bodyRow = content.row + used + (content.height - used > 2 ? 1 : 0); // 1-row section gap when there's room
561
+ bodyHeight = content.row + content.height - bodyRow;
562
+ }
563
+ if (bodyHeight <= 0) return;
564
+ const bodyRect = { row: bodyRow, col: content.col, width: content.width, height: bodyHeight };
565
+
566
+ // ── Empty reward: clean tree + no open PRs + gh healthy → "All clear" ──
567
+ if (g.files.length === 0 && state.prs.length === 0 && !state.prNote) {
568
+ emptyState(draw, bodyRect, {
569
+ headline: 'All clear',
570
+ secondary: [`${g.branch} — clean working tree, no open PRs.`, 'Press g to refresh.'],
571
+ });
572
+ return;
573
+ }
574
+
575
+ // ── The board ──
576
+ const items = state.board.map((row) => rowToItem(row, now));
577
+ const res = draw.list(bodyRect, items, state.cursor, state.scroll);
578
+ state.scroll = res.scroll; // store adjusted scroll back (Draw.list contract)
579
+ },
580
+
581
+ /**
582
+ * Read-only navigation: j/k move the cursor, g refreshes, q quits. No async
583
+ * actions — this is a monitor, not a controller.
584
+ * @param {import('../../core/tui/contract.js').ViewKey} k
585
+ * @param {GitPrState} state
586
+ * @returns {import('../../core/tui/contract.js').ViewAction}
587
+ */
588
+ onKey(k, state) {
589
+ const key = k.key;
590
+ const ch = k.input;
591
+ if (ch === 'q') return { type: 'quit' };
592
+ if (ch === 'g') return { type: 'refresh' };
593
+ if (key.downArrow || ch === 'j') {
594
+ if (state.board.length) state.cursor = Math.min(state.board.length - 1, state.cursor + 1);
595
+ return { type: 'render' };
596
+ }
597
+ if (key.upArrow || ch === 'k') {
598
+ state.cursor = Math.max(0, state.cursor - 1);
599
+ return { type: 'render' };
600
+ }
601
+ return { type: 'none' };
602
+ },
603
+
604
+ /**
605
+ * Plain-text snapshot for the non-TTY / piped path. No ANSI. The host threads
606
+ * its current banner via `ctx` so guidance surfaces without mirroring it.
607
+ * @param {GitPrState} state
608
+ * @param {import('../../core/tui/contract.js').DumpContext} [ctx]
609
+ * @returns {string}
610
+ */
611
+ dump(state, ctx) {
612
+ /** @type {string[]} */
613
+ const lines = ['Git / PR board'];
614
+ const banner = ctx && ctx.banner ? ctx.banner : null;
615
+ if (banner) lines.push('', `[${banner.level}] ${banner.msg}`);
616
+ else if (state.gitError) lines.push('', `[error] ${state.gitError}`);
617
+
618
+ const g = state.git;
619
+ if (!g) {
620
+ lines.push('', state.lastFetch === 0 ? '(reading git…)' : '(git unavailable)');
621
+ return lines.join('\n');
622
+ }
623
+
624
+ const now = Date.now();
625
+
626
+ // Header.
627
+ let head = `⎇ ${g.branch}`;
628
+ if (g.upstream) {
629
+ head += ` → ${g.upstream}`;
630
+ if (g.ahead) head += ` ↑${g.ahead}`;
631
+ if (g.behind) head += ` ↓${g.behind}`;
632
+ if (!g.ahead && !g.behind) head += ' (up to date)';
633
+ } else {
634
+ head += ' (no upstream)';
635
+ }
636
+ head += ` · ${treePhrase(g)}`;
637
+ lines.push('', head);
638
+ if (g.lastCommit) {
639
+ lines.push(`⊙ ${g.lastCommit.sha} ${g.lastCommit.subject} (${relAge(g.lastCommit.when, now)})`);
640
+ } else {
641
+ lines.push('⊙ no commits yet');
642
+ }
643
+
644
+ // Working tree.
645
+ lines.push('', 'Working tree');
646
+ if (g.files.length === 0) {
647
+ lines.push(' ✓ nothing to commit, working tree clean');
648
+ } else {
649
+ for (const f of g.files) {
650
+ const churn = f.add || f.del ? ` +${f.add} −${f.del}` : '';
651
+ lines.push(` ${f.xy.replace(/ /g, '·')} ${f.path}${churn} [${f.cls}]`);
652
+ }
653
+ }
654
+
655
+ // Pull requests.
656
+ lines.push('', 'Pull requests');
657
+ if (state.prNote) {
658
+ lines.push(` · ${state.prNote}`);
659
+ } else if (state.prs.length === 0) {
660
+ lines.push(' · No open pull requests.');
661
+ } else {
662
+ for (const pr of state.prs) {
663
+ const mark = pr.current ? '*' : ' ';
664
+ const draft = pr.isDraft ? ' (draft)' : '';
665
+ lines.push(
666
+ ` ${mark}#${pr.number} ${pr.title}${draft} [review:${pr.review} ci:${pr.ci}] (${relAge(pr.updatedAt, now)})`
667
+ );
668
+ }
669
+ }
670
+
671
+ return lines.join('\n');
672
+ },
673
+ };
674
+
675
+ export default view;