@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.
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
- package/dist/clients/attach/attach-cmd.d.ts +2 -3
- package/dist/clients/attach/attach-cmd.js +689 -689
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +57 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +15 -17
- package/dist/commands/node.js +5 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
- package/dist/commands/sys/setup-core.d.ts +10 -0
- package/dist/commands/sys/setup-core.js +39 -1
- package/dist/commands/sys/setup.js +10 -1
- package/dist/core/__tests__/boot.test.js +141 -6
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +145 -0
- package/dist/core/__tests__/tmux-surface.test.js +180 -2
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +45 -0
- package/dist/core/canvas/boot.js +36 -3
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +65 -1
- package/dist/core/canvas/pid.d.ts +86 -17
- package/dist/core/canvas/pid.js +152 -37
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/keybindings/inbox.d.ts +21 -0
- package/dist/core/keybindings/inbox.js +35 -0
- package/dist/core/keybindings/index.d.ts +1 -0
- package/dist/core/keybindings/index.js +1 -0
- package/dist/core/preview-registry.js +23 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +2 -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 +1 -0
- package/dist/core/runtime/tmux.js +55 -1
- package/dist/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +36 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
package/dist/core/canvas/pid.js
CHANGED
|
@@ -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
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
// `
|
|
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
|
|
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
|
|
130
|
-
* For a batch, prefer {@link enrichRows} — it scans each cwd's
|
|
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
|
|
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 `
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 :
|
|
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 :
|
|
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:
|
|
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
|
|
546
|
-
* For a batch, prefer {@link enrichRows} — it scans each cwd's
|
|
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 =
|
|
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
|
|
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 =
|
|
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 `
|
|
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 ?? {}) :
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
26
|
-
return
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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+
|
|
49
|
-
b('crtr.
|
|
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
|
+
}
|