@crouton-kit/crouter 0.3.82 → 0.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +2 -3
  7. package/dist/clients/attach/attach-cmd.js +689 -689
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +57 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +15 -17
  22. package/dist/commands/node.js +5 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
  26. package/dist/commands/sys/setup-core.d.ts +10 -0
  27. package/dist/commands/sys/setup-core.js +39 -1
  28. package/dist/commands/sys/setup.js +10 -1
  29. package/dist/core/__tests__/boot.test.js +141 -6
  30. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  31. package/dist/core/__tests__/human-deliver.test.js +332 -0
  32. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  33. package/dist/core/__tests__/pid-identity-match.test.js +145 -0
  34. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  35. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  36. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  38. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  39. package/dist/core/canvas/attention.d.ts +16 -14
  40. package/dist/core/canvas/attention.js +34 -29
  41. package/dist/core/canvas/boot.d.ts +45 -0
  42. package/dist/core/canvas/boot.js +36 -3
  43. package/dist/core/canvas/browse/app.js +3 -3
  44. package/dist/core/canvas/canvas.d.ts +40 -0
  45. package/dist/core/canvas/canvas.js +65 -1
  46. package/dist/core/canvas/pid.d.ts +86 -17
  47. package/dist/core/canvas/pid.js +152 -37
  48. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  49. package/dist/core/canvas/remote-canvas-source.js +4 -2
  50. package/dist/core/canvas/render.d.ts +5 -5
  51. package/dist/core/canvas/render.js +14 -14
  52. package/dist/core/canvas/source.d.ts +2 -2
  53. package/dist/core/canvas/source.js +3 -3
  54. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  55. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +4 -2
  59. package/dist/core/keybindings/inbox.d.ts +21 -0
  60. package/dist/core/keybindings/inbox.js +35 -0
  61. package/dist/core/keybindings/index.d.ts +1 -0
  62. package/dist/core/keybindings/index.js +1 -0
  63. package/dist/core/preview-registry.js +23 -29
  64. package/dist/core/profiles/select.js +4 -4
  65. package/dist/core/runtime/pi-vendored.js +15 -1
  66. package/dist/core/runtime/placement.d.ts +1 -10
  67. package/dist/core/runtime/placement.js +43 -26
  68. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  69. package/dist/core/runtime/session-list-cache.js +92 -24
  70. package/dist/core/runtime/spawn.js +2 -1
  71. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  72. package/dist/core/runtime/tmux-chrome.js +1 -1
  73. package/dist/core/runtime/tmux.d.ts +1 -0
  74. package/dist/core/runtime/tmux.js +55 -1
  75. package/dist/core/spawn.d.ts +0 -59
  76. package/dist/core/spawn.js +5 -163
  77. package/dist/daemon/crtrd.js +36 -11
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  81. package/dist/web-client/index.html +1 -1
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +2 -2
  84. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  85. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  86. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  87. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  88. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  89. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  90. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  91. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  92. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  93. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  94. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -9,6 +9,7 @@
9
9
  // placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { readFileSync } from 'node:fs';
12
+ import { readKernelBootId } from './boot.js';
12
13
  /** Is `pid` a ZOMBIE (exited, awaiting reap by its parent)? `kill(pid, 0)`
13
14
  * reports a zombie as a perfectly normal alive process — its PID is still
14
15
  * occupied in the process table — so callers relying on that signal-0 probe
@@ -60,13 +61,29 @@ export function isPidAlive(pid) {
60
61
  }
61
62
  return !isZombie(pid);
62
63
  }
63
- /** Split a `composeIdentity` fingerprint (`lstart` or `lstart#ticks`) into its
64
- * coarse (`lstart`, second-granular, portable) and fine (`ticks`, Linux-only
65
- * jiffies, best-effort) parts. `#` never appears in `lstart` itself (a `ps`
66
- * timestamp), so the first `#` unambiguously separates them. */
64
+ /** Matches the kernel `boot_id` UUID shape (`/proc/sys/kernel/random/boot_id`),
65
+ * used to tell a NEW-format identity base (a per-boot UUID) apart from a
66
+ * LEGACY one (a `ps lstart` wall-clock timestamp). A boot_id is a lowercase
67
+ * hex UUID; an `lstart` string is a human-readable date — the two never
68
+ * collide, so this cleanly classifies which comparison rule an identity's
69
+ * base obeys (see `identitiesMatch`). */
70
+ const BOOT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
71
+ /** Split a `composeIdentity` fingerprint (`<base>` or `<base>#<ticks>`, where
72
+ * `base` is a per-boot `boot_id` UUID on Linux-with-procfs or the portable
73
+ * `ps lstart` timestamp otherwise) into its coarse base and fine `ticks`
74
+ * (Linux-only jiffies) parts. `#` never appears in a `boot_id` or an `lstart`
75
+ * timestamp, so the first `#` unambiguously separates them. Only a NON-EMPTY,
76
+ * purely-numeric suffix counts as ticks (the shape `procStartTicks` and
77
+ * `composeIdentity` guarantee); a malformed or empty suffix (`A#`, `A#bad`,
78
+ * `A#1#2`) is treated as ABSENT ticks, so the base compare then applies — a
79
+ * garbage suffix must never be trusted as a fine discriminator (review
80
+ * Minor). */
67
81
  function splitIdentity(identity) {
68
82
  const i = identity.indexOf('#');
69
- return i === -1 ? { base: identity, ticks: undefined } : { base: identity.slice(0, i), ticks: identity.slice(i + 1) };
83
+ if (i === -1)
84
+ return { base: identity, ticks: undefined };
85
+ const suffix = identity.slice(i + 1);
86
+ return { base: identity.slice(0, i), ticks: /^\d+$/.test(suffix) ? suffix : undefined };
70
87
  }
