@crouton-kit/crouter 0.3.28 → 0.3.30

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 (324) hide show
  1. package/dist/build-root.d.ts +15 -7
  2. package/dist/build-root.js +48 -34
  3. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  4. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  6. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  8. package/dist/builtin-memory/design.md +1 -1
  9. package/dist/builtin-memory/development.md +1 -1
  10. package/dist/builtin-memory/internal/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  12. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  13. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  14. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  15. package/dist/builtin-memory/planning.md +1 -1
  16. package/dist/builtin-memory/spec.md +6 -4
  17. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  18. package/dist/builtin-personas/runtime-base.md +5 -4
  19. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  20. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  21. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  22. package/dist/builtin-views/canvas/core.mjs +586 -0
  23. package/dist/builtin-views/canvas/text.mjs +58 -0
  24. package/dist/builtin-views/canvas/tui.mjs +165 -0
  25. package/dist/builtin-views/canvas/web.jsx +120 -0
  26. package/dist/builtin-views/git-pr/core.mjs +673 -0
  27. package/dist/builtin-views/git-pr/text.mjs +84 -0
  28. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  29. package/dist/builtin-views/git-pr/web.jsx +216 -0
  30. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  31. package/dist/builtin-views/inbox/core.mjs +1273 -0
  32. package/dist/builtin-views/inbox/text.mjs +73 -0
  33. package/dist/builtin-views/inbox/tui.mjs +312 -0
  34. package/dist/builtin-views/inbox/web.jsx +188 -0
  35. package/dist/builtin-views/linkedin/core.mjs +906 -0
  36. package/dist/builtin-views/linkedin/text.mjs +69 -0
  37. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  38. package/dist/builtin-views/linkedin/web.jsx +206 -0
  39. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  40. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  41. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  42. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  43. package/dist/cli.js +22 -17
  44. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  45. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  46. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  47. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  48. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  51. package/dist/clients/attach/attach-cmd.js +282 -46
  52. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  53. package/dist/clients/attach/auth-pickers.js +194 -0
  54. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  55. package/dist/clients/attach/canvas-panels.js +45 -17
  56. package/dist/clients/attach/chat-view.d.ts +27 -2
  57. package/dist/clients/attach/chat-view.js +98 -9
  58. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  59. package/dist/clients/attach/clipboard-image.js +150 -26
  60. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  61. package/dist/clients/attach/clipboard-text.js +39 -0
  62. package/dist/clients/attach/config-load.d.ts +54 -7
  63. package/dist/clients/attach/config-load.js +99 -3
  64. package/dist/clients/attach/context-message.d.ts +12 -0
  65. package/dist/clients/attach/context-message.js +72 -0
  66. package/dist/clients/attach/git-info.d.ts +20 -0
  67. package/dist/clients/attach/git-info.js +44 -0
  68. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  69. package/dist/clients/attach/graph-overlay.js +59 -19
  70. package/dist/clients/attach/input-controller.d.ts +75 -13
  71. package/dist/clients/attach/input-controller.js +305 -67
  72. package/dist/clients/attach/pickers.d.ts +58 -0
  73. package/dist/clients/attach/pickers.js +171 -0
  74. package/dist/clients/attach/slash-commands.d.ts +22 -0
  75. package/dist/clients/attach/slash-commands.js +91 -25
  76. package/dist/clients/attach/titled-editor.d.ts +33 -0
  77. package/dist/clients/attach/titled-editor.js +84 -0
  78. package/dist/clients/attach/view-socket.d.ts +18 -1
  79. package/dist/clients/attach/view-socket.js +70 -1
  80. package/dist/clients/web/dev-server.d.ts +7 -0
  81. package/dist/clients/web/dev-server.js +59 -0
  82. package/dist/clients/web/events.d.ts +14 -0
  83. package/dist/clients/web/events.js +151 -0
  84. package/dist/clients/web/server.d.ts +18 -0
  85. package/dist/clients/web/server.js +450 -0
  86. package/dist/clients/web/web-cmd.d.ts +2 -0
  87. package/dist/clients/web/web-cmd.js +120 -0
  88. package/dist/commands/canvas.js +1 -2
  89. package/dist/commands/chord.js +4 -25
  90. package/dist/commands/dashboard.js +5 -1
  91. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  92. package/dist/commands/memory/lint.js +14 -2
  93. package/dist/commands/memory/read.js +2 -2
  94. package/dist/commands/memory/shared.d.ts +7 -1
  95. package/dist/commands/memory/shared.js +18 -3
  96. package/dist/commands/memory/write.js +8 -4
  97. package/dist/commands/memory.js +4 -4
  98. package/dist/commands/node.d.ts +1 -1
  99. package/dist/commands/node.js +64 -65
  100. package/dist/commands/pkg.js +1 -2
  101. package/dist/commands/revive.js +11 -3
  102. package/dist/commands/sys/sync.d.ts +1 -0
  103. package/dist/commands/sys/sync.js +187 -0
  104. package/dist/commands/sys.js +3 -2
  105. package/dist/commands/view-cycle.js +2 -2
  106. package/dist/commands/view-list.js +8 -8
  107. package/dist/commands/view-new.js +21 -17
  108. package/dist/commands/view-pick.js +1 -1
  109. package/dist/commands/view-run.js +35 -14
  110. package/dist/commands/view.js +8 -6
  111. package/dist/commands/workspace.d.ts +2 -0
  112. package/dist/commands/workspace.js +161 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  114. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  116. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  117. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  118. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  119. package/dist/core/__tests__/close.test.js +14 -20
  120. package/dist/core/__tests__/context-intro.test.js +19 -19
  121. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  122. package/dist/core/__tests__/draw-style.test.js +1 -1
  123. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  124. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  125. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  126. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  127. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  128. package/dist/core/__tests__/focuses.test.js +25 -13
  129. package/dist/core/__tests__/fork.test.js +22 -5
  130. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  131. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  133. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  135. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  136. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  137. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  138. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  139. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  140. package/dist/core/__tests__/helpers/harness.js +19 -36
  141. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  142. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  143. package/dist/core/__tests__/lifecycle.test.js +1 -19
  144. package/dist/core/__tests__/listing-completeness.test.js +2 -2
  145. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  146. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  147. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  148. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  149. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  150. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  151. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  152. package/dist/core/__tests__/reset.test.js +19 -79
  153. package/dist/core/__tests__/steer-note.test.js +9 -7
  154. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  155. package/dist/core/bootstrap.d.ts +0 -3
  156. package/dist/core/bootstrap.js +1 -143
  157. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  158. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  159. package/dist/core/canvas/browse/app.js +131 -8
  160. package/dist/core/canvas/browse/model.d.ts +23 -5
  161. package/dist/core/canvas/browse/model.js +68 -13
  162. package/dist/core/canvas/browse/render.d.ts +7 -1
  163. package/dist/core/canvas/browse/render.js +178 -47
  164. package/dist/core/canvas/canvas.js +1 -1
  165. package/dist/core/canvas/focuses.d.ts +4 -4
  166. package/dist/core/canvas/focuses.js +7 -7
  167. package/dist/core/canvas/nav-model.js +8 -7
  168. package/dist/core/canvas/render.d.ts +68 -9
  169. package/dist/core/canvas/render.js +195 -61
  170. package/dist/core/canvas/types.d.ts +16 -23
  171. package/dist/core/config.js +1 -4
  172. package/dist/core/memory-resolver.js +43 -5
  173. package/dist/core/runtime/bearings.d.ts +1 -1
  174. package/dist/core/runtime/bearings.js +8 -8
  175. package/dist/core/runtime/branded-host.d.ts +16 -0
  176. package/dist/core/runtime/branded-host.js +127 -0
  177. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  178. package/dist/core/runtime/broker.d.ts +4 -1
  179. package/dist/core/runtime/broker.js +494 -58
  180. package/dist/core/runtime/close.d.ts +12 -2
  181. package/dist/core/runtime/close.js +37 -16
  182. package/dist/core/runtime/front-door.js +6 -14
  183. package/dist/core/runtime/host.d.ts +14 -34
  184. package/dist/core/runtime/host.js +14 -50
  185. package/dist/core/runtime/launch.d.ts +12 -9
  186. package/dist/core/runtime/launch.js +27 -15
  187. package/dist/core/runtime/lifecycle.d.ts +1 -1
  188. package/dist/core/runtime/lifecycle.js +15 -19
  189. package/dist/core/runtime/nodes.d.ts +0 -55
  190. package/dist/core/runtime/nodes.js +6 -74
  191. package/dist/core/runtime/placement.d.ts +91 -335
  192. package/dist/core/runtime/placement.js +291 -858
  193. package/dist/core/runtime/promote.d.ts +2 -0
  194. package/dist/core/runtime/promote.js +24 -6
  195. package/dist/core/runtime/recap.d.ts +8 -0
  196. package/dist/core/runtime/recap.js +107 -0
  197. package/dist/core/runtime/recycle.js +25 -61
  198. package/dist/core/runtime/reset.d.ts +43 -37
  199. package/dist/core/runtime/reset.js +131 -218
  200. package/dist/core/runtime/revive.d.ts +9 -29
  201. package/dist/core/runtime/revive.js +40 -139
  202. package/dist/core/runtime/spawn.d.ts +15 -16
  203. package/dist/core/runtime/spawn.js +165 -194
  204. package/dist/core/runtime/surface-bg.d.ts +11 -0
  205. package/dist/core/runtime/surface-bg.js +68 -0
  206. package/dist/core/runtime/tmux.d.ts +15 -52
  207. package/dist/core/runtime/tmux.js +35 -99
  208. package/dist/core/scope.d.ts +1 -2
  209. package/dist/core/scope.js +1 -5
  210. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  211. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  212. package/dist/core/skill-sync/builtins.d.ts +42 -0
  213. package/dist/core/skill-sync/builtins.js +112 -0
  214. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  215. package/dist/core/skill-sync/claude-plugins.js +71 -0
  216. package/dist/core/skill-sync/engine.d.ts +42 -0
  217. package/dist/core/skill-sync/engine.js +633 -0
  218. package/dist/core/skill-sync/export.d.ts +23 -0
  219. package/dist/core/skill-sync/export.js +86 -0
  220. package/dist/core/skill-sync/manifest.d.ts +64 -0
  221. package/dist/core/skill-sync/manifest.js +181 -0
  222. package/dist/core/skill-sync/profile.d.ts +76 -0
  223. package/dist/core/skill-sync/profile.js +173 -0
  224. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  225. package/dist/core/skill-sync/snapshot.js +120 -0
  226. package/dist/core/substrate/index.d.ts +1 -1
  227. package/dist/core/substrate/index.js +1 -1
  228. package/dist/core/substrate/injected-store.js +3 -3
  229. package/dist/core/substrate/on-read.js +68 -6
  230. package/dist/core/substrate/render.d.ts +8 -11
  231. package/dist/core/substrate/render.js +29 -43
  232. package/dist/core/substrate/schema.d.ts +10 -3
  233. package/dist/core/substrate/schema.js +6 -3
  234. package/dist/core/tui/host.d.ts +12 -4
  235. package/dist/core/tui/host.js +280 -149
  236. package/dist/core/view/bridge.d.ts +10 -0
  237. package/dist/core/view/bridge.js +31 -0
  238. package/dist/core/view/chrome.d.ts +9 -0
  239. package/dist/core/view/chrome.js +22 -0
  240. package/dist/core/view/contract.d.ts +171 -0
  241. package/dist/core/view/contract.js +23 -0
  242. package/dist/core/view/loader.d.ts +31 -0
  243. package/dist/core/view/loader.js +188 -0
  244. package/dist/core/view/transport-local.d.ts +7 -0
  245. package/dist/core/view/transport-local.js +70 -0
  246. package/dist/core/view/transport.d.ts +4 -0
  247. package/dist/core/view/transport.js +15 -0
  248. package/dist/daemon/crtrd.d.ts +38 -27
  249. package/dist/daemon/crtrd.js +303 -376
  250. package/dist/daemon/manage.js +6 -1
  251. package/dist/index.js +1 -1
  252. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  253. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  254. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  255. package/dist/pi-extensions/canvas-nav.js +5 -0
  256. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  257. package/dist/pi-extensions/canvas-recap.js +236 -0
  258. package/dist/pi-extensions/canvas-resume.js +2 -1
  259. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  260. package/dist/pi-extensions/canvas-stophook.js +87 -101
  261. package/dist/pi-extensions/canvas-view.js +2 -1
  262. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  263. package/dist/pi-extensions/widget-order-bus.js +34 -0
  264. package/dist/prompts/view.d.ts +2 -2
  265. package/dist/prompts/view.js +145 -73
  266. package/dist/types.d.ts +1 -6
  267. package/dist/types.js +1 -3
  268. package/dist/web/ViewChrome.d.ts +7 -0
  269. package/dist/web/ViewChrome.js +28 -0
  270. package/dist/web/ViewPane.d.ts +39 -0
  271. package/dist/web/ViewPane.js +48 -0
  272. package/dist/web/index.d.ts +6 -0
  273. package/dist/web/index.js +16 -0
  274. package/dist/web/runtime.d.ts +39 -0
  275. package/dist/web/runtime.js +133 -0
  276. package/dist/web/states.d.ts +24 -0
  277. package/dist/web/states.js +24 -0
  278. package/dist/web/transport-http.d.ts +5 -0
  279. package/dist/web/transport-http.js +28 -0
  280. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  281. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  282. package/dist/web-client/index.html +13 -0
  283. package/package.json +20 -6
  284. package/dist/builtin-views/canvas/client.mjs +0 -303
  285. package/dist/builtin-views/canvas/view.mjs +0 -576
  286. package/dist/builtin-views/git-pr/client.mjs +0 -440
  287. package/dist/builtin-views/git-pr/view.mjs +0 -675
  288. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  289. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  290. package/dist/builtin-views/inbox/view.mjs +0 -889
  291. package/dist/builtin-views/linkedin/client.mjs +0 -610
  292. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  293. package/dist/commands/pkg/bridge.d.ts +0 -1
  294. package/dist/commands/pkg/bridge.js +0 -137
  295. package/dist/commands/skill/author.d.ts +0 -3
  296. package/dist/commands/skill/author.js +0 -140
  297. package/dist/commands/skill/shared.d.ts +0 -3
  298. package/dist/commands/skill/shared.js +0 -19
  299. package/dist/commands/skill.d.ts +0 -2
  300. package/dist/commands/skill.js +0 -21
  301. package/dist/commands/tmux-spread.d.ts +0 -2
  302. package/dist/commands/tmux-spread.js +0 -144
  303. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  304. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  305. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  306. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  307. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  308. package/dist/core/__tests__/home-session.test.js +0 -186
  309. package/dist/core/__tests__/relaunch.test.js +0 -335
  310. package/dist/core/bridge-map.d.ts +0 -19
  311. package/dist/core/bridge-map.js +0 -73
  312. package/dist/core/tui/contract.d.ts +0 -83
  313. package/dist/core/tui/contract.js +0 -8
  314. package/dist/core/tui/loader.d.ts +0 -16
  315. package/dist/core/tui/loader.js +0 -94
  316. package/dist/prompts/skill.d.ts +0 -2
  317. package/dist/prompts/skill.js +0 -650
  318. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  323. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  324. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -1,889 +0,0 @@
