@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.
- package/dist/build-root.d.ts +15 -7
- package/dist/build-root.js +48 -34
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +5 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +22 -17
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +4 -25
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +8 -4
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/listing-completeness.test.js +2 -2
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.js +8 -7
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -335
- package/dist/core/runtime/placement.js +291 -858
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +15 -52
- package/dist/core/runtime/tmux.js +35 -99
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +10 -3
- package/dist/core/substrate/schema.js +6 -3
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -1,1171 +0,0 @@
|
|
|
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;
|