71
88
  /** THE single precision-aware identity compare, used EVERYWHERE two
72
89
  * `composeIdentity` fingerprints are compared (this module's own
@@ -75,32 +92,60 @@ function splitIdentity(identity) {
75
92
  * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
76
93
  * fix).
77
94
  *
78
- * The hazard this closes: `composeIdentity` appends the `#<ticks>` Linux
79
- * discriminator only when `/proc/<pid>/stat` happens to be readable at
80
- * capture time so the SAME live process can be recorded plain `lstart` on
81
- * one capture and `lstart#ticks` on another (a transient `/proc` read
82
- * failure, or capturing on a platform/container without procfs). A naive
83
- * `===` then reads that as a reuse (different string) and reports `dead` for
84
- * a broker that never actually died — the false-DEAD direction, which can
85
- * make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
86
- * onto a still-live session.
95
+ * Two hazards this closes, both in the false-DEAD direction (the dangerous
96
+ * one a false DEAD lets `reviveNode`'s double-launch guard relaunch a
97
+ * SECOND broker onto a still-live session):
87
98
  *
88
- * Rule: split both on `#`. Bases (`lstart`) differ definitely NOT a match
89
- * (different process, or the same pid reused in a different second). Bases
90
- * match and BOTH carry a `#ticks` suffix match iff the suffixes are equal
91
- * (this still catches genuine same-second reuse, the case `procStartTicks`
92
- * exists for). Bases match and EITHER lacks the suffix → coarse-vs-fine,
93
- * can't distinguish MATCH (fail-open to same-process, matching this
94
- * module's universal discipline: only a POSITIVE mismatch counts as reuse,
95
- * never an absence of evidence). */
99
+ * 1. `composeIdentity` appends the `#<ticks>` Linux discriminator only when
100
+ * `/proc/<pid>/stat` happens to be readable at capture time so the SAME
101
+ * live process can be recorded plain `lstart` on one capture and
102
+ * `lstart#ticks` on another (a transient `/proc` read failure, or
103
+ * capturing on a platform/container without procfs). A naive `===` reads
104
+ * that as a reuse (different string) and reports `dead` for a broker that
105
+ * never died.
106
+ * 2. `lstart` is NOT stable for a live process on every host. It is derived
107
+ * from `/proc/stat` btime (wall-clock boot epoch) + starttime jiffies, so
108
+ * a clock correction / suspend-resume shifts btime and re-anchors the
109
+ * SAME live process's `lstart` by seconds between two captures. Observed
110
+ * on Blaxel unikraft guests: pid+ticks identical across probes, `lstart`
111
+ * drifting +81s — a base-first compare then reads the live home-node
112
+ * broker as reused/dead every cycle, driving an uncapped revive loop.
113
+ *
114
+ * A third hazard the base itself once carried: `ticks` (jiffies-since-boot)
115
+ * RESET on every boot and can repeat across boots, so ticks-alone is only
116
+ * safe WITHIN one boot. `composeIdentity` therefore boot-SCOPES the base on
117
+ * Linux-with-procfs — the base IS the kernel `boot_id` UUID — so a
118
+ * new-format identity is `<bootId>#<ticks>` and equality means same boot AND
119
+ * same process. This closes the cross-boot false match at the root, without
120
+ * plumbing boot reconciliation ahead of every caller.
121
+ *
122
+ * Rule: split both on `#`.
123
+ * - BOTH sides carry `#ticks` AND both bases are `boot_id` UUIDs
124
+ * (new-format vs new-format): match iff base AND ticks are equal. A
125
+ * different boot_id with equal ticks is NOT a match (the cross-boot
126
+ * collision).
127
+ * - BOTH sides carry `#ticks` but a base is NOT a boot_id UUID on EITHER
128
+ * side — a LEGACY `lstart#ticks` row recorded before this repo boot-scoped
129
+ * the base, or a Linux host that has procfs ticks but no readable boot_id:
130
+ * ticks DECIDE it alone (the prior landed semantics). This is a NARROW
131
+ * format-migration path so an in-place upgrade never false-kills a live
132
+ * broker whose row still holds `lstart#ticks`; it is NOT a general
133
+ * fallback — new-format vs new-format always demands base+ticks above.
134
+ * - EITHER side lacks a valid `#ticks` suffix (macOS / no procfs / transient
135
+ * `/proc` miss / malformed suffix): fall back to the coarse base —
136
+ * matching iff the bases agree (fail-open to same-process, matching this
137
+ * module's universal discipline: only a POSITIVE mismatch counts as reuse,
138
+ * never an absence of evidence). */
96
139
  export function identitiesMatch(a, b) {
97
140
  const ia = splitIdentity(a);
98
141
  const ib = splitIdentity(b);
99
- if (ia.base !== ib.base)
100
- return false;
101
- if (ia.ticks !== undefined && ib.ticks !== undefined)
102
- return ia.ticks === ib.ticks;
103
- return true;
142
+ if (ia.ticks !== undefined && ib.ticks !== undefined) {
143
+ if (BOOT_ID_RE.test(ia.base) && BOOT_ID_RE.test(ib.base)) {
144
+ return ia.base === ib.base && ia.ticks === ib.ticks;
145
+ }
146
+ return ia.ticks === ib.ticks; // legacy lstart#ticks (either side) → ticks-alone migration
147
+ }
148
+ return ia.base === ib.base;
104
149
  }