1
- // @ts-check
2
- /**
3
- * Combined `inbox` view — triage LinkedIn messages + Gmail in one ranked list.
4
- *
5
- * Self-contained ESM. Default-exports a ViewModule (see
6
- * `../../core/tui/contract.ts`). The host injects `Draw` + `ViewHost`; this
7
- * module imports NOTHING from crtr — only sibling `.mjs` (its Source adapters,
8
- * the shared render helpers, and the standard-state bodies) + Node builtins.
9
- *
10
- * The view owns the MERGE, the screen, the chrome, and the keymap; each Source
11
- * (`./sources/<id>.mjs`) owns its data fetching AND its own discover→auth→settle
12
- * recovery state machine. Partial readiness is mandatory: a down source
13
- * contributes zero rows + its own slim banner; every ready source's rows still
14
- * show. A guided full-content panel appears ONLY when NO source produced rows.
15
- *
16
- * SGR discipline (§2): all hue is NUMERIC SGR codes; every colored element pairs
17
- * hue with a glyph or weight so it survives NO_COLOR / dumb terminals.
18
- *
19
- * @module inbox/view
20
- */
21
-
22
- import linkedinSource from './sources/linkedin.mjs';
23
- import { loadingState, emptyState, errorState, notReadyState } from '../_lib/states.mjs';
24
- import {
25
- truncate,
26
- padEnd,
27
- toLinesArr,
28
- spanWidth,
29
- centeredStack,
30
- splitPanes,
31
- relTimestamp,
32
- dayKey,
33
- dayLabel,
34
- wrapText,
35
- isPrintable,
36
- } from './_lib/render.mjs';
37
-
38
- // ── Sources ──────────────────────────────────────────────────────────────────
39
- //
40
- // Gmail is built concurrently by a sibling node. Import it dynamically + guarded
41
- // so the view still loads (and `crtr view run inbox` exits 0) before that file
42
- // lands; once present it drops straight into the `sources` array. A missing or
43
- // malformed source simply contributes nothing — never a crash.
44
- let gmailSource = null;
45
- try {
46
- const mod = await import('./sources/gmail.mjs');
47
- gmailSource = (mod && (mod.default || mod.gmailSource)) || null;
48
- } catch {
49
- gmailSource = null;
50
- }
51
-
52
- /** Ordered source list (LinkedIn first). Filtered of any absent source. */
53
- const sources = [linkedinSource, gmailSource].filter(Boolean);
54
-
55
- /** id → Source, for badge lookup + detail/reply dispatch by row.sourceId. */
56
- const SOURCE_BY_ID = {};
57
- for (const s of sources) SOURCE_BY_ID[s.id] = s;
58
-
59
- /** @param {string} id @returns {any} */
60
- function sourceById(id) {
61
- return SOURCE_BY_ID[id] || null;
62
- }
63
-
64
- /** @param {string} id @returns {{glyph:string, fg:string}} */
65
- function badgeFor(id) {
66
- const s = SOURCE_BY_ID[id];
67
- return (s && s.badge) || { glyph: '?', fg: '37' };
68
- }
69
-
70
- /** Fixed emoji set for the react picker. */
71
- const EMOJIS = ['👍', '❤️', '😂', '🔥', '👏', '😮'];
72
-
73
- /** Severity rank for choosing one banner / one guided panel across sources. */
74
- const LEVEL_RANK = { error: 3, action: 2, info: 1 };
75
-
76
- /**
77
- * Prefix a source's banner with its label — `Gmail: log in, then press g`. The
78
- * contract says the source banner is bare and the VIEW prepends the label, but
79
- * some sources bake the label in; this stays robust either way by not
80
- * double-prefixing when the text already leads with `<label>:`.
81
- * @param {string} label @param {string} banner @returns {string}
82
- */
83
- function labeled(label, banner) {
84
- const b = String(banner == null ? '' : banner);
85
- const lead = `${label}:`.toLowerCase();
86
- return b.toLowerCase().startsWith(lead) ? b : `${label}: ${b}`;
87
- }
88
-
89
- // ── State ────────────────────────────────────────────────────────────────────
90
-
91
- /**
92
- * @typedef {Object} InboxState
93
- * @property {Record<string, any>} subs Per-source private substate (state.subs[id]).
94
- * @property {Record<string, object[]>} rowsBySource Last listRows() per source (ready sources only).
95
- * @property {object[]} rows The merged, sorted UnifiedRow[] (filtered).
96
- * @property {number} cursor Left-pane cursor into rows.
97
- * @property {number} scroll draw.list scroll for the left pane.
98
- * @property {string|null} openKey key of the open row (right pane).
99
- * @property {object|null} openRow The open UnifiedRow (source dispatch + ref).
100
- * @property {object|null} thread Loaded UnifiedThread, or null.
101
- * @property {number} threadScroll Computed top line of the thread window.
102
- * @property {'list'|'reply'|'react'} mode Input mode.
103
- * @property {string} draft Reply input buffer.
104
- * @property {number} reactCursor Index into EMOJIS.
105
- * @property {'all'|string} filter Source filter (All → each source id).
106
- * @property {Record<string, object|null>} banners Per-source down-state SourceError (or null).
107
- * @property {Record<string, boolean>} ready Per-source readiness.
108
- * @property {number} lastFetch Epoch ms of the last refresh (0 ⇒ first paint).
109
- */
110
-
111
- /** Sort the merged set unread-first then ts desc. Mutates `arr`. @param {object[]} arr */
112
- function sortRows(arr) {
113
- arr.sort((a, b) => {
114
- if (!!a.unread !== !!b.unread) return a.unread ? -1 : 1;
115
- return (b.ts || 0) - (a.ts || 0);
116
- });
117
- }
118
-
119
- /** Filter cycle: All, then each present source id. @returns {string[]} */
120
- function filterCycle() {
121
- return ['all', ...sources.map((s) => s.id)];
122
- }
123
-
124
- /**
125
- * Re-merge rowsBySource into the visible, sorted list — keeping the cursor on the
126
- * same row (by key) across refreshes/filters.
127
- * @param {InboxState} state
128
- */
129
- function mergeRows(state) {
130
- const prev = state.rows[state.cursor];
131
- const prevKey = prev && prev.key;
132
- /** @type {object[]} */
133
- let all = [];
134
- for (const s of sources) {
135
- if (state.filter !== 'all' && state.filter !== s.id) continue;
136
- const rs = state.rowsBySource[s.id] || [];
137
- all = all.concat(rs);
138
- }
139
- sortRows(all);
140
- state.rows = all;
141
- if (prevKey) {
142
- const i = all.findIndex((r) => r.key === prevKey);
143
- if (i >= 0) state.cursor = i;
144
- }
145
- if (state.cursor >= all.length) state.cursor = Math.max(0, all.length - 1);
146
- if (state.cursor < 0) state.cursor = 0;
147
- }
148
-
149
- /** Sum unread across READY sources (independent of the visible filter). */
150
- function unreadCount(state) {
151
- let n = 0;
152
- for (const s of sources) {
153
- if (!state.ready[s.id]) continue;
154
- for (const r of state.rowsBySource[s.id] || []) if (r.unread) n++;
155
- }
156
- return n;
157
- }
158
-
159
- /** Drive the live "N unread · <filter>" subtitle. */
160
- function updateSubtitle(state, host) {
161
- const n = unreadCount(state);
162
- /** @type {string[]} */
163
- const parts = [];
164
- if (n > 0) parts.push(`${n} unread`);
165
- if (state.filter !== 'all') {
166
- const s = SOURCE_BY_ID[state.filter];
167
- parts.push(`${s ? s.label : state.filter} only`);
168
- }
169
- host.setSubtitle(parts.length ? parts.join(' · ') : null);
170
- }
171
-
172
- /**
173
- * Collapse the per-source down-states into ONE host banner (the slim, label-
174
- * prefixed line). Clears the banner when every source is healthy.
175
- * @param {InboxState} state @param {object} host
176
- */
177
- function applyBanners(state, host) {
178
- /** @type {{label:string, d:any}[]} */
179
- const downs = [];
180
- for (const s of sources) {
181
- const e = state.banners[s.id];
182
- if (e && e.display) downs.push({ label: s.label || s.id, d: e.display });
183
- }
184
- if (downs.length === 0) {
185
- host.setError(null);
186
- return;
187
- }
188
- let level = 'info';
189
- for (const x of downs) if ((LEVEL_RANK[x.d.level] || 0) > (LEVEL_RANK[level] || 0)) level = x.d.level;
190
- const msg = downs.map((x) => labeled(x.label, x.d.banner)).join(' · ');
191
- host.setBanner(msg, /** @type {any} */ (level));
192
- }
193
-
194
- /**
195
- * Set a one-off banner for a per-action SourceError (open/reply/react). The next
196
- * refresh re-derives the persistent down-banner via applyBanners.
197
- * @param {object} host @param {any} src @param {{display?:any}} e
198
- */
199
- function bannerFromSource(host, src, e) {
200
- const d = e && e.display;
201
- if (d && d.banner) host.setBanner(labeled(src.label, d.banner), d.level || 'error');
202
- else host.setError(`${src.label}: error`);
203
- }
204
-
205
- /**
206
- * Pick the most-severe down-source display for the no-rows guided panel
207
- * (blocking states win decisively).
208
- * @param {InboxState} state @returns {{label:string, d:any}|null}
209
- */
210
- function pickGuided(state) {
211
- let best = null;
212
- let bestScore = -1;
213
- for (const s of sources) {
214
- const e = state.banners[s.id];
215
- if (!e || !e.display) continue;
216
- const score = (LEVEL_RANK[e.display.level] || 0) + (e.display.blocking ? 10 : 0);
217
- if (score > bestScore) {
218
- bestScore = score;
219
- best = { label: s.label || s.id, d: e.display };
220
- }
221
- }
222
- return best;
223
- }
224
-
225
- // ── Refresh (data lane) ──────────────────────────────────────────────────────
226
-
227
- /**
228
- * For each source: ensureReady → on ok listRows; merge + sort; set per-source
229
- * banners from their SourceError.display. Partial readiness: one down source
230
- * never blanks the view. Runs in the host's single-flight lane. Skips while
231
- * composing/reacting so a poll can't disrupt input.
232
- * @param {InboxState} state @param {object} host
233
- */
234
- async function refresh(state, host) {
235
- if (state.mode !== 'list') return;
236
- host.setStatus('Refreshing…');
237
- for (const s of sources) {
238
- const sub = state.subs[s.id];
239
- let er;
240
- try {
241
- er = await s.ensureReady(sub, host);
242
- } catch (e) {
243
- er = { ok: false, error: defensiveError('source error', e) };
244
- }
245
- if (er && er.ok) {
246
- state.ready[s.id] = true;
247
- state.banners[s.id] = null;
248
- let lr;
249
- try {
250
- lr = await s.listRows(sub);
251
- } catch (e) {
252
- lr = { ok: false, error: defensiveError('list failed', e) };
253
- }
254
- if (lr && lr.ok && Array.isArray(lr.data)) {
255
- state.rowsBySource[s.id] = lr.data;
256
- } else {
257
- state.rowsBySource[s.id] = [];
258
- state.banners[s.id] = (lr && lr.error) || defensiveError('list failed', null);
259
- }
260
- } else {
261
- state.ready[s.id] = false;
262
- state.rowsBySource[s.id] = [];
263
- state.banners[s.id] = (er && er.error) || defensiveError('not ready', null);
264
- }
265
- }
266
- mergeRows(state);
267
- host.setStatus(null);
268
- applyBanners(state, host);
269
- updateSubtitle(state, host);
270
- state.lastFetch = Date.now();
271
- }
272
-
273
- /** Build a SourceError-shaped object for a thrown/missing error (defensive). */
274
- function defensiveError(headline, e) {
275
- const msg = e && e.message ? String(e.message) : headline;
276
- return {
277
- kind: 'error',
278
- display: {
279
- headline,
280
- explanation: msg,
281
- nextStep: 'Press g to retry',
282
- banner: msg,
283
- level: 'error',
284
- blocking: false,
285
- },
286
- };
287
- }
288
-
289
- // ── Render ───────────────────────────────────────────────────────────────────
290
-
291
- /**
292
- * The no-rows guided full-content takeover, built from a down source's display.
293
- * @param {object} draw @param {object} content @param {{label:string, d:any}} guide
294
- */
295
- function renderGuided(draw, content, guide) {
296
- const d = guide.d;
297
- const headline = `${guide.label}: ${d.headline}`;
298
- if (d.level === 'error') {
299
- errorState(draw, content, {
300
- headline,
301
- cause: d.explanation,
302
- hint: d.nextStep || 'Press g to retry.',
303
- });
304
- return;
305
- }
306
- notReadyState(draw, content, {
307
- glyph: d.level === 'action' ? '⚠' : '⊙',
308
- glyphFg: d.level === 'action' ? '33' : '36',
309
- headline,
310
- explanation: d.explanation,
311
- nextStep: d.nextStep || undefined,
312
- });
313
- }
314
-
315
- /** Two-pane loading skeleton: dim placeholder rows left, a dim caption right. */
316
- function renderLoadingSkeleton(draw, content) {
317
- const { left, right } = splitPanes(draw, content);
318
- loadingState(draw, left, { rows: Math.min(5, Math.max(1, left.height)) });
319
- centeredStack(draw, right, [[{ text: 'Loading inbox…', style: { dim: true } }]]);
320
- }
321
-
322
- /**
323
- * Left pane — the merged row list: cursor ▸ · unread ● · source badge · name ·
324
- * dim snippet · right-flush relative ts.
325
- * @param {InboxState} state @param {object} draw @param {object} left
326
- */
327
- function renderRowList(state, draw, left) {
328
- if (left.width <= 0 || left.height <= 0) return;
329
- const items = state.rows.map((row, i) => {
330
- const isCursor = i === state.cursor;
331
- const b = badgeFor(row.sourceId);
332
- const glyph = padEnd(b.glyph || '?', 2); // 'in' / '@ ' — align names
333
- /** @type {object[]} */
334
- const spans = [
335
- { text: isCursor ? '▸' : ' ', style: isCursor ? { fg: '36', bold: true } : undefined },
336
- { text: row.unread ? '●' : ' ', style: row.unread ? { fg: '36', bold: true } : undefined },
337
- { text: ' ' },
338
- { text: glyph, style: { fg: b.fg || '37', bold: true } }, // badge: hue + glyph (mono-safe)
339
- { text: ' ' + (row.name || 'Unknown'), style: row.unread ? { bold: true } : undefined },
340
- ];
341
- const snip = (row.snippet || '').replace(/\s+/g, ' ').trim();
342
- if (snip) spans.push({ text: ' ' + snip, style: { dim: true } });
343
- /** @type {any} */
344
- const item = { spans };
345
- const ts = relTimestamp(row.ts);
346
- if (ts) item.right = [{ text: ts, style: { dim: true } }];
347
- return item;
348
- });
349
- const res = draw.list(left, items, state.cursor, state.scroll);
350
- state.scroll = res.scroll;
351
- }
352
-
353
- /**
354
- * Build the thread's flat visual lines with you-vs-them grouping. them = cyan
355
- * sender + dim right time + default body; you = green ▎ rail + green You +
356
- * rail-indented body. Day dividers between date changes; 1 spacer between groups.
357
- * @param {object[]} messages @param {number} width @param {number} reactTarget index, or -1
358
- * @returns {object[]}
359
- */
360
- function buildThreadLines(messages, width, reactTarget) {
361
- /** @type {object[]} */
362
- const lines = [];
363
- let prevDay = null;
364
- messages.forEach((m, idx) => {
365
- const day = dayKey(m.ts);
366
- if (m.ts && day !== prevDay) {
367
- const txt = `── ${dayLabel(m.ts)} ──`;
368
- const pad = Math.max(0, Math.floor((width - Array.from(txt).length) / 2));
369
- lines.push({ spans: [{ text: ' '.repeat(pad) + txt, style: { dim: true } }] });
370
- prevDay = day;
371
- }
372
- const tick = idx === reactTarget ? [{ text: '▸ ', style: { fg: '36', bold: true } }] : [];
373
- const ts = relTimestamp(m.ts);
374
- const right = ts ? [{ text: ts, style: { dim: true } }] : undefined;
375
- if (m.fromMe) {
376
- lines.push({
377
- spans: [...tick, { text: '▎ ', style: { fg: '32', bold: true } }, { text: 'You', style: { fg: '32', bold: true } }],
378
- right,
379
- });
380
- for (const bl of wrapText(m.text || '', Math.max(1, width - 2))) {
381
- lines.push({ spans: [{ text: '▎ ', style: { fg: '32' } }, { text: bl }] });
382
- }
383
- } else {
384
- lines.push({ spans: [...tick, { text: m.sender || 'Them', style: { fg: '36', bold: true } }], right });
385
- for (const bl of wrapText(m.text || '', width)) {
386
- lines.push({ spans: [{ text: bl }] });
387
- }
388
- }
389
- if (idx < messages.length - 1) lines.push({ spans: [{ text: '' }] }); // spacer BETWEEN groups
390
- });
391
- return lines;
392
- }
393
-
394
- /** Paint the thread body (tail-windowed) into `rect`. */
395
- function renderThreadBody(state, draw, rect, reactTarget) {
396
- if (rect.width <= 0 || rect.height <= 0) return;
397
- const messages = (state.thread && state.thread.messages) || [];
398
- const lines = buildThreadLines(messages, rect.width, reactTarget);
399
- const start = Math.max(0, lines.length - rect.height);
400
- state.threadScroll = start;
401
- let r = rect.row;
402
- for (let i = start; i < lines.length && r < rect.row + rect.height; i++, r++) {
403
- const ln = lines[i];
404
- if (ln.right && ln.right.length) {
405
- const rw = spanWidth(ln.right);
406
- draw.spans(r, rect.col, ln.spans, Math.max(0, rect.width - rw - 1));
407
- draw.spansRight(r, rect.col + rect.width, ln.right, rw);
408
- } else {
409
- draw.spans(r, rect.col, ln.spans, rect.width);
410
- }
411
- }
412
- }
413
-
414
- /** The lifted compose bar (reply mode): hairline + label + draft + cursor + hint. */
415
- function renderComposer(state, draw, right) {
416
- const hairRow = right.row + right.height - 3;
417
- const barRow = right.row + right.height - 2;
418
- const hintRow = right.row + right.height - 1;
419
- draw.hline(hairRow, right.col, right.col + right.width);
420
- const label = '✎ Reply ';
421
- const labelW = Array.from(label).length;
422
- const avail = Math.max(1, right.width - labelW - 1); // 1 cell reserved for the cursor
423
- let shown = state.draft;
424
- const arr = Array.from(shown);
425
- if (arr.length > avail - 1) shown = arr.slice(arr.length - (avail - 1)).join(''); // keep the tail/cursor visible
426
- draw.spans(barRow, right.col, [
427
- { text: label, style: { fg: '33', bold: true } },
428
- { text: shown },
429
- { text: '█', style: { fg: '33' } },
430
- ], right.width);
431
- draw.spans(hintRow, right.col, [
432
- { text: 'enter', style: { bold: true } }, { text: ' send', style: { dim: true } },
433
- { text: ' · ', style: { dim: true } },
434
- { text: 'esc', style: { bold: true } }, { text: ' cancel', style: { dim: true } },
435
- ], right.width);
436
- }
437
-
438
- /** The react picker bar (react mode): hairline + emoji chip row + hint. */
439
- function renderReactBar(state, draw, right) {
440
- const hairRow = right.row + right.height - 3;
441
- const barRow = right.row + right.height - 2;
442
- const hintRow = right.row + right.height - 1;
443
- draw.hline(hairRow, right.col, right.col + right.width);
444
- /** @type {object[]} */
445
- const spans = [{ text: '☺ React ', style: { fg: '33', bold: true } }];
446
- EMOJIS.forEach((e, i) => {
447
- if (i === state.reactCursor) {
448
- spans.push({ text: ' ' });
449
- spans.push({ text: '[' + e + ']', style: { bg: '236', reverse: true } }); // accent-bg + brackets/reverse (mono carrier)
450
- } else {
451
- spans.push({ text: ' ' + e + ' ' });
452
- }
453
- });
454
- draw.spans(barRow, right.col, spans, right.width);
455
- draw.spans(hintRow, right.col, [
456
- { text: '←/→', style: { bold: true } }, { text: ' pick', style: { dim: true } },
457
- { text: ' · ', style: { dim: true } },
458
- { text: 'enter', style: { bold: true } }, { text: ' react', style: { dim: true } },
459
- { text: ' · ', style: { dim: true } },
460
- { text: 'esc', style: { bold: true } }, { text: ' cancel', style: { dim: true } },
461
- ], right.width);
462
- }
463
-
464
- /**
465
- * Right pane — dispatch on the open row's source: header (+ optional subtitle) +
466
- * hairline + grouped body, plus the compose/react bar when in a mode.
467
- * @param {InboxState} state @param {object} draw @param {object} right
468
- */
469
- function renderDetail(state, draw, right) {
470
- if (right.width <= 0 || right.height <= 0) return;
471
- if (!state.openKey || !state.thread) {
472
- centeredStack(draw, right, [
473
- [{ text: '✉ ', style: { dim: true } }, { text: 'No conversation open', style: { dim: true } }],
474
- [{ text: '' }],
475
- [{ text: 'Press ' }, { text: 'Enter', style: { bold: true } }, { text: ' to open a conversation' }],
476
- ]);
477
- return;
478
- }
479
- const thread = state.thread;
480
- const messages = thread.messages || [];
481
- const hasSub = !!thread.subtitle;
482
-
483
- // Header: bold title + right-flush relative time of the latest message.
484
- const lastTs = messages.length ? messages[messages.length - 1].ts : (state.openRow ? state.openRow.ts : 0);
485
- const ts = relTimestamp(lastTs);
486
- const rw = ts ? Array.from(ts).length : 0;
487
- draw.spans(right.row, right.col, [{ text: thread.title || 'Conversation', style: { bold: true } }], Math.max(0, right.width - rw - 1));
488
- if (ts) draw.spansRight(right.row, right.col + right.width, [{ text: ts, style: { dim: true } }], rw);
489
- let headerRows = 1;
490
- if (hasSub) {
491
- draw.spans(right.row + 1, right.col, [{ text: thread.subtitle, style: { dim: true } }], right.width);
492
- headerRows = 2;
493
- }
494
- draw.hline(right.row + headerRows, right.col, right.col + right.width);
495
-
496
- const composing = state.mode === 'reply';
497
- const reacting = state.mode === 'react';
498
- const composerRows = composing || reacting ? 3 : 0;
499
- const bodyTop = right.row + headerRows + 1;
500
- const bodyBottom = right.row + right.height - 1 - composerRows;
501
- const bodyRect = { row: bodyTop, col: right.col, width: right.width, height: Math.max(0, bodyBottom - bodyTop + 1) };
502
-
503
- if (messages.length === 0) {
504
- centeredStack(draw, bodyRect, [[{ text: 'Loading messages…', style: { dim: true } }]]);
505
- } else {
506
- renderThreadBody(state, draw, bodyRect, reacting ? messages.length - 1 : -1);
507
- }
508
-
509
- if (composing) renderComposer(state, draw, right);
510
- else if (reacting) renderReactBar(state, draw, right);
511
- }
512
-
513
- // ── onKey handlers ───────────────────────────────────────────────────────────
514
-
515
- /** Open the row under the cursor: dispatch to its source's loadThread. */
516
- async function openThread(state, host) {
517
- const row = state.rows[state.cursor];
518
- if (!row) return { type: 'none' };
519
- const src = sourceById(row.sourceId);
520
- const sub = state.subs[row.sourceId];
521
- if (!src || !sub) {
522
- host.setError('Unknown source for this row.');
523
- return { type: 'render' };
524
- }
525
- state.openKey = row.key;
526
- state.openRow = row;
527
- state.thread = null;
528
- state.threadScroll = 0;
529
- host.setStatus('Loading thread…');
530
- let r;
531
- try {
532
- r = await src.loadThread(sub, row.ref);
533
- } catch (e) {
534
- r = { ok: false, error: defensiveError('could not load thread', e) };
535
- }
536
- host.setStatus(null);
537
- if (!r || !r.ok) {
538
- bannerFromSource(host, src, (r && r.error) || defensiveError('could not load thread', null));
539
- return { type: 'render' };
540
- }
541
- state.thread = r.data;
542
- row.unread = false; // optimistic; the source also clears its cache
543
- updateSubtitle(state, host);
544
- return { type: 'render' };
545
- }
546
-
547
- /** Send the current draft via the open row's source. */
548
- async function sendReply(state, host) {
549
- const text = state.draft.trim();
550
- if (!text) {
551
- state.mode = 'list';
552
- host.setMode(null);
553
- return { type: 'render' };
554
- }
555
- const row = state.openRow;
556
- const src = row ? sourceById(row.sourceId) : null;
557
- const sub = row ? state.subs[row.sourceId] : null;
558
- if (!row || !src || !sub) {
559
- host.setError('No open conversation to reply to.');
560
- state.mode = 'list';
561
- host.setMode(null);
562
- return { type: 'render' };
563
- }
564
- state.mode = 'list';
565
- host.setMode(null);
566
- host.setStatus('Sending…');
567
- let r;
568
- try {
569
- r = await src.reply(sub, row.ref, text);
570
- } catch (e) {
571
- r = { ok: false, error: defensiveError('send failed', e) };
572
- }
573
- if (!r || !r.ok) {
574
- host.setStatus(null);
575
- bannerFromSource(host, src, (r && r.error) || defensiveError('send failed', null));
576
- return { type: 'render' };
577
- }
578
- state.draft = '';
579
- // Reconcile by reloading the thread (optimistic refresh).
580
- try {
581
- const t = await src.loadThread(sub, row.ref);
582
- if (t && t.ok) state.thread = t.data;
583
- } catch { /* a thread reload failure is non-fatal */ }
584
- host.setStatus('Sent');
585
- applyBanners(state, host);
586
- return { type: 'render' };
587
- }
588
-
589
- /** React to the latest message in the open thread (source must support react). */
590
- async function doReact(state, host) {
591
- const row = state.openRow;
592
- const src = row ? sourceById(row.sourceId) : null;
593
- const sub = row ? state.subs[row.sourceId] : null;
594
- state.mode = 'list';
595
- host.setMode(null);
596
- if (!row || !src || !sub || typeof src.react !== 'function') {
597
- host.setError('Cannot react here.');
598
- return { type: 'render' };
599
- }
600
- const emoji = EMOJIS[state.reactCursor] || EMOJIS[0];
601
- host.setStatus('Reacting…');
602
- let r;
603
- try {
604
- r = await src.react(sub, row.ref, emoji);
605
- } catch (e) {
606
- r = { ok: false, error: defensiveError('react failed', e) };
607
- }
608
- if (!r || !r.ok) {
609
- host.setStatus(null);
610
- bannerFromSource(host, src, (r && r.error) || defensiveError('react failed', null));
611
- return { type: 'render' };
612
- }
613
- host.setStatus('Reacted ' + emoji);
614
- applyBanners(state, host);
615
- return { type: 'render' };
616
- }
617
-
618
- /** Run a source's manual connect() (bound to its connectKey). */
619
- async function runConnect(src, state, host) {
620
- const sub = state.subs[src.id];
621
- if (typeof src.connect !== 'function' || !sub) return { type: 'none' };
622
- host.setStatus(`Connecting ${src.label}…`);
623
- let r;
624
- try {
625
- r = await src.connect(sub, host);
626
- } catch (e) {
627
- r = { ok: false, error: defensiveError('connect failed', e) };
628
- }
629
- host.setStatus(null);
630
- if (!r || !r.ok) {
631
- state.banners[src.id] = (r && r.error) || defensiveError('connect failed', null);
632
- applyBanners(state, host);
633
- return { type: 'render' };
634
- }
635
- return { type: 'refresh' }; // re-run ensureReady against the (re)connected tab
636
- }
637
-
638
- /** Cycle the source filter: All → each source → back to All. */
639
- function cycleFilter(state, host) {
640
- const cyc = filterCycle();
641
- const i = cyc.indexOf(state.filter);
642
- state.filter = cyc[(i + 1) % cyc.length] || 'all';
643
- mergeRows(state);
644
- updateSubtitle(state, host);
645
- }
646
-
647
- /** List-mode keystrokes. */
648
- function onKeyList(k, state, host) {
649
- const key = k.key;
650
- const ch = k.input;
651
-
652
- if (ch === 'q') return { type: 'quit' };
653
- if (ch === 'g') return { type: 'refresh' };
654
-
655
- if (key.downArrow || ch === 'j') {
656
- if (state.rows.length) state.cursor = Math.min(state.rows.length - 1, state.cursor + 1);
657
- return { type: 'render' };
658
- }
659
- if (key.upArrow || ch === 'k') {
660
- state.cursor = Math.max(0, state.cursor - 1);
661
- return { type: 'render' };
662
- }
663
- if (key.return) return openThread(state, host);
664
- if (ch === 'f') {
665
- cycleFilter(state, host);
666
- return { type: 'render' };
667
- }
668
- if (ch === 'r') {
669
- if (!state.openKey || !state.thread) {
670
- host.setBanner('Open a conversation first', 'action');
671
- return { type: 'render' };
672
- }
673
- if (!state.thread.canReply) return { type: 'none' };
674
- state.mode = 'reply';
675
- state.draft = '';
676
- host.setMode('compose');
677
- return { type: 'render' };
678
- }
679
- if (ch === 'e') {
680
- if (!state.openKey || !state.thread) {
681
- host.setBanner('Open a conversation first', 'action');
682
- return { type: 'render' };
683
- }
684
- if (!state.thread.canReact || (state.thread.messages || []).length === 0) return { type: 'none' };
685
- state.mode = 'react';
686
- state.reactCursor = 0;
687
- host.setMode('react');
688
- return { type: 'render' };
689
- }
690
- // Per-source connect keys (e.g. G → gmail.connect(), L → linkedin.connect()).
691
- for (const s of sources) {
692
- if (s.connectKey && ch === s.connectKey) return runConnect(s, state, host);
693
- }
694
- return { type: 'none' };
695
- }
696
-
697
- /** Reply-mode keystrokes. */
698
- function onKeyReply(k, state, host) {
699
- const key = k.key;
700
- if (key.escape) {
701
- state.mode = 'list';
702
- state.draft = '';
703
- host.setMode(null);
704
- return { type: 'render' };
705
- }
706
- if (key.return) return sendReply(state, host);
707
- if (key.backspace) {
708
- state.draft = state.draft.slice(0, -1);
709
- return { type: 'render' };
710
- }
711
- if (isPrintable(k)) {
712
- state.draft += k.input;
713
- return { type: 'render' };
714
- }
715
- return { type: 'none' };
716
- }
717
-
718
- /** React-mode keystrokes. */
719
- function onKeyReact(k, state, host) {
720
- const key = k.key;
721
- const ch = k.input;
722
- if (key.escape) {
723
- state.mode = 'list';
724
- host.setMode(null);
725
- return { type: 'render' };
726
- }
727
- if (key.leftArrow || ch === 'h') {
728
- state.reactCursor = Math.max(0, state.reactCursor - 1);
729
- return { type: 'render' };
730
- }
731
- if (key.rightArrow || ch === 'l') {
732
- state.reactCursor = Math.min(EMOJIS.length - 1, state.reactCursor + 1);
733
- return { type: 'render' };
734
- }
735
- if (key.return) return doReact(state, host);
736
- return { type: 'none' };
737
- }
738
-
739
- // ── ViewModule ───────────────────────────────────────────────────────────────
740
-
741
- /** @type {import('../../core/tui/contract.js').ViewModule<InboxState>} */
742
- const view = {
743
- manifest: {
744
- id: 'inbox',
745
- title: 'Inbox',
746
- description: 'Combined inbox — triage LinkedIn + Gmail in one ranked list',
747
- refreshMs: 30000,
748
- keymap: [
749
- { keys: 'j/k', label: 'move' },
750
- { keys: 'enter', label: 'open' },
751
- { keys: 'r', label: 'reply' },
752
- { keys: 'e', label: 'react' },
753
- { keys: 'f', label: 'filter' },
754
- { keys: 'g', label: 'refresh' },
755
- { keys: 'G', label: 'connect' },
756
- { keys: 'q', label: 'quit' },
757
- ],
758
- },
759
-
760
- /**
761
- * Build initial state. Cheap + sync — each source seeds its own substate. NO
762
- * slow fetch (the host paints the loading skeleton, then calls refresh()).
763
- * @param {object} host @returns {InboxState}
764
- */
765
- init(host) {
766
- /** @type {Record<string, any>} */
767
- const subs = {};
768
- for (const s of sources) subs[s.id] = typeof s.init === 'function' ? s.init(host) : {};
769
- return {
770
- subs,
771
- rowsBySource: {},
772
- rows: [],
773
- cursor: 0,
774
- scroll: 0,
775
- openKey: null,
776
- openRow: null,
777
- thread: null,
778
- threadScroll: 0,
779
- mode: 'list',
780
- draft: '',
781
- reactCursor: 0,
782
- filter: 'all',
783
- banners: {},
784
- ready: {},
785
- lastFetch: 0,
786
- };
787
- },
788
-
789
- refresh,
790
-
791
- /**
792
- * Paint. Precedence: no rows → loading skeleton (first paint) / guided panel
793
- * (a down source) / empty reward; else the two-pane merged inbox.
794
- * @param {InboxState} state @param {object} draw @param {object} content
795
- */
796
- render(state, draw, content) {
797
- if (state.rows.length === 0) {
798
- if (state.lastFetch === 0) {
799
- renderLoadingSkeleton(draw, content);
800
- return;
801
- }
802
- const guide = pickGuided(state);
803
- if (guide) {
804
- renderGuided(draw, content, guide);
805
- return;
806
- }
807
- emptyState(draw, content, {
808
- headline: 'All caught up',
809
- secondary: ['No messages in your inbox.', 'Press g to refresh.'],
810
- });
811
- return;
812
- }
813
- const { left, right } = splitPanes(draw, content);
814
- renderRowList(state, draw, left);
815
- renderDetail(state, draw, right);
816
- },
817
-
818
- /**
819
- * One keystroke → next action. Dispatches by mode.
820
- * @param {object} k @param {InboxState} state @param {object} host
821
- */
822
- onKey(k, state, host) {
823
- switch (state.mode) {
824
- case 'reply':
825
- return onKeyReply(k, state, host);
826
- case 'react':
827
- return onKeyReact(k, state, host);
828
- default:
829
- return onKeyList(k, state, host);
830
- }
831
- },
832
-
833
- /**
834
- * Plain-text snapshot for the non-TTY / piped path (exit 0). No ANSI. Surfaces
835
- * the merged list + each down source's banner + the host's current banner.
836
- * @param {InboxState} state @param {{banner?: {msg:string, level:string}|null}} [ctx]
837
- * @returns {string}
838
- */
839
- dump(state, ctx) {
840
- const banner = ctx && ctx.banner ? ctx.banner : null;
841
- const sigil = (lvl) => (lvl === 'error' ? '✗' : lvl === 'action' ? '▸' : 'ℹ');
842
- /** @type {string[]} */
843
- const lines = [];
844
- const n = unreadCount(state);
845
- let head = 'Inbox';
846
- if (n) head += ` · ${n} unread`;
847
- if (state.filter !== 'all') {
848
- const s = SOURCE_BY_ID[state.filter];
849
- head += ` · ${s ? s.label : state.filter} only`;
850
- }
851
- lines.push(head, '');
852
-
853
- let anyDown = false;
854
- for (const s of sources) {
855
- const e = state.banners[s.id];
856
- if (e && e.display) {
857
- anyDown = true;
858
- lines.push(labeled(s.label, e.display.banner));
859
- }
860
- }
861
- if (anyDown) lines.push('');
862
-
863
- if (state.rows.length === 0) {
864
- if (state.lastFetch === 0) lines.push('(loading…)');
865
- else if (!anyDown) lines.push('✓ All caught up — no messages.');
866
- } else {
867
- for (const row of state.rows) {
868
- const badge = padEnd(badgeFor(row.sourceId).glyph, 2);
869
- const dot = row.unread ? '●' : ' ';
870
- const snip = truncate((row.snippet || '').replace(/\s+/g, ' ').trim(), 48);
871
- lines.push(`[${dot}] ${badge} ${padEnd(row.name || 'Unknown', 20)} ${padEnd(snip, 48)} ${relTimestamp(row.ts)}`);
872
- }
873
- }
874
-
875
- if (state.openKey && state.thread) {
876
- lines.push('', `— ${state.thread.title} —`);
877
- if (state.thread.subtitle) lines.push(state.thread.subtitle);
878
- for (const m of state.thread.messages || []) {
879
- const who = m.fromMe ? 'You' : m.sender || 'Them';
880
- lines.push(`${who}: ${(m.text || '').replace(/\s+/g, ' ').trim()}`);
881
- }
882
- }
883
-
884
- if (banner) lines.push('', sigil(banner.level) + ' ' + banner.msg);
885
- return lines.join('\n');
886
- },
887
- };
888
-
889
- export default view;