@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,1171 @@
1
+ // @ts-check
2
+ /**
3
+ * LinkedIn Messages — the crtr `linkedin` view, reskinned to the approved
4
+ * crtr-views visual + interaction design (§5 of crtr-views-visual-design.md).
5
+ *
6
+ * Self-contained ESM. Imports its data layer from `./client.mjs` and the shared
7
+ * standard-state body helpers from `../_lib/states.mjs` RELATIVELY — and imports
8
+ * NOTHING from crtr. The host injects the `Draw` + `ViewHost` API and dynamically
9
+ * `import()`s this module's DEFAULT EXPORT.
10
+ *
11
+ * Two-pane inbox (conversation list 1 : thread 2, split by a `vline`):
12
+ * • conversation rows: cursor tick ▸ · unread dot ● · name · dim snippet ·
13
+ * right-flush relative timestamp; sorted unread-first then newest.
14
+ * • thread: you-vs-them grouping (cyan them / green ▎ tick you), day dividers,
15
+ * tail-windowing; compose (`r`) + react (`e`) as first-class modes.
16
+ * • the not-ready / auto-open recovery state machine: each ClientError maps to
17
+ * a behavior + guided full-content panel + state chip; auto-fixes drive the
18
+ * browser to the inbox and bounded-poll for readiness — never a dead-end.
19
+ *
20
+ * SGR discipline (§2): all hue is NUMERIC SGR codes (36 cyan, 33 yellow, 32
21
+ * green, 31 red, 90 grey, bg 236); every colored element pairs hue with a glyph
22
+ * or weight so it survives NO_COLOR / dumb terminals.
23
+ *
24
+ * @module linkedin/view
25
+ */
26
+
27
+ import {
28
+ discoverTab,
29
+ getContext,
30
+ listConversations,
31
+ viewConversation,
32
+ sendMessage,
33
+ markConversationAsRead,
34
+ reactToMessage,
35
+ openMessagingTab,
36
+ navigateToMessaging,
37
+ } from './client.mjs';
38
+ import { loadingState, emptyState, errorState, notReadyState } from '../_lib/states.mjs';
39
+
40
+ /** @typedef {import('./client.mjs').LiContext} LiContext */
41
+ /** @typedef {import('./client.mjs').Conversation} Conversation */
42
+ /** @typedef {import('./client.mjs').Message} Message */
43
+ /** @typedef {import('./client.mjs').ClientError} ClientError */
44
+
45
+ /**
46
+ * A guided-recovery panel descriptor (the full-content takeover, §5). `null` when
47
+ * the view is ready (or keeping last-known content under a banner).
48
+ * @typedef {Object} Recovery
49
+ * @property {'guided'|'error'} variant 'error' → the ✗ red errorState block; else notReadyState.
50
+ * @property {string} glyph Panel glyph (overridden by the live spinner frame when `spinner`).
51
+ * @property {string} glyphFg Numeric SGR hue for the glyph ('36' working, '33'/'31' blocked).
52
+ * @property {string} headline Bold headline naming the state.
53
+ * @property {string|string[]} explanation Dim explanatory line(s).
54
+ * @property {string|null} nextStep The explicit call-to-action (default weight).
55
+ * @property {boolean} spinner Auto-progress: animate the glyph + show elapsed (Ns).
56
+ * @property {number} [startedAt] Epoch ms the auto-progress began (for the live (Ns) counter).
57
+ */
58
+
59
+ /**
60
+ * The view's single mutable state object. The view owns it; hooks mutate it in
61
+ * place.
62
+ * @typedef {Object} LiState
63
+ * @property {LiContext|null} ctx Cached after the first getContext().
64
+ * @property {string|null} target Discovered/opened CDP tab id (or options.target).
65
+ * @property {string|undefined} port options.port passthrough.
66
+ * @property {Conversation[]} convos Inbox, sorted unread-first then newest.
67
+ * @property {number} convCursor Index into convos (left pane cursor).
68
+ * @property {number} convScroll draw.list scroll for the left pane.
69
+ * @property {string|null} openUrn URN of the open conversation (right pane).
70
+ * @property {Message[]} thread Messages of the open conversation.
71
+ * @property {number} threadScroll Computed top line of the thread window.
72
+ * @property {'list'|'reply'|'react'} mode Input mode.
73
+ * @property {string} draft Reply input buffer (view owns input).
74
+ * @property {number} reactCursor Index into EMOJIS.
75
+ * @property {number} lastFetch Epoch ms of the last successful refresh.
76
+ * @property {Recovery|null} recovery Active guided-recovery panel (full-content takeover), or null.
77
+ * @property {boolean} loginTabOpened True once the login tab has been opened for the current not-logged-in episode; reset on ready so a later logout re-opens exactly once.
78
+ */
79
+
80
+ /** Fixed emoji set for the react picker. */
81
+ const EMOJIS = ['👍', '❤️', '😂', '🔥', '👏', '😮'];
82
+
83
+ /** How many conversations to request per refresh. */
84
+ const CONVO_COUNT = 25;
85
+
86
+ /** Settle-poll bounds (§5): retry readiness up to N times, spaced ~MS, after an
87
+ * auto open/navigate — a hard ceiling so the flow never spins forever. */
88
+ const SETTLE_MAX = 5;
89
+ const SETTLE_INTERVAL_MS = 1200;
90
+
91
+ /** ClientError kinds that waiting can't fix — stop the settle-poll and guide. */
92
+ const HARD_STOP = new Set(['no-cdp', 'not-logged-in', 'capture-not-dev']);
93
+
94
+ /** Spinner frames for the auto-progress panel glyph (animates via the busy-tick). */
95
+ const SPINNER = ['⟳', '⟲'];
96
+
97
+ /** Month abbreviations for the timestamp / day-divider ladder. */
98
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
99
+
100
+ /** @param {number} ms @returns {Promise<void>} */
101
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
102
+
103
+ // ── Helpers ──────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Sort conversations unread-first, then newest (ts desc). Non-mutating.
107
+ * @param {Conversation[]} convos
108
+ * @returns {Conversation[]}
109
+ */
110
+ function sortConvos(convos) {
111
+ return convos.slice().sort((a, b) => {
112
+ if (!!a.unread !== !!b.unread) return a.unread ? -1 : 1;
113
+ return (b.ts || 0) - (a.ts || 0);
114
+ });
115
+ }
116
+
117
+ /** @param {LiState} state @returns {{target:string|undefined, port:string|undefined}} */
118
+ function baseOpts(state) {
119
+ return { target: state.target || undefined, port: state.port };
120
+ }
121
+
122
+ /** @param {string} s @param {number} n @returns {string} */
123
+ function truncate(s, n) {
124
+ const str = String(s == null ? '' : s);
125
+ return str.length > n ? str.slice(0, Math.max(0, n - 1)) + '…' : str;
126
+ }
127
+
128
+ /** @param {string} s @param {number} n @returns {string} */
129
+ function padEnd(s, n) {
130
+ const str = String(s == null ? '' : s);
131
+ return str.length >= n ? str.slice(0, n) : str + ' '.repeat(n - str.length);
132
+ }
133
+
134
+ /** @param {string|string[]|null|undefined} v @returns {string[]} */
135
+ function toLinesArr(v) {
136
+ if (v == null) return [];
137
+ if (Array.isArray(v)) return v.map((s) => String(s == null ? '' : s));
138
+ return [String(v)];
139
+ }
140
+
141
+ /**
142
+ * Relative-timestamp ladder (§5), max ~5 cols: now / {m}m / {h}h / {d}d /
143
+ * `Mon D` (this year) / `Mon ʼYY` (prior year).
144
+ * @param {number} ts epoch ms (0 ⇒ '')
145
+ * @param {number} [now]
146
+ * @returns {string}
147
+ */
148
+ function relTimestamp(ts, now = Date.now()) {
149
+ if (!ts) return '';
150
+ const s = Math.floor((now - ts) / 1000);
151
+ if (s < 60) return 'now';
152
+ const m = Math.floor(s / 60);
153
+ if (m < 60) return `${m}m`;
154
+ const h = Math.floor(m / 60);
155
+ if (h < 24) return `${h}h`;
156
+ const d = Math.floor(h / 24);
157
+ if (d < 7) return `${d}d`;
158
+ const dt = new Date(ts);
159
+ const mon = MONTHS[dt.getMonth()] || '';
160
+ const cur = new Date(now);
161
+ if (dt.getFullYear() === cur.getFullYear()) return `${mon} ${dt.getDate()}`;
162
+ return `${mon} ʼ${String(dt.getFullYear()).slice(-2)}`;
163
+ }
164
+
165
+ /** Calendar-day key for day-divider grouping. @param {number} ts @returns {string} */
166
+ function dayKey(ts) {
167
+ if (!ts) return '';
168
+ const d = new Date(ts);
169
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
170
+ }
171
+
172
+ /** Human day label for the divider: Today / Yesterday / Mon D / Mon D, YYYY. */
173
+ function dayLabel(ts, now = Date.now()) {
174
+ const d = new Date(ts);
175
+ const n = new Date(now);
176
+ const same = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
177
+ if (same(d, n)) return 'Today';
178
+ if (same(d, new Date(now - 86400000))) return 'Yesterday';
179
+ const mon = MONTHS[d.getMonth()] || '';
180
+ if (d.getFullYear() === n.getFullYear()) return `${mon} ${d.getDate()}`;
181
+ return `${mon} ${d.getDate()}, ${d.getFullYear()}`;
182
+ }
183
+
184
+ /**
185
+ * Word-wrap text to a width (also hard-splits over-long words). Preserves
186
+ * explicit newlines as paragraph breaks.
187
+ * @param {string} text @param {number} width @returns {string[]}
188
+ */
189
+ function wrapText(text, width) {
190
+ const w = Math.max(1, width | 0);
191
+ /** @type {string[]} */
192
+ const out = [];
193
+ const paragraphs = String(text == null ? '' : text).split('\n');
194
+ for (const para of paragraphs) {
195
+ if (para.trim() === '') {
196
+ out.push('');
197
+ continue;
198
+ }
199
+ let line = '';
200
+ for (const word of para.split(/\s+/).filter(Boolean)) {
201
+ let wd = word;
202
+ while (wd.length > w) {
203
+ if (line) {
204
+ out.push(line);
205
+ line = '';
206
+ }
207
+ out.push(wd.slice(0, w));
208
+ wd = wd.slice(w);
209
+ }
210
+ if (line === '') line = wd;
211
+ else if (line.length + 1 + wd.length <= w) line += ' ' + wd;
212
+ else {
213
+ out.push(line);
214
+ line = wd;
215
+ }
216
+ }
217
+ if (line !== '') out.push(line);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Decide whether a keystroke is a printable character to append to the draft.
224
+ * @param {{input:string, key:any}} k @returns {boolean}
225
+ */
226
+ function isPrintable(k) {
227
+ const key = k.key || {};
228
+ if (key.ctrl || key.meta) return false;
229
+ if (key.return || key.escape || key.backspace || key.tab || key.shiftTab) return false;
230
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return false;
231
+ const ch = k.input;
232
+ if (!ch || ch.length === 0) return false;
233
+ const code = ch.codePointAt(0);
234
+ if (code == null) return false;
235
+ if (code < 0x20 || code === 0x7f) return false; // C0 controls + DEL
236
+ return true;
237
+ }
238
+
239
+ /** Visible (column) width of a span group. @param {import('../../core/tui/draw.js').Span[]} spans */
240
+ function spanWidth(spans) {
241
+ let n = 0;
242
+ for (const s of spans) n += Array.from(s.text).length;
243
+ return n;
244
+ }
245
+
246
+ /**
247
+ * Place a vertically + horizontally centered stack of span-lines in `rect`.
248
+ * @param {import('../../core/tui/draw.js').Draw} draw
249
+ * @param {import('../../core/tui/draw.js').Rect} rect
250
+ * @param {import('../../core/tui/draw.js').Span[][]} lines
251
+ */
252
+ function centeredStack(draw, rect, lines) {
253
+ if (!rect || rect.width <= 0 || rect.height <= 0 || lines.length === 0) return;
254
+ const start = rect.row + Math.max(0, Math.floor((rect.height - lines.length) / 2));
255
+ lines.forEach((spans, i) => {
256
+ const row = start + i;
257
+ if (row < rect.row || row >= rect.row + rect.height) return;
258
+ const w = spanWidth(spans);
259
+ const col = rect.col + Math.max(0, Math.floor((rect.width - w) / 2));
260
+ draw.spans(row, col, spans, rect.col + rect.width - col);
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Split the content rect into the 1:2 two-pane layout, drawing the `vline` rule
266
+ * between the panes (§5 / §2: a quiet separator, not a box). Returns the inner
267
+ * list/thread rects (the left list owns col 0 for its cursor tick = the gutter;
268
+ * the thread sits one cell right of the rule).
269
+ * @param {import('../../core/tui/draw.js').Draw} draw
270
+ * @param {import('../../core/tui/draw.js').Rect} content
271
+ * @returns {{left: import('../../core/tui/draw.js').Rect, right: import('../../core/tui/draw.js').Rect}}
272
+ */
273
+ function splitPanes(draw, content) {
274
+ const cols = draw.columns(content, [1, 2]);
275
+ const l = cols[0];
276
+ const r = cols[1];
277
+ const vcol = r.col; // the boundary column carries the rule
278
+ draw.vline(vcol, content.row, content.row + content.height);
279
+ const left = { row: content.row, col: l.col, width: Math.max(0, vcol - l.col - 1), height: content.height };
280
+ const right = { row: content.row, col: vcol + 2, width: Math.max(0, r.col + r.width - (vcol + 2)), height: content.height };
281
+ return { left, right };
282
+ }
283
+
284
+ // ── Error → guided recovery descriptor ──────────────────────────────────────
285
+
286
+ /**
287
+ * Map a typed {@link ClientError} to its recovery descriptor (the §5 state-
288
+ * machine table). `autoFix` marks the self-healing branches; the rest carry the
289
+ * guided panel text + the banner that drives the title state chip.
290
+ * @param {ClientError} error
291
+ * @returns {{autoFix?:'open'|'navigate', variant?:'error'|'inline', glyph?:string, glyphFg?:string,
292
+ * headline?:string, explanation?:string|string[], nextStep?:string, keepContent?:boolean,
293
+ * bannerMsg?:string, bannerLevel?:import('../../core/tui/contract.js').BannerLevel}}
294
+ */
295
+ function describeError(error) {
296
+ const kind = error && error.kind;
297
+ switch (kind) {
298
+ case 'no-tab':
299
+ return {
300
+ autoFix: 'open',
301
+ headline: 'Opening LinkedIn…',
302
+ explanation: 'No messaging tab was open — opening one and waiting for it to load.',
303
+ };
304
+ case 'not-messaging':
305
+ return {
306
+ autoFix: 'navigate',
307
+ headline: 'Opening your inbox…',
308
+ explanation: 'Found LinkedIn on another page — switching it to Messages.',
309
+ };
310
+ case 'not-logged-in':
311
+ return {
312
+ glyph: '⚠', glyphFg: '33',
313
+ headline: 'Log in to continue',
314
+ explanation: 'LinkedIn needs a sign-in in the browser.',
315
+ bannerMsg: 'Log in in the opened tab, then press r', bannerLevel: 'action',
316
+ };
317
+ case 'no-cdp':
318
+ return {
319
+ glyph: '⚠', glyphFg: '31',
320
+ headline: 'No debuggable browser',
321
+ explanation: 'crtr drives a browser over CDP and none is running.',
322
+ nextStep: 'Launch Arc, or Chrome with --remote-debugging-port=9222, then press r',
323
+ bannerMsg: 'No debuggable browser — launch one, then press r', bannerLevel: 'error',
324
+ };
325
+ case 'rate-limited':
326
+ return {
327
+ glyph: '⚠', glyphFg: '33',
328
+ headline: 'LinkedIn is throttling',
329
+ explanation: 'Too many requests — waiting before trying again.',
330
+ nextStep: 'Press g to retry',
331
+ keepContent: true,
332
+ bannerMsg: 'LinkedIn is throttling — waiting, then retry with g', bannerLevel: 'info',
333
+ };
334
+ case 'not-connection':
335
+ return { variant: 'inline', bannerMsg: 'Can only message 1st-degree connections', bannerLevel: 'error' };
336
+ case 'capture-not-dev':
337
+ return {
338
+ glyph: '⚠', glyphFg: '31',
339
+ headline: 'Browser bridge unavailable',
340
+ explanation: 'This view needs a capture dev checkout (vault/ + esbuild).',
341
+ bannerMsg: 'Browser bridge unavailable — capture dev checkout required', bannerLevel: 'error',
342
+ };
343
+ case 'error':
344
+ default:
345
+ return {
346
+ variant: 'error',
347
+ headline: 'Something went wrong',
348
+ explanation: (error && /** @type {any} */ (error).message) || 'Unknown error.',
349
+ nextStep: 'Press g to retry.',
350
+ bannerMsg: (error && /** @type {any} */ (error).message) || 'Something went wrong', bannerLevel: 'error',
351
+ };
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Apply a non-auto-fix descriptor: set (or clear) the guided panel + the banner
357
+ * that drives the state chip. `inline` errors and `keepContent` (rate-limited
358
+ * with a populated inbox) keep the last-known content instead of taking over.
359
+ * @param {LiState} state
360
+ * @param {import('../../core/tui/contract.js').ViewHost} host
361
+ * @param {ReturnType<typeof describeError>} d
362
+ */
363
+ function applyGuided(state, host, d) {
364
+ host.setStatus(null);
365
+ host.setMode(null);
366
+ if (d.variant === 'inline' || (d.keepContent && state.convos.length)) {
367
+ state.recovery = null;
368
+ } else {
369
+ state.recovery = {
370
+ variant: d.variant === 'error' ? 'error' : 'guided',
371
+ glyph: d.glyph || '⚠',
372
+ glyphFg: d.glyphFg || '31',
373
+ headline: d.headline || 'Not ready',
374
+ explanation: d.explanation || '',
375
+ nextStep: d.nextStep || null,
376
+ spinner: false,
377
+ };
378
+ }
379
+ if (d.bannerMsg) host.setBanner(d.bannerMsg, d.bannerLevel || 'error');
380
+ else host.setError(null);
381
+ }
382
+
383
+ /**
384
+ * The recovery state machine. Runs inside `refresh` (the single-flight lane).
385
+ * Auto-fix branches drive the browser (open/navigate) then settle-poll; the rest
386
+ * fall straight to a guided panel. Never re-entrant, never a dead-end.
387
+ * @param {LiState} state
388
+ * @param {import('../../core/tui/contract.js').ViewHost} host
389
+ * @param {ClientError} error
390
+ */
391
+ async function recover(state, host, error) {
392
+ const d = describeError(error);
393
+ if (d.autoFix) {
394
+ host.setMode(null);
395
+ host.setError(null);
396
+ state.recovery = {
397
+ variant: 'guided', glyph: '⟳', glyphFg: '36',
398
+ headline: d.headline || 'Working…', explanation: d.explanation || '',
399
+ nextStep: null, spinner: true, startedAt: Date.now(),
400
+ };
401
+ host.setStatus(d.headline || 'Working…');
402
+ const fix = d.autoFix === 'open'
403
+ ? await openMessagingTab({ port: state.port })
404
+ : await navigateToMessaging({ target: state.target || undefined, port: state.port });
405
+ if (!fix.ok) {
406
+ applyGuided(state, host, describeError(fix.error));
407
+ return;
408
+ }
409
+ if (d.autoFix === 'open' && fix.data) state.target = fix.data;
410
+ state.ctx = null; // re-read auth for the (possibly new) tab/page
411
+ await settlePoll(state, host);
412
+ return;
413
+ }
414
+ // not-logged-in: open the messaging tab ONCE so the login page is visible, then
415
+ // STOP (§5). Logged out, LinkedIn redirects /messaging/ → /login|/authwall, so
416
+ // capture's URL-match misses and a NEW tab would be spawned on EVERY 30s
417
+ // auto-poll. Gate the open to once per episode; onReady() resets the flag so a
418
+ // later logout re-opens exactly once.
419
+ if (error && error.kind === 'not-logged-in' && !state.loginTabOpened) {
420
+ const o = await openMessagingTab({ port: state.port });
421
+ if (o.ok && o.data) state.target = o.data;
422
+ state.loginTabOpened = true;
423
+ }
424
+ applyGuided(state, host, d);
425
+ }
426
+
427
+ /**
428
+ * Bounded settle-poll after an auto open/navigate (§5): retry readiness up to
429
+ * SETTLE_MAX times spaced SETTLE_INTERVAL_MS, narrating elapsed seconds via
430
+ * setStatus (visible through the busy-tick repaint) and the panel's live (Ns).
431
+ * On success → ready + paint. On a hard-stop error → guided. On exhaustion →
432
+ * action banner "Still loading — press g to retry" over the panel (no infinite
433
+ * spin).
434
+ * @param {LiState} state
435
+ * @param {import('../../core/tui/contract.js').ViewHost} host
436
+ */
437
+ async function settlePoll(state, host) {
438
+ for (let i = 1; i <= SETTLE_MAX; i++) {
439
+ await sleep(SETTLE_INTERVAL_MS);
440
+ const secs = state.recovery && state.recovery.startedAt
441
+ ? Math.max(0, Math.floor((Date.now() - state.recovery.startedAt) / 1000))
442
+ : i;
443
+ host.setStatus(`Loading messages… (${secs}s)`);
444
+ const r = await attemptLoad(state);
445
+ if (r.ok) {
446
+ onReady(state, host);
447
+ return;
448
+ }
449
+ if (HARD_STOP.has(r.error.kind)) {
450
+ applyGuided(state, host, describeError(r.error));
451
+ return;
452
+ }
453
+ // transient (no-tab / not-messaging / rate-limited / error) → keep polling
454
+ }
455
+ if (state.recovery) state.recovery.spinner = false;
456
+ host.setStatus(null);
457
+ host.setBanner('Still loading — press g to retry', 'action');
458
+ }
459
+
460
+ /**
461
+ * One readiness attempt: discover the tab (unless supplied/known), read auth
462
+ * context once, list conversations, and reload the open thread if any. Returns a
463
+ * Result-shaped value ({ok:true} or the first ClientError) — never throws.
464
+ * @param {LiState} state
465
+ * @returns {Promise<{ok:true} | {ok:false, error:ClientError}>}
466
+ */
467
+ async function attemptLoad(state) {
468
+ if (!state.target) {
469
+ const r = await discoverTab({ port: state.port });
470
+ if (!r.ok) return r;
471
+ state.target = r.data;
472
+ }
473
+ if (!state.ctx) {
474
+ const r = await getContext(baseOpts(state));
475
+ if (!r.ok) return r;
476
+ state.ctx = r.data;
477
+ }
478
+ const lc = await listConversations({
479
+ ...baseOpts(state),
480
+ csrf: state.ctx.csrf,
481
+ memberId: state.ctx.memberId,
482
+ count: CONVO_COUNT,
483
+ });
484
+ if (!lc.ok) return lc;
485
+ state.convos = sortConvos(lc.data);
486
+ if (state.convCursor >= state.convos.length) state.convCursor = Math.max(0, state.convos.length - 1);
487
+ if (state.openUrn) {
488
+ const vc = await viewConversation({
489
+ ...baseOpts(state),
490
+ csrf: state.ctx.csrf,
491
+ conversationUrn: state.openUrn,
492
+ myMemberId: state.ctx.memberId,
493
+ });
494
+ if (vc.ok) state.thread = vc.data; // a thread-only failure is non-fatal
495
+ }
496
+ return { ok: true };
497
+ }
498
+
499
+ /**
500
+ * Mark the view ready: clear the recovery panel + banner, refresh the live
501
+ * unread subtitle, stamp lastFetch.
502
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
503
+ */
504
+ function onReady(state, host) {
505
+ state.recovery = null;
506
+ state.lastFetch = Date.now();
507
+ state.loginTabOpened = false; // a fresh ready resets the once-per-episode login-tab gate
508
+ host.setStatus(null);
509
+ host.setError(null);
510
+ if (state.mode === 'list') host.setMode(null);
511
+ updateUnread(state, host);
512
+ }
513
+
514
+ /** Drive the live "N unread" title subtitle (null ⇒ manifest default / nothing). */
515
+ function updateUnread(state, host) {
516
+ let n = 0;
517
+ for (const c of state.convos) if (c.unread) n++;
518
+ host.setSubtitle(n > 0 ? `${n} unread` : null);
519
+ }
520
+
521
+ /**
522
+ * Set the right severity banner for a per-action ClientError (open/send/react).
523
+ * @param {import('../../core/tui/contract.js').ViewHost} host @param {ClientError} error
524
+ */
525
+ function bannerError(host, error) {
526
+ const d = describeError(error);
527
+ if (d.bannerMsg) host.setBanner(d.bannerMsg, d.bannerLevel || 'error');
528
+ else host.setError(d.headline || (error && /** @type {any} */ (error).message) || 'Error');
529
+ }
530
+
531
+ // ── Refresh (data lane) ──────────────────────────────────────────────────────
532
+
533
+ /**
534
+ * Fetch the inbox (and the open thread). Runs in the host's single-flight lane:
535
+ * on launch, on `refreshMs`, and on `{type:'refresh'}`. On failure, hands off to
536
+ * the recovery state machine. Skips auto-polls while composing/reacting so a poll
537
+ * can't disrupt input.
538
+ * @param {LiState} state
539
+ * @param {import('../../core/tui/contract.js').ViewHost} host
540
+ * @returns {Promise<void>}
541
+ */
542
+ async function refresh(state, host) {
543
+ if (state.mode !== 'list') return;
544
+ host.setStatus('Loading…');
545
+ const r = await attemptLoad(state);
546
+ if (r.ok) {
547
+ onReady(state, host);
548
+ return;
549
+ }
550
+ await recover(state, host, r.error);
551
+ }
552
+
553
+ // ── Render ───────────────────────────────────────────────────────────────────
554
+
555
+ /**
556
+ * The guided-recovery full-content takeover (§4 not-ready / §5 panels).
557
+ * @param {LiState} state
558
+ * @param {import('../../core/tui/draw.js').Draw} draw
559
+ * @param {import('../../core/tui/draw.js').Rect} content
560
+ */
561
+ function renderRecovery(state, draw, content) {
562
+ const rec = state.recovery;
563
+ if (!rec) return;
564
+ if (rec.variant === 'error') {
565
+ errorState(draw, content, {
566
+ headline: rec.headline,
567
+ cause: rec.explanation,
568
+ hint: rec.nextStep || 'Press g to retry.',
569
+ });
570
+ return;
571
+ }
572
+ let glyph = rec.glyph;
573
+ let explanation = rec.explanation;
574
+ if (rec.spinner) {
575
+ glyph = SPINNER[Math.floor(Date.now() / 240) % SPINNER.length] || rec.glyph;
576
+ const secs = Math.max(0, Math.floor((Date.now() - (rec.startedAt || Date.now())) / 1000));
577
+ explanation = toLinesArr(rec.explanation).concat([`(${secs}s)`]);
578
+ }
579
+ notReadyState(draw, content, {
580
+ glyph,
581
+ glyphFg: rec.glyphFg,
582
+ headline: rec.headline,
583
+ explanation,
584
+ nextStep: rec.nextStep || undefined,
585
+ });
586
+ }
587
+
588
+ /**
589
+ * Two-pane loading skeleton (§4 loading): dim placeholder rows on the left, a dim
590
+ * caption on the right, split by the rule.
591
+ * @param {LiState} state
592
+ * @param {import('../../core/tui/draw.js').Draw} draw
593
+ * @param {import('../../core/tui/draw.js').Rect} content
594
+ */
595
+ function renderLoadingSkeleton(state, draw, content) {
596
+ const { left, right } = splitPanes(draw, content);
597
+ loadingState(draw, left, { rows: Math.min(5, Math.max(1, left.height)) });
598
+ centeredStack(draw, right, [[{ text: 'Loading conversations…', style: { dim: true } }]]);
599
+ }
600
+
601
+ /**
602
+ * Left pane — the conversation list (§5 row anatomy), drawn through draw.list so
603
+ * the host owns the cursor highlight + windowing; the right-flush relative time
604
+ * rides on ListItemRow.right.
605
+ * @param {LiState} state
606
+ * @param {import('../../core/tui/draw.js').Draw} draw
607
+ * @param {import('../../core/tui/draw.js').Rect} left
608
+ */
609
+ function renderConvoList(state, draw, left) {
610
+ if (left.width <= 0 || left.height <= 0) return;
611
+ /** @type {import('../../core/tui/draw.js').ListItemRow[]} */
612
+ const items = state.convos.map((c, i) => {
613
+ const isCursor = i === state.convCursor;
614
+ /** @type {import('../../core/tui/draw.js').Span[]} */
615
+ const spans = [
616
+ { text: isCursor ? '▸' : ' ', style: isCursor ? { fg: '36', bold: true } : undefined }, // cursor tick
617
+ { text: c.unread ? '●' : ' ', style: c.unread ? { fg: '36', bold: true } : undefined }, // unread dot
618
+ { text: ' ' + (c.name || 'Unknown'), style: c.unread ? { bold: true } : undefined }, // name (bold if unread)
619
+ ];
620
+ const snippet = (c.lastMessage || '').replace(/\s+/g, ' ').trim();
621
+ if (snippet) spans.push({ text: ' ' + snippet, style: { dim: true } });
622
+ const ts = relTimestamp(c.ts);
623
+ /** @type {import('../../core/tui/draw.js').ListItemRow} */
624
+ const item = { spans };
625
+ if (ts) item.right = [{ text: ts, style: { dim: true } }];
626
+ return item;
627
+ });
628
+ const res = draw.list(left, items, state.convCursor, state.convScroll);
629
+ state.convScroll = res.scroll; // store adjusted scroll back (Draw.list contract)
630
+ }
631
+
632
+ /**
633
+ * Build the thread's flat visual lines with you-vs-them grouping (§5): them =
634
+ * cyan sender + dim right time + default body at gutter; you = green ▎ rail +
635
+ * green You + dim right time + rail-indented default body. Day dividers between
636
+ * date changes; 1 blank spacer between groups. The react target's header gets a
637
+ * cyan ▸ tick.
638
+ * @param {LiState} state @param {number} width @param {number} reactTarget index, or -1
639
+ * @returns {import('../../core/tui/draw.js').ListItemRow[]}
640
+ */
641
+ function buildThreadLines(state, width, reactTarget) {
642
+ /** @type {import('../../core/tui/draw.js').ListItemRow[]} */
643
+ const lines = [];
644
+ let prevDay = null;
645
+ state.thread.forEach((m, idx) => {
646
+ const day = dayKey(m.ts);
647
+ if (m.ts && day !== prevDay) {
648
+ const txt = `── ${dayLabel(m.ts)} ──`;
649
+ const pad = Math.max(0, Math.floor((width - Array.from(txt).length) / 2));
650
+ lines.push({ spans: [{ text: ' '.repeat(pad) + txt, style: { dim: true } }] });
651
+ prevDay = day;
652
+ }
653
+ const tick = idx === reactTarget ? [{ text: '▸ ', style: { fg: '36', bold: true } }] : [];
654
+ const ts = relTimestamp(m.ts);
655
+ const right = ts ? [{ text: ts, style: { dim: true } }] : undefined;
656
+ if (m.fromMe) {
657
+ lines.push({
658
+ spans: [...tick, { text: '▎ ', style: { fg: '32', bold: true } }, { text: 'You', style: { fg: '32', bold: true } }],
659
+ right,
660
+ });
661
+ for (const bl of wrapText(m.text || '', Math.max(1, width - 2))) {
662
+ lines.push({ spans: [{ text: '▎ ', style: { fg: '32' } }, { text: bl }] });
663
+ }
664
+ } else {
665
+ lines.push({ spans: [...tick, { text: m.sender || 'Them', style: { fg: '36', bold: true } }], right });
666
+ for (const bl of wrapText(m.text || '', width)) {
667
+ lines.push({ spans: [{ text: bl }] });
668
+ }
669
+ }
670
+ if (idx < state.thread.length - 1) lines.push({ spans: [{ text: '' }] }); // spacer BETWEEN groups only (no trailing waste under tail-windowing)
671
+ });
672
+ return lines;
673
+ }
674
+
675
+ /**
676
+ * Paint the thread body (tail-windowed) into `rect`.
677
+ * @param {LiState} state
678
+ * @param {import('../../core/tui/draw.js').Draw} draw
679
+ * @param {import('../../core/tui/draw.js').Rect} rect
680
+ * @param {number} reactTarget
681
+ */
682
+ function renderThreadBody(state, draw, rect, reactTarget) {
683
+ if (rect.width <= 0 || rect.height <= 0) return;
684
+ const lines = buildThreadLines(state, rect.width, reactTarget);
685
+ const start = Math.max(0, lines.length - rect.height);
686
+ state.threadScroll = start;
687
+ let r = rect.row;
688
+ for (let i = start; i < lines.length && r < rect.row + rect.height; i++, r++) {
689
+ const ln = lines[i];
690
+ if (ln.right && ln.right.length) {
691
+ const rw = spanWidth(ln.right);
692
+ draw.spans(r, rect.col, ln.spans, Math.max(0, rect.width - rw - 1));
693
+ draw.spansRight(r, rect.col + rect.width, ln.right, rw);
694
+ } else {
695
+ draw.spans(r, rect.col, ln.spans, rect.width);
696
+ }
697
+ }
698
+ }
699
+
700
+ /**
701
+ * The lifted compose bar (§5 reply mode): a hairline, a `✎ Reply ` yellow-bold
702
+ * label + draft + `█` block cursor (horizontal-scrolled to the tail), and a dim
703
+ * `enter send · esc cancel` hint.
704
+ * @param {LiState} state
705
+ * @param {import('../../core/tui/draw.js').Draw} draw
706
+ * @param {import('../../core/tui/draw.js').Rect} right
707
+ */
708
+ function renderComposer(state, draw, right) {
709
+ const hairRow = right.row + right.height - 3;
710
+ const barRow = right.row + right.height - 2;
711
+ const hintRow = right.row + right.height - 1;
712
+ draw.hline(hairRow, right.col, right.col + right.width);
713
+ const label = '✎ Reply ';
714
+ const labelW = Array.from(label).length;
715
+ const avail = Math.max(1, right.width - labelW - 1); // 1 cell reserved for the cursor
716
+ let shown = state.draft;
717
+ const arr = Array.from(shown);
718
+ if (arr.length > avail - 1) shown = arr.slice(arr.length - (avail - 1)).join(''); // keep the tail/cursor visible
719
+ draw.spans(barRow, right.col, [
720
+ { text: label, style: { fg: '33', bold: true } },
721
+ { text: shown },
722
+ { text: '█', style: { fg: '33' } }, // block cursor (glyph carries it in mono)
723
+ ], right.width);
724
+ draw.spans(hintRow, right.col, [
725
+ { text: 'enter', style: { bold: true } }, { text: ' send', style: { dim: true } },
726
+ { text: ' · ', style: { dim: true } },
727
+ { text: 'esc', style: { bold: true } }, { text: ' cancel', style: { dim: true } },
728
+ ], right.width);
729
+ }
730
+
731
+ /**
732
+ * The react picker bar (§5 react mode): a hairline, the emoji chip row with the
733
+ * selected chip in accent-bg + brackets (mono-safe), and a dim hint.
734
+ * @param {LiState} state
735
+ * @param {import('../../core/tui/draw.js').Draw} draw
736
+ * @param {import('../../core/tui/draw.js').Rect} right
737
+ */
738
+ function renderReactBar(state, draw, right) {
739
+ const hairRow = right.row + right.height - 3;
740
+ const barRow = right.row + right.height - 2;
741
+ const hintRow = right.row + right.height - 1;
742
+ draw.hline(hairRow, right.col, right.col + right.width);
743
+ /** @type {import('../../core/tui/draw.js').Span[]} */
744
+ const spans = [{ text: '☺ React ', style: { fg: '33', bold: true } }];
745
+ EMOJIS.forEach((e, i) => {
746
+ if (i === state.reactCursor) {
747
+ spans.push({ text: ' ' });
748
+ spans.push({ text: '[' + e + ']', style: { bg: '236', reverse: true } }); // accent-bg (color) + brackets+reverse (mono carrier, §2)
749
+ } else {
750
+ spans.push({ text: ' ' + e + ' ' });
751
+ }
752
+ });
753
+ draw.spans(barRow, right.col, spans, right.width);
754
+ draw.spans(hintRow, right.col, [
755
+ { text: '←/→', style: { bold: true } }, { text: ' pick', style: { dim: true } },
756
+ { text: ' · ', style: { dim: true } },
757
+ { text: 'enter', style: { bold: true } }, { text: ' react', style: { dim: true } },
758
+ { text: ' · ', style: { dim: true } },
759
+ { text: 'esc', style: { bold: true } }, { text: ' cancel', style: { dim: true } },
760
+ ], right.width);
761
+ }
762
+
763
+ /**
764
+ * Right pane — the open thread (header + hairline + grouped body) plus the
765
+ * compose/react bar when in a mode. Closed: a guided empty stack.
766
+ * @param {LiState} state
767
+ * @param {import('../../core/tui/draw.js').Draw} draw
768
+ * @param {import('../../core/tui/draw.js').Rect} right
769
+ */
770
+ function renderThread(state, draw, right) {
771
+ if (right.width <= 0 || right.height <= 0) return;
772
+ const openConvo = state.openUrn ? state.convos.find((c) => c.urn === state.openUrn) : null;
773
+
774
+ if (!state.openUrn) {
775
+ centeredStack(draw, right, [
776
+ [{ text: '✉ ', style: { dim: true } }, { text: 'No conversation open', style: { dim: true } }],
777
+ [{ text: '' }],
778
+ [{ text: 'Press ' }, { text: 'Enter', style: { bold: true } }, { text: ' to open a conversation' }],
779
+ ]);
780
+ return;
781
+ }
782
+
783
+ // Header: bold name + right-flush dim conversation time, hairline beneath.
784
+ const headerName = openConvo ? openConvo.name : 'Conversation';
785
+ const convTs = openConvo ? relTimestamp(openConvo.ts) : '';
786
+ const rw = convTs ? Array.from(convTs).length : 0;
787
+ draw.spans(right.row, right.col, [{ text: headerName, style: { bold: true } }], Math.max(0, right.width - rw - 1));
788
+ if (convTs) draw.spansRight(right.row, right.col + right.width, [{ text: convTs, style: { dim: true } }], rw);
789
+ draw.hline(right.row + 1, right.col, right.col + right.width);
790
+
791
+ const composing = state.mode === 'reply';
792
+ const reacting = state.mode === 'react';
793
+ const composerRows = composing || reacting ? 3 : 0;
794
+ const bodyTop = right.row + 2;
795
+ const bodyBottom = right.row + right.height - 1 - composerRows;
796
+ const bodyRect = { row: bodyTop, col: right.col, width: right.width, height: Math.max(0, bodyBottom - bodyTop + 1) };
797
+
798
+ if (state.thread.length === 0) {
799
+ centeredStack(draw, bodyRect, [[{ text: 'Loading messages…', style: { dim: true } }]]);
800
+ } else {
801
+ renderThreadBody(state, draw, bodyRect, reacting ? state.thread.length - 1 : -1);
802
+ }
803
+
804
+ if (composing) renderComposer(state, draw, right);
805
+ else if (reacting) renderReactBar(state, draw, right);
806
+ }
807
+
808
+ // ── onKey handlers ───────────────────────────────────────────────────────────
809
+
810
+ /**
811
+ * Open the conversation under the cursor: view it, then auto-mark it read.
812
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
813
+ * @returns {Promise<import('../../core/tui/contract.js').ViewAction>}
814
+ */
815
+ async function openThread(state, host) {
816
+ const convo = state.convos[state.convCursor];
817
+ if (!convo) return { type: 'none' };
818
+ if (!state.ctx) {
819
+ host.setBanner('Not ready yet — press g to refresh', 'action');
820
+ return { type: 'render' };
821
+ }
822
+ state.openUrn = convo.urn;
823
+ state.thread = [];
824
+ state.threadScroll = 0;
825
+ host.setStatus('Loading thread…');
826
+
827
+ const vc = await viewConversation({
828
+ ...baseOpts(state),
829
+ csrf: state.ctx.csrf,
830
+ conversationUrn: convo.urn,
831
+ myMemberId: state.ctx.memberId,
832
+ });
833
+ if (!vc.ok) {
834
+ host.setStatus(null);
835
+ bannerError(host, vc.error);
836
+ return { type: 'render' };
837
+ }
838
+ state.thread = vc.data;
839
+
840
+ // Auto mark read; clear the unread flag locally (optimistic) + refresh the count.
841
+ await markConversationAsRead({ ...baseOpts(state), csrf: state.ctx.csrf, conversationUrn: convo.urn });
842
+ convo.unread = false;
843
+
844
+ host.setStatus(null);
845
+ host.setError(null);
846
+ updateUnread(state, host);
847
+ return { type: 'render' };
848
+ }
849
+
850
+ /**
851
+ * Send the current draft to the open conversation's recipient.
852
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
853
+ * @returns {Promise<import('../../core/tui/contract.js').ViewAction>}
854
+ */
855
+ async function sendReply(state, host) {
856
+ const text = state.draft.trim();
857
+ if (!text) {
858
+ state.mode = 'list';
859
+ host.setMode(null);
860
+ return { type: 'render' };
861
+ }
862
+ const convo = state.openUrn ? state.convos.find((c) => c.urn === state.openUrn) : null;
863
+ if (!convo || !state.ctx) {
864
+ host.setError('No open conversation to reply to.');
865
+ state.mode = 'list';
866
+ host.setMode(null);
867
+ return { type: 'render' };
868
+ }
869
+ // Leave compose mode before the async send so the working chip shows.
870
+ state.mode = 'list';
871
+ host.setMode(null);
872
+ host.setStatus('Sending…');
873
+ const r = await sendMessage({
874
+ ...baseOpts(state),
875
+ csrf: state.ctx.csrf,
876
+ myMemberId: state.ctx.memberId,
877
+ recipient: convo.recipientId,
878
+ text,
879
+ conversationUrn: state.openUrn,
880
+ });
881
+ if (!r.ok) {
882
+ host.setStatus(null);
883
+ bannerError(host, r.error); // not-connection → inline error banner over the open thread
884
+ return { type: 'render' };
885
+ }
886
+
887
+ // Optimistic append, then reconcile by re-viewing the thread.
888
+ state.thread.push({ urn: '', sender: 'You', text, ts: Date.now(), fromMe: true });
889
+ state.draft = '';
890
+ const vc = await viewConversation({
891
+ ...baseOpts(state),
892
+ csrf: state.ctx.csrf,
893
+ conversationUrn: state.openUrn,
894
+ myMemberId: state.ctx.memberId,
895
+ });
896
+ if (vc.ok) state.thread = vc.data;
897
+ host.setError(null);
898
+ host.setStatus('Sent');
899
+ return { type: 'render' };
900
+ }
901
+
902
+ /**
903
+ * React to the most recent message in the open thread with the selected emoji.
904
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
905
+ * @returns {Promise<import('../../core/tui/contract.js').ViewAction>}
906
+ */
907
+ async function doReact(state, host) {
908
+ const target = state.thread.length ? state.thread[state.thread.length - 1] : null;
909
+ state.mode = 'list';
910
+ host.setMode(null);
911
+ if (!target || !target.urn || !state.ctx) {
912
+ host.setError('No message to react to.');
913
+ return { type: 'render' };
914
+ }
915
+ const emoji = EMOJIS[state.reactCursor] || EMOJIS[0];
916
+ host.setStatus('Reacting…');
917
+ const r = await reactToMessage({ ...baseOpts(state), csrf: state.ctx.csrf, messageUrn: target.urn, emoji });
918
+ if (!r.ok) {
919
+ host.setStatus(null);
920
+ bannerError(host, r.error);
921
+ return { type: 'render' };
922
+ }
923
+ host.setError(null);
924
+ host.setStatus('Reacted ' + emoji);
925
+ return { type: 'render' };
926
+ }
927
+
928
+ /**
929
+ * List-mode keystrokes.
930
+ * @param {import('../../core/tui/contract.js').ViewKey} k
931
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
932
+ * @returns {import('../../core/tui/contract.js').ViewAction | Promise<import('../../core/tui/contract.js').ViewAction>}
933
+ */
934
+ function onKeyList(k, state, host) {
935
+ const key = k.key;
936
+ const ch = k.input;
937
+
938
+ if (ch === 'q') return { type: 'quit' };
939
+ if (ch === 'g') return { type: 'refresh' };
940
+
941
+ if (key.downArrow || ch === 'j') {
942
+ if (state.convos.length) state.convCursor = Math.min(state.convos.length - 1, state.convCursor + 1);
943
+ return { type: 'render' };
944
+ }
945
+ if (key.upArrow || ch === 'k') {
946
+ state.convCursor = Math.max(0, state.convCursor - 1);
947
+ return { type: 'render' };
948
+ }
949
+ if (key.return) return openThread(state, host);
950
+ if (ch === 'r') {
951
+ if (!state.openUrn) {
952
+ host.setBanner('Open a conversation first', 'action');
953
+ return { type: 'render' };
954
+ }
955
+ state.mode = 'reply';
956
+ state.draft = '';
957
+ host.setMode('compose');
958
+ host.setError(null);
959
+ return { type: 'render' };
960
+ }
961
+ if (ch === 'e') {
962
+ if (!state.openUrn || state.thread.length === 0) {
963
+ host.setBanner('Open a conversation first', 'action');
964
+ return { type: 'render' };
965
+ }
966
+ state.mode = 'react';
967
+ state.reactCursor = 0;
968
+ host.setMode('react');
969
+ host.setError(null);
970
+ return { type: 'render' };
971
+ }
972
+ return { type: 'none' };
973
+ }
974
+
975
+ /**
976
+ * Reply-mode keystrokes. Printable chars edit the draft; Enter sends; Esc cancels.
977
+ * @param {import('../../core/tui/contract.js').ViewKey} k
978
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
979
+ * @returns {import('../../core/tui/contract.js').ViewAction | Promise<import('../../core/tui/contract.js').ViewAction>}
980
+ */
981
+ function onKeyReply(k, state, host) {
982
+ const key = k.key;
983
+ if (key.escape) {
984
+ state.mode = 'list';
985
+ state.draft = '';
986
+ host.setMode(null);
987
+ return { type: 'render' };
988
+ }
989
+ if (key.return) return sendReply(state, host);
990
+ if (key.backspace) {
991
+ state.draft = state.draft.slice(0, -1);
992
+ return { type: 'render' };
993
+ }
994
+ if (isPrintable(k)) {
995
+ state.draft += k.input;
996
+ return { type: 'render' };
997
+ }
998
+ return { type: 'none' };
999
+ }
1000
+
1001
+ /**
1002
+ * React-mode keystrokes. h/l or ←/→ move the chip cursor; Enter reacts; Esc cancels.
1003
+ * @param {import('../../core/tui/contract.js').ViewKey} k
1004
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
1005
+ * @returns {import('../../core/tui/contract.js').ViewAction | Promise<import('../../core/tui/contract.js').ViewAction>}
1006
+ */
1007
+ function onKeyReact(k, state, host) {
1008
+ const key = k.key;
1009
+ const ch = k.input;
1010
+ if (key.escape) {
1011
+ state.mode = 'list';
1012
+ host.setMode(null);
1013
+ return { type: 'render' };
1014
+ }
1015
+ if (key.leftArrow || ch === 'h') {
1016
+ state.reactCursor = Math.max(0, state.reactCursor - 1);
1017
+ return { type: 'render' };
1018
+ }
1019
+ if (key.rightArrow || ch === 'l') {
1020
+ state.reactCursor = Math.min(EMOJIS.length - 1, state.reactCursor + 1);
1021
+ return { type: 'render' };
1022
+ }
1023
+ if (key.return) return doReact(state, host);
1024
+ return { type: 'none' };
1025
+ }
1026
+
1027
+ // ── ViewModule ───────────────────────────────────────────────────────────────
1028
+
1029
+ /** @type {import('../../core/tui/contract.js').ViewModule<LiState>} */
1030
+ const view = {
1031
+ manifest: {
1032
+ id: 'linkedin',
1033
+ title: 'LinkedIn Messages',
1034
+ description: 'Inbox — read, reply, react',
1035
+ refreshMs: 30000,
1036
+ keymap: [
1037
+ { keys: 'j/k', label: 'move' },
1038
+ { keys: 'enter', label: 'open' },
1039
+ { keys: 'r', label: 'reply' },
1040
+ { keys: 'e', label: 'react' },
1041
+ { keys: 'g', label: 'refresh' },
1042
+ { keys: 'q', label: 'quit' },
1043
+ ],
1044
+ },
1045
+
1046
+ /**
1047
+ * Build initial state. Cheap + synchronous — NO slow fetch (the host paints the
1048
+ * loading skeleton, then calls refresh()).
1049
+ * @param {import('../../core/tui/contract.js').ViewHost} host
1050
+ * @returns {LiState}
1051
+ */
1052
+ init(host) {
1053
+ const opts = host.options || {};
1054
+ return {
1055
+ ctx: null,
1056
+ target: opts.target || null,
1057
+ port: opts.port || undefined,
1058
+ convos: [],
1059
+ convCursor: 0,
1060
+ convScroll: 0,
1061
+ openUrn: null,
1062
+ thread: [],
1063
+ threadScroll: 0,
1064
+ mode: 'list',
1065
+ draft: '',
1066
+ reactCursor: 0,
1067
+ lastFetch: 0,
1068
+ recovery: null,
1069
+ loginTabOpened: false,
1070
+ };
1071
+ },
1072
+
1073
+ refresh,
1074
+
1075
+ /**
1076
+ * Paint the view. Precedence: recovery takeover → loading skeleton → empty
1077
+ * reward → the two-pane inbox.
1078
+ * @param {LiState} state
1079
+ * @param {import('../../core/tui/draw.js').Draw} draw
1080
+ * @param {import('../../core/tui/draw.js').Rect} content
1081
+ */
1082
+ render(state, draw, content) {
1083
+ if (state.recovery) {
1084
+ renderRecovery(state, draw, content);
1085
+ return;
1086
+ }
1087
+ if (state.convos.length === 0) {
1088
+ if (state.lastFetch === 0) {
1089
+ renderLoadingSkeleton(state, draw, content);
1090
+ return;
1091
+ }
1092
+ emptyState(draw, content, {
1093
+ headline: 'All caught up',
1094
+ secondary: ['No conversations in your inbox.', 'Press g to refresh.'],
1095
+ });
1096
+ return;
1097
+ }
1098
+ const { left, right } = splitPanes(draw, content);
1099
+ renderConvoList(state, draw, left);
1100
+ renderThread(state, draw, right);
1101
+ },
1102
+
1103
+ /**
1104
+ * One keystroke → next action. Dispatches by mode.
1105
+ * @param {import('../../core/tui/contract.js').ViewKey} k
1106
+ * @param {LiState} state @param {import('../../core/tui/contract.js').ViewHost} host
1107
+ * @returns {import('../../core/tui/contract.js').ViewAction | Promise<import('../../core/tui/contract.js').ViewAction>}
1108
+ */
1109
+ onKey(k, state, host) {
1110
+ switch (state.mode) {
1111
+ case 'reply':
1112
+ return onKeyReply(k, state, host);
1113
+ case 'react':
1114
+ return onKeyReact(k, state, host);
1115
+ default:
1116
+ return onKeyList(k, state, host);
1117
+ }
1118
+ },
1119
+
1120
+ /**
1121
+ * Plain-text snapshot for the non-TTY / piped path (exit 0). No ANSI. Surfaces
1122
+ * the guided recovery panel + the host's current banner (threaded via ctx) so
1123
+ * the static path shows guidance, not a blank screen.
1124
+ * @param {LiState} state
1125
+ * @param {import('../../core/tui/contract.js').DumpContext} [ctx]
1126
+ * @returns {string}
1127
+ */
1128
+ dump(state, ctx) {
1129
+ const banner = ctx && ctx.banner ? ctx.banner : null;
1130
+ const sigil = (lvl) => (lvl === 'error' ? '✗' : lvl === 'action' ? '▸' : 'ℹ');
1131
+ /** @type {string[]} */
1132
+ const lines = [];
1133
+ let n = 0;
1134
+ for (const c of state.convos) if (c.unread) n++;
1135
+ lines.push('LinkedIn Messages' + (n ? ` · ${n} unread` : ''), '');
1136
+
1137
+ if (state.recovery) {
1138
+ const r = state.recovery;
1139
+ lines.push(r.headline);
1140
+ for (const e of toLinesArr(r.explanation)) if (e) lines.push(' ' + e);
1141
+ if (r.nextStep) lines.push(' → ' + r.nextStep);
1142
+ if (banner) lines.push(' ' + sigil(banner.level) + ' ' + banner.msg);
1143
+ return lines.join('\n');
1144
+ }
1145
+
1146
+ if (state.convos.length === 0) {
1147
+ if (banner) lines.push(sigil(banner.level) + ' ' + banner.msg);
1148
+ else lines.push(state.lastFetch === 0 ? '(loading…)' : '✓ All caught up — no conversations.');
1149
+ } else {
1150
+ for (const c of sortConvos(state.convos)) {
1151
+ const badge = c.unread ? '●' : ' ';
1152
+ const snip = truncate((c.lastMessage || '').replace(/\s+/g, ' ').trim(), 56);
1153
+ lines.push(`[${badge}] ${padEnd(c.name || 'Unknown', 20)} ${padEnd(snip, 56)} ${relTimestamp(c.ts)}`);
1154
+ }
1155
+ }
1156
+
1157
+ if (state.openUrn && state.thread.length) {
1158
+ const convo = state.convos.find((c) => c.urn === state.openUrn);
1159
+ lines.push('', `— ${convo ? convo.name : state.openUrn} —`);
1160
+ for (const m of state.thread) {
1161
+ const who = m.fromMe ? 'You' : m.sender || 'Them';
1162
+ lines.push(`${who}: ${(m.text || '').replace(/\s+/g, ' ').trim()}`);
1163
+ }
1164
+ }
1165
+
1166
+ if (banner && state.convos.length) lines.push('', sigil(banner.level) + ' ' + banner.msg);
1167
+ return lines.join('\n');
1168
+ },
1169
+ };
1170
+
1171
+ export default view;