105
150
  export function recordedPidLiveness(pid, expectedIdentity) {
106
151
  const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
@@ -226,16 +271,86 @@ function procStartTicks(pid) {
226
271
  return null;
227
272
  }
228
273
  }
229
- /** Compose the stable process-identity fingerprint used everywhere in this
230
- * module: `ps lstart` (portable, BSD + Linux) plus the Linux-only jiffies
231
- * discriminator when available (best-effort see `procStartTicks`). ALWAYS
232
- * used to build an identity string, never `lstart` alone directly, so every
233
- * identity captured anywhere (launch-time `recordPid`, the teardown
234
- * snapshot, and the escalation-window re-check) is comparable apples-to-
235
- * apples. */
236
- function composeIdentity(pid, lstart) {
237
- const ticks = procStartTicks(pid);
238
- return ticks !== null ? `${lstart}#${ticks}` : lstart;
274
+ /** How long a FAILED (`null`) boot_id read is remembered as negative before the
275
+ * next call is allowed to re-read. Bounds the cost on a genuinely no-boot_id
276
+ * host (macOS / no procfs) to one throwing `/proc` read per window rather than
277
+ * one per identity composition identity is composed per supervised row per
278
+ * daemon tick and per process in a full ps-table capture, so an uncapped
279
+ * retry-on-null is a synchronous read storm there. Kept short so a transient
280
+ * miss on a real boot_id host still recovers promptly. */
281
+ const BOOT_ID_NEGATIVE_TTL_MS = 60_000;
282
+ /** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
283
+ * whole lifetime of this process it only changes on a kernel boot, which
284
+ * necessarily kills this process — so once a real value is read it is cached
285
+ * for good, rather than re-read for every row of a whole-process-table `ps`
286
+ * scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative
287
+ * for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a
288
+ * transient `/proc` miss on a boot_id-capable host still recovers (never
289
+ * permanently downgraded to legacy `lstart#ticks`), while a genuinely
290
+ * no-boot_id host is not hammered with a synchronous throwing read on every
291
+ * identity composition. `now` is injectable for tests. */
292
+ export function makeBootIdCache(read, now = Date.now, negativeTtlMs = BOOT_ID_NEGATIVE_TTL_MS) {
293
+ let cached = null;
294
+ let retryAfter = 0;
295
+ return () => {
296
+ if (cached !== null)
297
+ return cached;
298
+ const t = now();
299
+ if (t < retryAfter)
300
+ return null; // within the negative-cache window — don't re-read
301
+ const v = read();
302
+ if (v !== null)
303
+ return (cached = v);
304
+ retryAfter = t + negativeTtlMs;
305
+ return null;
306
+ };
307
+ }
308
+ const bootIdBase = makeBootIdCache(readKernelBootId);
309
+ /** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `<lstart>#<ticks>` row
310
+ * whose base is NOT a boot_id UUID but which DOES carry ticks? These are the
311
+ * rows the one-time startup migration re-records once this platform can
312
+ * compose boot-scoped identities (see `migrateLegacyPidIdentities`). */
313
+ export function isLegacyTicksIdentity(identity) {
314
+ const { base, ticks } = splitIdentity(identity);
315
+ return ticks !== undefined && !BOOT_ID_RE.test(base);
316
+ }
317
+ /** Is `identity` a NEW-format boot-scoped baseline — `<bootId>#<ticks>` with a
318
+ * real boot_id UUID base AND ticks? The startup migration only re-records a
319
+ * legacy row when the pid's CURRENT identity comes out in this shape (i.e. the
320
+ * platform now composes new-format), never otherwise. */
321
+ export function isBootScopedIdentity(identity) {
322
+ const { base, ticks } = splitIdentity(identity);
323
+ return ticks !== undefined && BOOT_ID_RE.test(base);
324
+ }
325
+ /** Compose the stable, BOOT-SCOPED process-identity fingerprint used
326
+ * everywhere in this module. ALWAYS used to build an identity string, never a
327
+ * raw field directly, so every identity captured anywhere (launch-time
328
+ * `recordPid`, the teardown snapshot, and the escalation-window re-check) is
329
+ * comparable apples-to-apples.
330
+ *
331
+ * Platform capability, not layered fallbacks — the base is the finest STABLE
332
+ * per-boot anchor available:
333
+ * - Linux-with-procfs (the production guest shape): `<bootId>#<ticks>` — the
334
+ * per-boot `boot_id` UUID plus the per-process jiffies-since-boot. Ticks
335
+ * reset per boot and can repeat, so scoping the base to the boot is what
336
+ * makes an equal-ticks compare safe ACROSS boots (see `identitiesMatch`).
337
+ * - Linux-with-procfs but no readable `boot_id` (if that combination
338
+ * exists): `<lstart>#<ticks>` — the prior landed behavior.
339
+ * - No procfs ticks (macOS / containers without procfs): `<lstart>` alone —
340
+ * unchanged coarse behavior. The base is only ever a `boot_id` when a
341
+ * `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every
342
+ * process on the boot) would be a useless per-process identity. */
343
+ export function composeIdentity(pid, lstart, deps = {}) {
344
+ // `deps` is a TEST-ONLY seam: it lets a unit test drive the composition with
345
+ // an injected ticks/boot_id pair (so `<bootId>#<ticks>` composition is
346
+ // provable off-Linux, where real `/proc` reads return null), without adding
347
+ // any runtime branch — production callers pass no `deps` and read the real
348
+ // `/proc` values exactly as before.
349
+ const ticks = deps.ticks !== undefined ? deps.ticks : procStartTicks(pid);
350
+ if (ticks === null)
351
+ return lstart;
352
+ const bootId = deps.bootId !== undefined ? deps.bootId : bootIdBase();
353
+ return `${bootId ?? lstart}#${ticks}`;
239
354
  }
240
355
  /** One combined `ps` probe over the WHOLE process table — `pid`, `ppid`, AND
241
356
  * `lstart` per row — parsed into BOTH a ppid → children-pid[] map (for
@@ -57,6 +57,6 @@ export declare class RemoteCanvasSource implements CanvasSource {
57
57
  subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
58
58
  subscribersOf(id: string): Promise<SubscriptionRef[]>;
59
59
  view(root: string): Promise<string[]>;
60
- askCountsForView(root: string): Promise<Record<string, number>>;
60
+ ticketCountsForView(root: string): Promise<Record<string, number>>;
61
61
  hasActiveLiveSubscription(id: string): Promise<boolean>;
62
62
  }
@@ -12,7 +12,7 @@ const CACHE_TTL_MS = 1000;
12
12
  // `execJson<T>` parses stdout and casts it to T with NO structural check — a
13
13
  // remote subprocess that exits 0 with a wrong shape (e.g. `{}`, `{node:null}`)
14
14
  // would otherwise pass through as a "valid" T. `fetchShow`/`listNodes`/
15
- // `askCountsForView` validate before caching/returning; on a shape mismatch
15
+ // `ticketCountsForView` validate before caching/returning; on a shape mismatch
16
16
  // they degrade exactly like the existing network-failure path (null/[]/{}),
17
17
  // never a malformed object a caller might dereference.
18
18
  // ---------------------------------------------------------------------------
@@ -206,7 +206,9 @@ export class RemoteCanvasSource {
206
206
  this.viewCache.set(root, { value: out, at: Date.now() });
207
207
  return out;
208
208
  }
209
- async askCountsForView(root) {
209
+ async ticketCountsForView(root) {
210
+ // The `canvas attention map` transport is unchanged; its `counts` values now
211
+ // mean all pending human tickets (deck/review/notify), not just asks.
210
212
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
211
213
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
212
214
  }
@@ -126,16 +126,16 @@ export declare function dashboardRowsAll(): DashboardRow[];
126
126
  export declare function dashboardRowsAllFromSource(source: CanvasSource): Promise<DashboardRow[]>;
127
127
  /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
128
128
  * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
129
- * meta read + one telemetry read + one ask scan. Idempotent (no-op once enriched).
130
- * For a batch, prefer {@link enrichRows} — it scans each cwd's ask inbox once. */
129
+ * meta read + one telemetry read + one ticket scan. Idempotent (no-op once enriched).
130
+ * For a batch, prefer {@link enrichRows} — it scans each cwd's ticket inbox once. */
131
131
  export declare function enrichRow(row: DashboardRow): DashboardRow;
132
132
  /** Batch {@link enrichRow}: enrich every not-yet-enriched row, scanning each
133
- * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
134
- * Use this for a whole viewport / the full forest; mutates each row in place. */
133
+ * distinct cwd's ticket inbox exactly ONCE (via `ticketCountsForNodes`) instead
134
+ * of per row. Use this for a whole viewport / the full forest; mutates each row in place. */
135
135
  export declare function enrichRows(rows: DashboardRow[]): void;
136
136
  /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
137
137
  * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
138
- * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
138
+ * `remoteAsks` — a pre-fetched `ticketCountsForView` map (one call per forest
139
139
  * root; the caller's job, since the right root set is a browse/snapshot
140
140
  * concern, not this per-row enrichment loop's). See `runBrowse`'s
141
141
  * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
@@ -18,7 +18,7 @@ import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
18
18
  import { RemoteCanvasSource } from './remote-canvas-source.js';
19
19
  import { fullName } from './labels.js';
20
20
  import { jobDir, contextDir } from './paths.js';
21
- import { countAsks, asksForNodes } from './attention.js';
21
+ import { countTickets, ticketCountsForNodes } from './attention.js';
22
22
  import { isPidAlive } from './pid.js';
23
23
  import { listFocuses } from './focuses.js';
24
24
  import { resolveNodeVisual, faultSummary } from './status-glyph.js';
@@ -68,7 +68,7 @@ function nodeLine(nodeId, indent, connector) {
68
68
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
69
69
  const tel = readNodeTelemetry(nodeId);
70
70
  const ctx = fmtCtx(tel.tokens_in);
71
- const asks = countAsks(nodeId);
71
+ const asks = countTickets(nodeId);
72
72
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
73
73
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
74
74
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
@@ -119,7 +119,7 @@ export function renderTree(rootId) {
119
119
  return `? <missing node: ${rootId}>`;
120
120
  const tel = readNodeTelemetry(rootId);
121
121
  const ctx = fmtCtx(tel.tokens_in);
122
- const asks = countAsks(rootId);
122
+ const asks = countTickets(rootId);
123
123
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
124
124
  const hanging = hangingFor(node);
125
125
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
@@ -182,7 +182,7 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote) {
182
182
  const hanging = remote ? null : hangingFor(node);
183
183
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
184
184
  const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(nodeId).tokens_in);
185
- const asks = remote ? 0 : countAsks(nodeId);
185
+ const asks = remote ? 0 : countTickets(nodeId);
186
186
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
187
187
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
188
188
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
@@ -212,7 +212,7 @@ export async function renderTreeFromSource(source, rootId) {
212
212
  if (node === null)
213
213
  return `? <missing node: ${rootId}>`;
214
214
  const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
215
- const asks = remote ? 0 : countAsks(rootId);
215
+ const asks = remote ? 0 : countTickets(rootId);
216
216
  const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
217
217
  const hanging = remote ? null : hangingFor(node);
218
218
  const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
@@ -457,7 +457,7 @@ export function dashboardRows(rootId) {
457
457
  kind: node.kind,
458
458
  mode: node.mode,
459
459
  ctx_tokens: tel.tokens_in ?? 0,
460
- asks: countAsks(id),
460
+ asks: countTickets(id),
461
461
  cwd: node.cwd,
462
462
  created: node.created,
463
463
  }];
@@ -542,8 +542,8 @@ export async function dashboardRowsAllFromSource(source) {
542
542
  // ---------------------------------------------------------------------------
543
543
  /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
544
544
  * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
545
- * meta read + one telemetry read + one ask scan. Idempotent (no-op once enriched).
546
- * For a batch, prefer {@link enrichRows} — it scans each cwd's ask inbox once. */
545
+ * meta read + one telemetry read + one ticket scan. Idempotent (no-op once enriched).
546
+ * For a batch, prefer {@link enrichRows} — it scans each cwd's ticket inbox once. */
547
547
  export function enrichRow(row) {
548
548
  if (row.enriched === true)
549
549
  return row;
@@ -551,18 +551,18 @@ export function enrichRow(row) {
551
551
  if (meta !== null)
552
552
  row.name = fullName(meta);
553
553
  row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
554
- row.asks = countAsks(row.node_id);
554
+ row.asks = countTickets(row.node_id);
555
555
  row.enriched = true;
556
556
  return row;
557
557
  }
558
558
  /** Batch {@link enrichRow}: enrich every not-yet-enriched row, scanning each
559
- * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
560
- * Use this for a whole viewport / the full forest; mutates each row in place. */
559
+ * distinct cwd's ticket inbox exactly ONCE (via `ticketCountsForNodes`) instead
560
+ * of per row. Use this for a whole viewport / the full forest; mutates each row in place. */
561
561
  export function enrichRows(rows) {
562
562
  const todo = rows.filter((r) => r.enriched !== true);
563
563
  if (todo.length === 0)
564
564
  return;
565
- const asks = asksForNodes(todo.map((r) => r.node_id));
565
+ const asks = ticketCountsForNodes(todo.map((r) => r.node_id));
566
566
  for (const row of todo) {
567
567
  const meta = getNode(row.node_id);
568
568
  if (meta !== null)
@@ -574,7 +574,7 @@ export function enrichRows(rows) {
574
574
  }
575
575
  /** Telemetry (ctx tokens) is a local disk read, so it is suppressed to 0 for a
576
576
  * remote source. Ask counts are suppressed to 0 UNLESS the caller supplies
577
- * `remoteAsks` — a pre-fetched `askCountsForView` map (one call per forest
577
+ * `remoteAsks` — a pre-fetched `ticketCountsForView` map (one call per forest
578
578
  * root; the caller's job, since the right root set is a browse/snapshot
579
579
  * concern, not this per-row enrichment loop's). See `runBrowse`'s
580
580
  * `remoteAskCounts`, fetched once per snapshot rebuild, not per flush. */
@@ -583,7 +583,7 @@ export async function enrichRowsFromSource(source, rows, remoteAsks) {
583
583
  if (todo.length === 0)
584
584
  return;
585
585
  const remote = source instanceof RemoteCanvasSource;
586
- const asks = remote ? (remoteAsks ?? {}) : asksForNodes(todo.map((r) => r.node_id));
586
+ const asks = remote ? (remoteAsks ?? {}) : ticketCountsForNodes(todo.map((r) => r.node_id));
587
587
  await Promise.all(todo.map(async (row) => {
588
588
  const meta = await source.getNode(row.node_id);
589
589
  if (meta !== null)
@@ -9,7 +9,7 @@ export interface CanvasSource {
9
9
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
10
10
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
11
11
  view(root: string): Promise<string[]>;
12
- askCountsForView(root: string): Promise<Record<string, number>>;
12
+ ticketCountsForView(root: string): Promise<Record<string, number>>;
13
13
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
14
14
  }
15
15
  export declare class LocalCanvasSource implements CanvasSource {
@@ -21,7 +21,7 @@ export declare class LocalCanvasSource implements CanvasSource {
21
21
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
22
22
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
23
23
  view(root: string): Promise<string[]>;
24
- askCountsForView(root: string): Promise<Record<string, number>>;
24
+ ticketCountsForView(root: string): Promise<Record<string, number>>;
25
25
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
26
26
  }
27
27
  export declare const localCanvasSource: CanvasSource;
@@ -1,5 +1,5 @@
1
1
  import { getNode, getRow, listNodes, subscriptionsOf, subscribersOf, view, hasActiveLiveSubscription, } from './canvas.js';
2
- import { asksForNodes } from './attention.js';
2
+ import { ticketCountsForNodes } from './attention.js';
3
3
  import { readState } from '../config.js';
4
4
  import { resolveRemoteCanvasTarget } from '../view/remote-canvas-target.js';
5
5
  import { RemoteCanvasSource } from './remote-canvas-source.js';
@@ -22,8 +22,8 @@ export class LocalCanvasSource {
22
22
  async view(root) {
23
23
  return view(root);
24
24
  }
25
- async askCountsForView(root) {
26
- return asksForNodes([root, ...view(root)]);
25
+ async ticketCountsForView(root) {
26
+ return ticketCountsForNodes([root, ...view(root)]);
27
27
  }
28
28
  async hasActiveLiveSubscription(nodeId) {
29
29
  return hasActiveLiveSubscription(nodeId);
@@ -0,0 +1,38 @@
1
+ // Run with: node --import tsx/esm --test src/core/keybindings/__tests__/inbox-affordance.test.ts
2
+ //
3
+ // The shared inbox-affordance formatter is the single source of truth for how
4
+ // every pending-interaction surface (attach footer, `human ask`/`review`
5
+ // follow-up + help effects, `node focus` error) names the inbox shortcut. These
6
+ // guard that it resolves the live binding — default, custom, multi-gesture, and
7
+ // disabled — instead of hardcoding a symbol that can drift from the real key.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { resolveKeybindings } from '../index.js';
11
+ import { inboxOpenHint, inboxOpenInstruction, inboxPopupHint, inboxShortcut, } from '../inbox.js';
12
+ const custom = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+j'] });
13
+ const multi = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+i', 'alt+j'] });
14
+ const disabled = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': [] });
15
+ test('inboxShortcut resolves default, custom, multi-gesture, and disabled', () => {
16
+ assert.equal(inboxShortcut(resolveKeybindings()), 'Alt+I');
17
+ assert.equal(inboxShortcut(custom()), 'Alt+J');
18
+ assert.equal(inboxShortcut(multi()), 'Alt+I / Alt+J');
19
+ assert.equal(inboxShortcut(disabled()), null);
20
+ });
21
+ test('inboxOpenHint mirrors the attach footer contract', () => {
22
+ assert.equal(inboxOpenHint(resolveKeybindings()), 'Alt+I inbox');
23
+ assert.equal(inboxOpenHint(custom()), 'Alt+J inbox');
24
+ assert.equal(inboxOpenHint(multi()), 'Alt+I / Alt+J inbox');
25
+ assert.equal(inboxOpenHint(disabled()), 'hl inbox open');
26
+ });
27
+ test('inboxPopupHint is a bare parenthetical token, CLI form when disabled', () => {
28
+ assert.equal(inboxPopupHint(resolveKeybindings()), 'Alt+I');
29
+ assert.equal(inboxPopupHint(custom()), 'Alt+J');
30
+ assert.equal(inboxPopupHint(multi()), 'Alt+I / Alt+J');
31
+ assert.equal(inboxPopupHint(disabled()), '`hl inbox open`');
32
+ });
33
+ test('inboxOpenInstruction is an imperative clause, CLI-only when disabled', () => {
34
+ assert.equal(inboxOpenInstruction(resolveKeybindings()), 'press Alt+I (or run `hl inbox open` at a terminal)');
35
+ assert.equal(inboxOpenInstruction(custom()), 'press Alt+J (or run `hl inbox open` at a terminal)');
36
+ assert.equal(inboxOpenInstruction(multi()), 'press Alt+I / Alt+J (or run `hl inbox open` at a terminal)');
37
+ assert.equal(inboxOpenInstruction(disabled()), 'run `hl inbox open` at a terminal');
38
+ });
@@ -17,8 +17,9 @@ const REQUIRED_IDS = [
17
17
  ...ids('crtr.tmux.menu', ['open', 'promote', 'resume', 'demote', 'detach', 'close-subtree', 'context', 'providers', 'graph', 'focus-manager', 'issues']),
18
18
  ...ids('crtr.tmux.menu.issue', ['idea', 'bug']),
19
19
  ...ids('crtr.tmux.node', ['next', 'previous']),
20
+ 'crtr.tmux.inbox.toggle',
20
21
  ...ids('crtr.tmux.view', ['next', 'previous']),
21
- ...ids('crtr.attach', ['detach', 'clear-or-detach', 'graph.toggle', 'model-ladder.next']),
22
+ ...ids('crtr.attach', ['detach', 'clear-or-detach', 'graph.toggle', 'model-ladder.next', 'model-ladder.previous']),
22
23
  'crtr.mode.cycle',
23
24
  ...ids('crtr.graph', ['down', 'up', 'first', 'last', 'collapse-or-manager', 'expand-or-child', 'focus', 'focus-manager', 'close-subtree', 'dismiss']),
24
25
  ...ids('crtr.graph.confirm', ['accept', 'cancel']),
@@ -7,8 +7,8 @@ type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child
7
7
  type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' | `slot.${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}` | 'create' | 'search-existing' | 'use-root' | 'set-default' | 'add-directory' | 'unpin';
8
8
  type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance';
9
9
  type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
10
- export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'request-control' | 'release-control' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.settings.${'down' | 'up' | 'cell-previous' | 'cell-next' | 'refresh' | 'quit' | 'edit-or-submit' | 'cancel-edit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}`;
11
- export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.request-control">, BindingDefinition<"crtr.view.chat.release-control">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.settings.down"> | BindingDefinition<"crtr.view.settings.up"> | BindingDefinition<"crtr.view.settings.cell-previous"> | BindingDefinition<"crtr.view.settings.cell-next"> | BindingDefinition<"crtr.view.settings.refresh"> | BindingDefinition<"crtr.view.settings.quit"> | BindingDefinition<"crtr.view.settings.edit-or-submit"> | BindingDefinition<"crtr.view.settings.cancel-edit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">];
10
+ export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'request-control' | 'release-control' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.settings.${'down' | 'up' | 'cell-previous' | 'cell-next' | 'refresh' | 'quit' | 'edit-or-submit' | 'cancel-edit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}`;
11
+ export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.request-control">, BindingDefinition<"crtr.view.chat.release-control">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.settings.down"> | BindingDefinition<"crtr.view.settings.up"> | BindingDefinition<"crtr.view.settings.cell-previous"> | BindingDefinition<"crtr.view.settings.cell-next"> | BindingDefinition<"crtr.view.settings.refresh"> | BindingDefinition<"crtr.view.settings.quit"> | BindingDefinition<"crtr.view.settings.edit-or-submit"> | BindingDefinition<"crtr.view.settings.cancel-edit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">];
12
12
  export declare const BINDING_IDS: ReadonlySet<string>;
13
13
  export declare const SAFETY_REQUIREMENTS: readonly SafetyRequirement[];
14
14
  export {};
@@ -40,13 +40,15 @@ export const BINDING_CATALOG = Object.freeze([
40
40
  b('crtr.tmux.menu.issue.bug', 'Global tmux', 'Create bug', 'Create a bug issue.', ['b'], 'tmux-menu-selector', ['tmux.menu.issue']),
41
41
  b('crtr.tmux.node.next', 'Global tmux', 'Next node', 'Focus the next canvas node.', ['alt+]'], 'host-global', ['tmux.root']),
42
42
  b('crtr.tmux.node.previous', 'Global tmux', 'Previous node', 'Focus the previous canvas node.', ['alt+['], 'host-global', ['tmux.root']),
43
+ b('crtr.tmux.inbox.toggle', 'Global tmux', 'Toggle inbox', 'Open or close the human inbox popup.', ['alt+i'], 'host-global', ['tmux.root']),
43
44
  b('crtr.tmux.view.next', 'Global tmux', 'Next view', 'Focus the next view.', ['alt+v > ]'], 'tmux-sequence', ['tmux.root']),
44
45
  b('crtr.tmux.view.previous', 'Global tmux', 'Previous view', 'Focus the previous view.', ['alt+v > ['], 'tmux-sequence', ['tmux.root']),
45
46
  b('crtr.attach.detach', 'Attach', 'Detach', 'Detach from the node.', ['ctrl+d'], 'host-global', ATTACH_CONTEXTS),
46
47
  b('crtr.attach.clear-or-detach', 'Attach', 'Clear or detach', 'Clear input or detach when input is empty.', ['ctrl+c'], 'host-global', ATTACH_CONTEXTS),
47
48
  b('crtr.attach.graph.toggle', 'Attach', 'Toggle graph', 'Open or close the graph overlay.', ['alt+g'], 'host-global', ATTACH_CONTEXTS),
48
- b('crtr.attach.model-ladder.next', 'Attach', 'Next model ladder', 'Move to the next model ladder rung.', ['alt+shift+m'], 'host-global', ATTACH_CONTEXTS),
49
- b('crtr.mode.cycle', 'Attach', 'Cycle mode', 'Cycle the active agent mode.', ['alt+m'], 'host-global', ATTACH_CONTEXTS),
49
+ b('crtr.attach.model-ladder.next', 'Attach', 'Next model ladder', 'Move to the next model ladder rung.', ['alt+m'], 'host-global', ATTACH_CONTEXTS),
50
+ b('crtr.attach.model-ladder.previous', 'Attach', 'Previous model ladder', 'Move to the previous model ladder rung.', ['alt+shift+m'], 'host-global', ATTACH_CONTEXTS),
51
+ b('crtr.mode.cycle', 'Attach', 'Cycle mode', 'Cycle the active agent mode.', ['alt+shift+p'], 'host-global', ATTACH_CONTEXTS),
50
52
  b('crtr.graph.down', 'GRAPH', 'Move down', 'Move to the next graph row.', ['j'], 'terminal', ['graph']),
51
53
  b('crtr.graph.up', 'GRAPH', 'Move up', 'Move to the previous graph row.', ['k'], 'terminal', ['graph']),
52
54
  b('crtr.graph.first', 'GRAPH', 'First row', 'Move to the first graph row.', ['g'], 'terminal', ['graph']),
@@ -0,0 +1,21 @@
1
+ import type { BindingId } from './catalog.js';
2
+ import type { BindingResolution } from './types.js';
3
+ /** The single source of truth for how every pending-interaction surface names
4
+ * the inbox shortcut. Resolves the live `crtr.tmux.inbox.toggle` gestures and
5
+ * returns the formatted label(s) — joined with ' / ' when multiple, matching
6
+ * crouter's `formatBinding` convention — or null when the binding is disabled.
7
+ * Bindings default to the user's resolved config so a bare call reflects
8
+ * current state; callers holding a live snapshot (e.g. the attach viewer) pass
9
+ * it so a `/reload` rebind is honored. */
10
+ export declare function inboxShortcut(bindings?: BindingResolution<BindingId>): string | null;
11
+ /** Closed-inbox footer affordance: "<shortcut> inbox", or the CLI fallback
12
+ * `hl inbox open` when the binding is disabled. */
13
+ export declare function inboxOpenHint(bindings?: BindingResolution<BindingId>): string;
14
+ /** Bare token naming the inbox affordance for a parenthetical — the resolved
15
+ * shortcut (e.g. `Alt+I`), or the backtick-quoted CLI form when disabled. Use
16
+ * where prose reads "the inbox popup (<hint>)". */
17
+ export declare function inboxPopupHint(bindings?: BindingResolution<BindingId>): string;
18
+ /** Imperative clause telling the human how to open the inbox — "press
19
+ * <shortcut> (or run `hl inbox open` at a terminal)", or just the CLI form
20
+ * when the binding is disabled. */
21
+ export declare function inboxOpenInstruction(bindings?: BindingResolution<BindingId>): string;
@@ -0,0 +1,35 @@
1
+ import { formatGesture } from './match.js';
2
+ import { resolveUserKeybindings } from './resolve.js';
3
+ const INBOX_TOGGLE = 'crtr.tmux.inbox.toggle';
4
+ /** The single source of truth for how every pending-interaction surface names
5
+ * the inbox shortcut. Resolves the live `crtr.tmux.inbox.toggle` gestures and
6
+ * returns the formatted label(s) — joined with ' / ' when multiple, matching
7
+ * crouter's `formatBinding` convention — or null when the binding is disabled.
8
+ * Bindings default to the user's resolved config so a bare call reflects
9
+ * current state; callers holding a live snapshot (e.g. the attach viewer) pass
10
+ * it so a `/reload` rebind is honored. */
11
+ export function inboxShortcut(bindings = resolveUserKeybindings()) {
12
+ const gestures = bindings.gestures(INBOX_TOGGLE);
13
+ return gestures.length > 0 ? gestures.map(formatGesture).join(' / ') : null;
14
+ }
15
+ /** Closed-inbox footer affordance: "<shortcut> inbox", or the CLI fallback
16
+ * `hl inbox open` when the binding is disabled. */
17
+ export function inboxOpenHint(bindings) {
18
+ const shortcut = inboxShortcut(bindings);
19
+ return shortcut !== null ? `${shortcut} inbox` : 'hl inbox open';
20
+ }
21
+ /** Bare token naming the inbox affordance for a parenthetical — the resolved
22
+ * shortcut (e.g. `Alt+I`), or the backtick-quoted CLI form when disabled. Use
23
+ * where prose reads "the inbox popup (<hint>)". */
24
+ export function inboxPopupHint(bindings) {
25
+ return inboxShortcut(bindings) ?? '`hl inbox open`';
26
+ }
27
+ /** Imperative clause telling the human how to open the inbox — "press
28
+ * <shortcut> (or run `hl inbox open` at a terminal)", or just the CLI form
29
+ * when the binding is disabled. */
30
+ export function inboxOpenInstruction(bindings) {
31
+ const shortcut = inboxShortcut(bindings);
32
+ return shortcut !== null
33
+ ? `press ${shortcut} (or run \`hl inbox open\` at a terminal)`
34
+ : 'run `hl inbox open` at a terminal';
35
+ }