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