@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { BINDING_CATALOG, BINDING_IDS, SAFETY_REQUIREMENTS, type BindingId, } from './catalog.js';
|
|
2
2
|
export { GestureSyntaxError, KeybindingValidationError, normalizeGesture, normalizeSparseOverrides, normalizeStroke, resolveKeybindings, resolveUserKeybindings, } from './resolve.js';
|
|
3
3
|
export { canonicalTerminalStroke, formatBinding, formatGesture, formatStroke, matchesPiTuiInput, matchesTerminalInput, type PiTuiMatcher, type RawTerminalInput, } from './match.js';
|
|
4
|
+
export { inboxOpenHint, inboxOpenInstruction, inboxPopupHint, inboxShortcut, } from './inbox.js';
|
|
4
5
|
export { KeybindingConcurrentEditError, persistUserKeybindings, type PersistedKeybindings, type PersistKeybindingsOptions, } from './persistence.js';
|
|
5
6
|
export type { BindingDefinition, BindingInputKind, BindingResolution, BindingSafetyCapability, EffectiveBinding, KeybindingDiagnostic, KeybindingDiagnosticCode, SafetyRequirement, SparseKeybindingOverrides, } from './types.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { BINDING_CATALOG, BINDING_IDS, SAFETY_REQUIREMENTS, } from './catalog.js';
|
|
2
2
|
export { GestureSyntaxError, KeybindingValidationError, normalizeGesture, normalizeSparseOverrides, normalizeStroke, resolveKeybindings, resolveUserKeybindings, } from './resolve.js';
|
|
3
3
|
export { canonicalTerminalStroke, formatBinding, formatGesture, formatStroke, matchesPiTuiInput, matchesTerminalInput, } from './match.js';
|
|
4
|
+
export { inboxOpenHint, inboxOpenInstruction, inboxPopupHint, inboxShortcut, } from './inbox.js';
|
|
4
5
|
export { KeybindingConcurrentEditError, persistUserKeybindings, } from './persistence.js';
|
|
@@ -93,7 +93,6 @@ const LEAF_ICON_CP = {
|
|
|
93
93
|
'human notify': 0xf0f3,
|
|
94
94
|
'human show': 0xf108,
|
|
95
95
|
'human cancel': 0xf00d,
|
|
96
|
-
'human inbox': 0xf01c,
|
|
97
96
|
'human list': 0xf03a,
|
|
98
97
|
'human deck': 0xf0db,
|
|
99
98
|
'human resolve': 0xf00c,
|
|
@@ -1083,29 +1082,28 @@ function truncateLine(line, max) {
|
|
|
1083
1082
|
return line.length > max ? `${line.slice(0, max).trimEnd()}…` : line;
|
|
1084
1083
|
}
|
|
1085
1084
|
const ASK_TEASER_CHARS = 160;
|
|
1086
|
-
/** `crtr human ask` (humanAsk.run, src/commands/human/prompts.ts
|
|
1085
|
+
/** `crtr human ask` (humanAsk.run, src/commands/human/prompts.ts) returns
|
|
1087
1086
|
* `{job_id, dir, follow_up}` — `job_id` is always short (a node id) so it
|
|
1088
1087
|
* always bullets; `dir`/`follow_up` cross the prose threshold depending on
|
|
1089
|
-
* path length, so the shape is recognized by `job_id` plus
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1088
|
+
* path length, so the shape is recognized by `job_id` plus the stable road-sign
|
|
1089
|
+
* prefix `Queued for the human` (queuedFollowUp, prompts.ts — its inbox-
|
|
1090
|
+
* affordance tail resolves per call, the prefix never varies) appearing
|
|
1091
|
+
* anywhere in the text — never by assuming dir/follow_up's bullet vs. prose
|
|
1092
|
+
* form. Arrival is always just durable queue state, so the card collapses to
|
|
1093
|
+
* one posted line. The
|
|
1094
|
+
* question text isn't in the output at all (only the bare-question invocation
|
|
1095
|
+
* echoes it), so it's recovered from argv: the first non-flag token after
|
|
1096
|
+
* `human ask`, skipping `--context-file`'s value — absent for a
|
|
1097
|
+
* `--context-file` deck invocation, which still gets the road-sign line with
|
|
1098
|
+
* no question teaser. Returns null when the road-sign is absent (an
|
|
1099
|
+
* error/unrecognized shape). */
|
|
1098
1100
|
function summarizeHumanAsk(text, tokens) {
|
|
1099
1101
|
const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1100
1102
|
if (jobId === undefined)
|
|
1101
1103
|
return null;
|
|
1102
|
-
|
|
1103
|
-
const delivered = text.includes('no need to poll');
|
|
1104
|
-
if (!drained && !delivered)
|
|
1104
|
+
if (!text.includes('Queued for the human'))
|
|
1105
1105
|
return null;
|
|
1106
|
-
const lines =
|
|
1107
|
-
? [{ text: 'ask posted — drain via `human inbox`', tone: 'warning' }]
|
|
1108
|
-
: [{ text: 'ask posted → waiting on human', tone: 'accent' }];
|
|
1106
|
+
const lines = [{ text: 'ask posted → waiting on human', tone: 'accent' }];
|
|
1109
1107
|
const question = firstPositionalArg(tokens, 2, new Set(['--context-file']));
|
|
1110
1108
|
if (question !== undefined) {
|
|
1111
1109
|
const firstLine = truncateLine(question.split('\n')[0].trim(), ASK_TEASER_CHARS);
|
|
@@ -1114,26 +1112,22 @@ function summarizeHumanAsk(text, tokens) {
|
|
|
1114
1112
|
}
|
|
1115
1113
|
return lines;
|
|
1116
1114
|
}
|
|
1117
|
-
/** `crtr human review` (humanReview.run, src/commands/human/prompts.ts
|
|
1115
|
+
/** `crtr human review` (humanReview.run, src/commands/human/prompts.ts)
|
|
1118
1116
|
* returns `{job_id, dir, output, follow_up}` — same recognition strategy as
|
|
1119
|
-
* summarizeHumanAsk (job_id bullet +
|
|
1120
|
-
*
|
|
1121
|
-
* isn't in the output either, so its basename is recovered
|
|
1122
|
-
* file positional, skipping `--output`'s value). Returns null
|
|
1123
|
-
*
|
|
1117
|
+
* summarizeHumanAsk (job_id bullet + the stable `Queued for the human`
|
|
1118
|
+
* road-sign prefix, which review shares with ask). The
|
|
1119
|
+
* reviewed file isn't in the output either, so its basename is recovered
|
|
1120
|
+
* from argv (the file positional, skipping `--output`'s value). Returns null
|
|
1121
|
+
* when the road-sign is absent. */
|
|
1124
1122
|
function summarizeHumanReview(text, tokens) {
|
|
1125
1123
|
const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1126
1124
|
if (jobId === undefined)
|
|
1127
1125
|
return null;
|
|
1128
|
-
|
|
1129
|
-
const live = text.includes("live on the human's screen for anchored, line-by-line review");
|
|
1130
|
-
if (!drained && !live)
|
|
1126
|
+
if (!text.includes('Queued for the human'))
|
|
1131
1127
|
return null;
|
|
1132
1128
|
const file = firstPositionalArg(tokens, 2, new Set(['--output']));
|
|
1133
1129
|
const label = file !== undefined ? `review posted: ${basename(file)}` : 'review posted';
|
|
1134
|
-
return
|
|
1135
|
-
? [{ text: `${label} — drain via \`human inbox\``, tone: 'warning' }]
|
|
1136
|
-
: [{ text: `${label} → waiting on human`, tone: 'accent' }];
|
|
1130
|
+
return [{ text: `${label} → waiting on human`, tone: 'accent' }];
|
|
1137
1131
|
}
|
|
1138
1132
|
/** `crtr human list` (humanList.run, src/commands/human/queue.ts:283) has no
|
|
1139
1133
|
* custom `render`; the generic renderer bullets `- total: N` then an
|
|
@@ -297,10 +297,10 @@ function rootProfileMessage(cwd) {
|
|
|
297
297
|
* the session IS interactive. Ask on the controlling terminal — create a
|
|
298
298
|
* profile pointing at cwd, or proceed as root — and block until answered.
|
|
299
299
|
* Deliberately a minimal `readline` prompt, not `crtr human ask`: that bridge
|
|
300
|
-
* is asynchronous (
|
|
301
|
-
* and cannot gate pi's boot; the
|
|
302
|
-
*
|
|
303
|
-
*
|
|
300
|
+
* is asynchronous (registers an interaction with humanloop, then waits for the
|
|
301
|
+
* human to answer it from a separate inbox) and cannot gate pi's boot; the
|
|
302
|
+
* humanloop entry points (`human ask`/`review`) are built for a queued,
|
|
303
|
+
* asynchronously-answered interaction, not a two-choice gate on the same TTY pi
|
|
304
304
|
* is about to take over — a raw `readline` question is the actual minimal
|
|
305
305
|
* fit here. */
|
|
306
306
|
/** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
|
|
@@ -21,9 +21,23 @@ import { join } from 'node:path';
|
|
|
21
21
|
* the sessions dir — same vendoring rationale as the slash-command list below. */
|
|
22
22
|
export function piSessionsRoot() {
|
|
23
23
|
const env = process.env['PI_CODING_AGENT_DIR'];
|
|
24
|
-
const agentDir = env !== undefined && env !== '' ? env : join(homedir(), '.pi', 'agent');
|
|
24
|
+
const agentDir = env !== undefined && env !== '' ? expandTilde(env) : join(homedir(), '.pi', 'agent');
|
|
25
25
|
return join(agentDir, 'sessions');
|
|
26
26
|
}
|
|
27
|
+
/** Mirror pi's `expandTildePath` (= `normalizePath` with the default expandTilde
|
|
28
|
+
* on): a bare `~` is the home dir, and a leading `~/` (or `~\` on win32) joins
|
|
29
|
+
* the remainder under home. pi's `getAgentDir()` runs `PI_CODING_AGENT_DIR`
|
|
30
|
+
* through this, so the vendored root must too or `~/custom-pi` yields a broken
|
|
31
|
+
* relative path. Other forms (absolute, relative, `~user`) pass through as pi
|
|
32
|
+
* leaves them. */
|
|
33
|
+
function expandTilde(p) {
|
|
34
|
+
if (p === '~')
|
|
35
|
+
return homedir();
|
|
36
|
+
if (p.startsWith('~/') || (process.platform === 'win32' && p.startsWith('~\\'))) {
|
|
37
|
+
return join(homedir(), p.slice(2));
|
|
38
|
+
}
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* pi's builtin slash commands — vendored verbatim from pi `core/slash-commands.js`
|
|
29
43
|
* `BUILTIN_SLASH_COMMANDS` (review C1). **21 entries at 0.78.1** (review n1 — NOT 23).
|
|
@@ -12,16 +12,6 @@ export declare function focusByPane(pane: string): FocusRow | null;
|
|
|
12
12
|
export declare function focusedNodes(): Set<string>;
|
|
13
13
|
/** Every live viewer row (GC'ing any whose pane has gone). */
|
|
14
14
|
export declare function listFocuses(): FocusRow[];
|
|
15
|
-
/** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
|
|
16
|
-
* surface into: the HIGHEST node of nodeId's graph that has a viewer — the
|
|
17
|
-
* viewer closest to the graph root, i.e. the session/window the user is actually
|
|
18
|
-
* watching this work in. Walks nodeId's spine to its root, enumerates the whole
|
|
19
|
-
* tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
|
|
20
|
-
* of the first node that has one. null when nothing in the graph is on screen —
|
|
21
|
-
* the caller then surfaces in the user's attached pane. PURE (db reads only): no
|
|
22
|
-
* tmux probe, so the pane may be stale; the caller liveness-checks before
|
|
23
|
-
* targeting it. */
|
|
24
|
-
export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
|
|
25
15
|
/** Wait until a broker's view.sock accepts a connection. The launching caller
|
|
26
16
|
* stays async, so the broker's real exit signal can fail fast the wait instead
|
|
27
17
|
* of being masked by a zombie pid. Success proves more than file existence: it
|
|
@@ -84,6 +74,7 @@ export declare function openViewerWindow(nodeId: string, session: string, opts?:
|
|
|
84
74
|
export declare function focus(nodeId: string, opts: {
|
|
85
75
|
pane?: string;
|
|
86
76
|
newPane?: boolean;
|
|
77
|
+
inPlace?: boolean;
|
|
87
78
|
revive: Reviver;
|
|
88
79
|
}): Promise<FocusResult>;
|
|
89
80
|
/** Tear a node off its viewer (close/reset/cancel teardown). The broker engine
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
// tmux-chrome.ts import tmux.ts") holds — every other module reaches the
|
|
25
25
|
// driver verbs through here.
|
|
26
26
|
import { createConnection } from 'node:net';
|
|
27
|
-
import { getNode, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows,
|
|
27
|
+
import { getNode, fullName, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
|
|
28
28
|
import { nodeHasAssistantMessage, isNodeStreaming } from '../canvas/render.js';
|
|
29
29
|
import { headlessBrokerHost } from './host.js';
|
|
30
|
-
import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, } from './tmux.js';
|
|
31
|
-
import { newNodeId
|
|
30
|
+
import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, setPaneOption, respawnPaneSync, windowOfPane, renameWindow, } from './tmux.js';
|
|
31
|
+
import { newNodeId } from './nodes.js';
|
|
32
32
|
import { viewSocketPath } from '../canvas/paths.js';
|
|
33
33
|
import { isPidAlive } from '../canvas/pid.js';
|
|
34
34
|
// Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
|
|
@@ -61,8 +61,7 @@ installTmuxBindings as installMenuBinding, setPaneOption, getPaneOption, piComma
|
|
|
61
61
|
//
|
|
62
62
|
// GC is lazy on read: a row whose viewer pane no longer exists (the user closed
|
|
63
63
|
// the window, the attach client died) is pruned the next time it is read, so the
|
|
64
|
-
// registry self-heals without a sweeper.
|
|
65
|
-
// tmux probe — by design; its caller liveness-checks the pane it returns.)
|
|
64
|
+
// registry self-heals without a sweeper.
|
|
66
65
|
// ---------------------------------------------------------------------------
|
|
67
66
|
/** Prune a viewer row whose pane is gone; return it iff its pane is still live. */
|
|
68
67
|
function liveOrPrune(f) {
|
|
@@ -100,27 +99,6 @@ export function listFocuses() {
|
|
|
100
99
|
return live;
|
|
101
100
|
}
|
|
102
101
|
// ---------------------------------------------------------------------------
|
|
103
|
-
// Graph → viewer routing (for surfacing human-in-the-loop prompts)
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
/** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
|
|
106
|
-
* surface into: the HIGHEST node of nodeId's graph that has a viewer — the
|
|
107
|
-
* viewer closest to the graph root, i.e. the session/window the user is actually
|
|
108
|
-
* watching this work in. Walks nodeId's spine to its root, enumerates the whole
|
|
109
|
-
* tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
|
|
110
|
-
* of the first node that has one. null when nothing in the graph is on screen —
|
|
111
|
-
* the caller then surfaces in the user's attached pane. PURE (db reads only): no
|
|
112
|
-
* tmux probe, so the pane may be stale; the caller liveness-checks before
|
|
113
|
-
* targeting it. */
|
|
114
|
-
export function graphSurfaceTarget(nodeId) {
|
|
115
|
-
const root = rootOfSpine(nodeId);
|
|
116
|
-
for (const id of [root, ...view(root)]) {
|
|
117
|
-
const f = getFocusByNode(id);
|
|
118
|
-
if (f !== null && f.pane !== null)
|
|
119
|
-
return f;
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
102
|
// view.sock readiness — the one shared broker-cold-start primitive
|
|
125
103
|
// ---------------------------------------------------------------------------
|
|
126
104
|
const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
|
|
@@ -311,6 +289,45 @@ export async function focus(nodeId, opts) {
|
|
|
311
289
|
// split pane's location — the caller's own session.
|
|
312
290
|
const callerSession = paneLocation(callerPane)?.session ?? null;
|
|
313
291
|
const fallbackSession = callerSession ?? '';
|
|
292
|
+
// (b0) --in-place → the CALLER pane BECOMES this node's viewer, replacing
|
|
293
|
+
// whatever it showed (the attach subagent scroll+swap: ↓ into the reports
|
|
294
|
+
// roster, Enter dives into that child right here, no new split). This
|
|
295
|
+
// re-execs the caller pane via `respawn-pane -k`, so it MUST be driven
|
|
296
|
+
// from OUTSIDE that pane's process tree (the viewer shells this through
|
|
297
|
+
// `tmux run-shell`, never a direct child) — else the respawn kills its own
|
|
298
|
+
// driver mid-call. Move the child's viewer here if it had one elsewhere
|
|
299
|
+
// (UNIQUE one-viewer-per-node) and drop the caller pane's prior viewer row
|
|
300
|
+
// (that node's broker keeps running headless, reconnectable by a later
|
|
301
|
+
// focus). The respawned `crtr surface attach` replays scrollback from the
|
|
302
|
+
// welcome snapshot and self-tags the pane `@crtr_node`.
|
|
303
|
+
if (opts.inPlace === true) {
|
|
304
|
+
const prior = focusOf(nodeId);
|
|
305
|
+
if (prior !== null) {
|
|
306
|
+
if (prior.pane !== null && prior.pane !== callerPane && paneExists(prior.pane))
|
|
307
|
+
closePane(prior.pane);
|
|
308
|
+
closeFocusRow(prior.focus_id);
|
|
309
|
+
}
|
|
310
|
+
const here = getFocusByPane(callerPane);
|
|
311
|
+
if (here !== null && here.node_id !== nodeId)
|
|
312
|
+
closeFocusRow(here.focus_id);
|
|
313
|
+
// Clear the stale tag before respawn so it never names the wrong node during
|
|
314
|
+
// the gap before the fresh attach re-tags on connect.
|
|
315
|
+
try {
|
|
316
|
+
setPaneOption(callerPane, '@crtr_node', '');
|
|
317
|
+
}
|
|
318
|
+
catch { /* best-effort */ }
|
|
319
|
+
const window = windowOfPane(callerPane);
|
|
320
|
+
// Continuity: the fresh viewer re-enters the inline roster on the node just
|
|
321
|
+
// swapped to (self), so the user keeps scrolling from where they were rather
|
|
322
|
+
// than being dropped back into the text box.
|
|
323
|
+
const ok = respawnPaneSync({ pane: callerPane, cwd: meta.cwd, env: { ...viewerSplitEnv(), CRTR_ATTACH_ROSTER: '1' }, command: `crtr surface attach to ${nodeId}` });
|
|
324
|
+
if (ok) {
|
|
325
|
+
if (window !== null)
|
|
326
|
+
renameWindow(window, fullName(meta));
|
|
327
|
+
registerViewerFocus(nodeId, callerPane, callerSession, window);
|
|
328
|
+
}
|
|
329
|
+
return { focused: ok, session: callerSession, inPlace: true, revived };
|
|
330
|
+
}
|
|
314
331
|
// (b) --new-pane → always a fresh viewer beside the caller. Drop any prior
|
|
315
332
|
// viewer this node holds elsewhere (UNIQUE(node_id): one viewer per node).
|
|
316
333
|
if (opts.newPane === true) {
|
|
@@ -7,7 +7,13 @@ export declare function compactSessionSearchText(text: string): string;
|
|
|
7
7
|
export declare class SessionListCache {
|
|
8
8
|
private readonly cacheFile;
|
|
9
9
|
private cache;
|
|
10
|
-
|
|
10
|
+
/** In-flight cwd scans keyed by normalized session dir, so an immediate
|
|
11
|
+
* /resume that races the background prewarm (or a second identical open)
|
|
12
|
+
* shares the one running jsonlFiles/stat/build pass instead of queueing a
|
|
13
|
+
* redundant one behind it. */
|
|
14
|
+
private inflightDir;
|
|
15
|
+
private loadPromise;
|
|
16
|
+
private buildChain;
|
|
11
17
|
private persisting;
|
|
12
18
|
private persistAgain;
|
|
13
19
|
private persistTimer;
|
|
@@ -18,12 +24,26 @@ export declare class SessionListCache {
|
|
|
18
24
|
* broker loop — pre-warming beats worker threads here. Errors are swallowed
|
|
19
25
|
* by the same paths that guard a normal open. */
|
|
20
26
|
prewarm(sessionDir: string): void;
|
|
21
|
-
/** Sessions for one cwd's session dir (the picker's default `cwd` scope).
|
|
27
|
+
/** Sessions for one cwd's session dir (the picker's default `cwd` scope).
|
|
28
|
+
* Single-flight: an identical concurrent cwd open (prewarm ⇄ immediate
|
|
29
|
+
* /resume) shares the in-flight promise rather than repeating the scan.
|
|
30
|
+
* Incompatible cwd/all builds still serialize through the build chain. */
|
|
22
31
|
listDir(sessionDir: string): Promise<WireSessionInfo[]>;
|
|
32
|
+
private buildDir;
|
|
23
33
|
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
24
34
|
listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
|
|
35
|
+
/** Serialize builds through a single chain: they mutate the shared cache map
|
|
36
|
+
* and, cold, scan the same large corpus. Identical cwd opens are already
|
|
37
|
+
* collapsed upstream by `listDir`'s in-flight sharing; chaining serializes
|
|
38
|
+
* the remaining incompatible builds (a cwd scan vs an `all` scan) so they
|
|
39
|
+
* never race map mutation or launch overlapping cold scans. */
|
|
25
40
|
private build;
|
|
41
|
+
private buildLocked;
|
|
42
|
+
/** Load the persisted cache exactly once, single-flight. Returns a shared
|
|
43
|
+
* promise so a build that starts while the read is still in flight awaits the
|
|
44
|
+
* same load rather than proceeding against a not-yet-populated map. */
|
|
26
45
|
private ensureLoaded;
|
|
46
|
+
private loadFromDisk;
|
|
27
47
|
/** Coalesce persists onto a single trailing timer so the tens-of-MB
|
|
28
48
|
* `JSON.stringify` never runs on the reply path and rapid opens write once. */
|
|
29
49
|
private schedulePersist;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { createReadStream } from 'node:fs';
|
|
20
20
|
import { readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { createInterface } from 'node:readline';
|
|
22
|
-
import { dirname, join } from 'node:path';
|
|
22
|
+
import { dirname, join, normalize } from 'node:path';
|
|
23
23
|
/** Search-text bound: `allMessagesText` is searched on every query edit; pi's
|
|
24
24
|
* unbounded value retains gigabytes in the viewer and turns key-repeat into GC
|
|
25
25
|
* jitter. The opening and latest text keep search useful. */
|
|
@@ -41,18 +41,41 @@ const CACHE_SCHEMA_VERSION = 1;
|
|
|
41
41
|
* are coalesced onto this trailing timer so rapid opens write at most once per
|
|
42
42
|
* window, off the hot path. */
|
|
43
43
|
const PERSIST_DEBOUNCE_MS = 2000;
|
|
44
|
+
function isFiniteNonNeg(v) {
|
|
45
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
46
|
+
}
|
|
47
|
+
/** An optional field is valid iff absent/undefined or a string. */
|
|
48
|
+
function isOptionalString(v) {
|
|
49
|
+
return v === undefined || typeof v === 'string';
|
|
50
|
+
}
|
|
51
|
+
/** Validate the full persisted `WireSessionInfo` shape. A same-version file whose
|
|
52
|
+
* entry drifts from the current wire schema (any required field missing or
|
|
53
|
+
* mistyped, any optional field the wrong type, non-finite/negative metadata)
|
|
54
|
+
* must discard the whole cache and rebuild rather than feed a malformed entry to
|
|
55
|
+
* the viewer or the sort. */
|
|
56
|
+
function isValidWireInfo(v) {
|
|
57
|
+
if (typeof v !== 'object' || v === null)
|
|
58
|
+
return false;
|
|
59
|
+
const i = v;
|
|
60
|
+
return (typeof i['path'] === 'string' &&
|
|
61
|
+
typeof i['id'] === 'string' &&
|
|
62
|
+
typeof i['cwd'] === 'string' &&
|
|
63
|
+
typeof i['created'] === 'string' &&
|
|
64
|
+
typeof i['modified'] === 'string' &&
|
|
65
|
+
isFiniteNonNeg(i['messageCount']) &&
|
|
66
|
+
typeof i['firstMessage'] === 'string' &&
|
|
67
|
+
typeof i['allMessagesText'] === 'string' &&
|
|
68
|
+
isOptionalString(i['name']) &&
|
|
69
|
+
isOptionalString(i['parentSessionPath']));
|
|
70
|
+
}
|
|
44
71
|
/** Shape-check a loaded entry — a well-formed-JSON file with the right version
|
|
45
|
-
* but a stale entry shape
|
|
46
|
-
*
|
|
72
|
+
* but a stale entry shape would otherwise feed a malformed `WireSessionInfo` to
|
|
73
|
+
* the viewer/sort. Any failure discards the whole file (rebuild). */
|
|
47
74
|
function isValidEntry(e) {
|
|
48
75
|
if (typeof e !== 'object' || e === null)
|
|
49
76
|
return false;
|
|
50
77
|
const c = e;
|
|
51
|
-
return (
|
|
52
|
-
typeof c['size'] === 'number' &&
|
|
53
|
-
typeof c['info'] === 'object' &&
|
|
54
|
-
c['info'] !== null &&
|
|
55
|
-
typeof c['info']['modified'] === 'string');
|
|
78
|
+
return isFiniteNonNeg(c['mtimeMs']) && isFiniteNonNeg(c['size']) && isValidWireInfo(c['info']);
|
|
56
79
|
}
|
|
57
80
|
// ---- faithful mirror of pi's private buildSessionInfo -----------------------
|
|
58
81
|
function parseLine(line) {
|
|
@@ -172,7 +195,13 @@ async function buildWireSessionInfo(filePath) {
|
|
|
172
195
|
export class SessionListCache {
|
|
173
196
|
cacheFile;
|
|
174
197
|
cache = new Map();
|
|
175
|
-
|
|
198
|
+
/** In-flight cwd scans keyed by normalized session dir, so an immediate
|
|
199
|
+
* /resume that races the background prewarm (or a second identical open)
|
|
200
|
+
* shares the one running jsonlFiles/stat/build pass instead of queueing a
|
|
201
|
+
* redundant one behind it. */
|
|
202
|
+
inflightDir = new Map();
|
|
203
|
+
loadPromise = null;
|
|
204
|
+
buildChain = Promise.resolve();
|
|
176
205
|
persisting = false;
|
|
177
206
|
persistAgain = false;
|
|
178
207
|
persistTimer = null;
|
|
@@ -187,9 +216,32 @@ export class SessionListCache {
|
|
|
187
216
|
prewarm(sessionDir) {
|
|
188
217
|
void this.listDir(sessionDir);
|
|
189
218
|
}
|
|
190
|
-
/** Sessions for one cwd's session dir (the picker's default `cwd` scope).
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
/** Sessions for one cwd's session dir (the picker's default `cwd` scope).
|
|
220
|
+
* Single-flight: an identical concurrent cwd open (prewarm ⇄ immediate
|
|
221
|
+
* /resume) shares the in-flight promise rather than repeating the scan.
|
|
222
|
+
* Incompatible cwd/all builds still serialize through the build chain. */
|
|
223
|
+
listDir(sessionDir) {
|
|
224
|
+
const key = normalize(sessionDir);
|
|
225
|
+
const existing = this.inflightDir.get(key);
|
|
226
|
+
if (existing)
|
|
227
|
+
return existing;
|
|
228
|
+
const op = this.buildDir(key);
|
|
229
|
+
this.inflightDir.set(key, op);
|
|
230
|
+
// Attach both settlement handlers so the slot is cleared on success OR
|
|
231
|
+
// rejection, a rejected scan can never poison the next open, and a
|
|
232
|
+
// discarded prewarm rejection never becomes unhandled.
|
|
233
|
+
const clear = () => {
|
|
234
|
+
if (this.inflightDir.get(key) === op)
|
|
235
|
+
this.inflightDir.delete(key);
|
|
236
|
+
};
|
|
237
|
+
op.then(clear, clear);
|
|
238
|
+
return op;
|
|
239
|
+
}
|
|
240
|
+
buildDir(sessionDir) {
|
|
241
|
+
// Prune only keys directly in this cwd's dir. A cwd-scope open must not evict
|
|
242
|
+
// other cwds' cached entries: they weren't rescanned, so their absence from
|
|
243
|
+
// `files` says nothing about whether they still exist on disk.
|
|
244
|
+
return jsonlFiles(sessionDir).then((files) => this.build(files, (key) => dirname(key) === sessionDir));
|
|
193
245
|
}
|
|
194
246
|
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
195
247
|
async listAll(sessionsRoot) {
|
|
@@ -205,19 +257,30 @@ export class SessionListCache {
|
|
|
205
257
|
const files = [];
|
|
206
258
|
for (const dir of dirEntries)
|
|
207
259
|
files.push(...(await jsonlFiles(dir)));
|
|
208
|
-
|
|
260
|
+
// Prune every cached key that is a direct grandchild of the sessions root
|
|
261
|
+
// (`<root>/<cwd-dir>/<file>.jsonl`). Using the path boundary rather than the
|
|
262
|
+
// set of dirs that still exist means a wholly-deleted cwd dir — absent from
|
|
263
|
+
// this scan — also has its stale entries evicted, so the persisted file never
|
|
264
|
+
// accumulates bloat from removed cwds.
|
|
265
|
+
return this.build(files, (key) => dirname(dirname(key)) === sessionsRoot);
|
|
266
|
+
}
|
|
267
|
+
/** Serialize builds through a single chain: they mutate the shared cache map
|
|
268
|
+
* and, cold, scan the same large corpus. Identical cwd opens are already
|
|
269
|
+
* collapsed upstream by `listDir`'s in-flight sharing; chaining serializes
|
|
270
|
+
* the remaining incompatible builds (a cwd scan vs an `all` scan) so they
|
|
271
|
+
* never race map mutation or launch overlapping cold scans. */
|
|
272
|
+
build(files, shouldPrune) {
|
|
273
|
+
const run = this.buildChain.then(() => this.buildLocked(files, shouldPrune));
|
|
274
|
+
// Keep the chain alive past a rejected build without leaking the rejection.
|
|
275
|
+
this.buildChain = run.then(() => { }, () => { });
|
|
276
|
+
return run;
|
|
209
277
|
}
|
|
210
|
-
async
|
|
278
|
+
async buildLocked(files, shouldPrune) {
|
|
211
279
|
await this.ensureLoaded();
|
|
212
280
|
const present = new Set(files);
|
|
213
281
|
let changed = false;
|
|
214
|
-
// Prune ONLY keys under the dirs we actually scanned. A cwd-scope open must
|
|
215
|
-
// not evict other cwds' cached entries: they weren't rescanned, so their
|
|
216
|
-
// absence from `files` says nothing about whether they still exist on disk.
|
|
217
|
-
// (Pruning the whole map here would self-destruct the global/`all` cache on
|
|
218
|
-
// every cwd open.)
|
|
219
282
|
for (const key of this.cache.keys()) {
|
|
220
|
-
if (!present.has(key) &&
|
|
283
|
+
if (!present.has(key) && shouldPrune(key)) {
|
|
221
284
|
this.cache.delete(key);
|
|
222
285
|
changed = true;
|
|
223
286
|
}
|
|
@@ -262,10 +325,15 @@ export class SessionListCache {
|
|
|
262
325
|
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
263
326
|
return result;
|
|
264
327
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
328
|
+
/** Load the persisted cache exactly once, single-flight. Returns a shared
|
|
329
|
+
* promise so a build that starts while the read is still in flight awaits the
|
|
330
|
+
* same load rather than proceeding against a not-yet-populated map. */
|
|
331
|
+
ensureLoaded() {
|
|
332
|
+
if (!this.loadPromise)
|
|
333
|
+
this.loadPromise = this.loadFromDisk();
|
|
334
|
+
return this.loadPromise;
|
|
335
|
+
}
|
|
336
|
+
async loadFromDisk() {
|
|
269
337
|
try {
|
|
270
338
|
const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
|
|
271
339
|
// Version mismatch or shape drift → discard and rebuild. No lenient
|
|
@@ -97,7 +97,8 @@ export async function bootRoot(opts) {
|
|
|
97
97
|
// mandate (bare `crtr` has none — writeGoal no-ops on empty).
|
|
98
98
|
if (opts.prompt !== undefined)
|
|
99
99
|
writeGoal(meta.node_id, opts.prompt);
|
|
100
|
-
// Reconcile the crouter-owned tmux bindings on this server
|
|
100
|
+
// Reconcile the crouter-owned tmux bindings on this server, including the
|
|
101
|
+
// inbox-toggle key (crtr.tmux.inbox.toggle) folded into the same manifest sweep.
|
|
101
102
|
try {
|
|
102
103
|
installTmuxBindings();
|
|
103
104
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { installTmuxBindings, sendKeysEnter, displayMessage, paneCurrentPath, type TmuxInstalledPair, type TmuxInstallDiagnostic, type TmuxInstallResult, } from './tmux.js';
|
|
1
|
+
export { installTmuxBindings, legacyInboxKeyFile, sendKeysEnter, displayMessage, paneCurrentPath, type TmuxInstalledPair, type TmuxInstallDiagnostic, type TmuxInstallResult, } from './tmux.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
|
|
2
2
|
// The ONLY non-placement module allowed to import the tmux driver, per the
|
|
3
3
|
// §5.1 lint. Re-exports the server chrome callers need.
|
|
4
|
-
export { installTmuxBindings, sendKeysEnter, displayMessage, paneCurrentPath, } from './tmux.js';
|
|
4
|
+
export { installTmuxBindings, legacyInboxKeyFile, sendKeysEnter, displayMessage, paneCurrentPath, } from './tmux.js';
|
|
@@ -218,5 +218,6 @@ export interface TmuxInstallResult {
|
|
|
218
218
|
readonly installed: readonly TmuxInstalledPair[];
|
|
219
219
|
readonly diagnostics: readonly TmuxInstallDiagnostic[];
|
|
220
220
|
}
|
|
221
|
+
export declare function legacyInboxKeyFile(): string;
|
|
221
222
|
/** Reconcile the tmux server with one immutable crouter binding snapshot. */
|
|
222
223
|
export declare function installTmuxBindings(bindings?: BindingResolution<BindingId>): TmuxInstallResult;
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
// root) or switch-client + select-window (across roots). done/dead nodes close
|
|
9
9
|
// their window; reviving opens a fresh one.
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
11
14
|
import { resolveUserKeybindings, } from '../keybindings/index.js';
|
|
12
15
|
import { bundledPiCommandForShell } from './pi-cli.js';
|
|
13
16
|
import { surfaceTmuxStyleArgs } from './surface-bg.js';
|
|
17
|
+
import { inboxToggleTmuxCommand } from '@crouton-kit/humanloop';
|
|
14
18
|
// ---------------------------------------------------------------------------
|
|
15
19
|
// Shell quoting + tmux invocation
|
|
16
20
|
// ---------------------------------------------------------------------------
|
|
@@ -428,6 +432,7 @@ const MENU_ACTIONS = {
|
|
|
428
432
|
'crtr.tmux.menu.issue.bug': undefined,
|
|
429
433
|
'crtr.tmux.node.next': undefined,
|
|
430
434
|
'crtr.tmux.node.previous': undefined,
|
|
435
|
+
'crtr.tmux.inbox.toggle': undefined,
|
|
431
436
|
'crtr.tmux.view.next': undefined,
|
|
432
437
|
'crtr.tmux.view.previous': undefined,
|
|
433
438
|
};
|
|
@@ -546,12 +551,28 @@ function readManifest() {
|
|
|
546
551
|
return null;
|
|
547
552
|
}
|
|
548
553
|
}
|
|
554
|
+
// Crouter's own pre-manifest root bindings. When a machine had these installed
|
|
555
|
+
// before the ownership manifest existed, the first reconciliation adopts them
|
|
556
|
+
// into the manifest (unbinding the stale copy so it reinstalls cleanly) instead
|
|
557
|
+
// of reporting them foreign. Gated on a fresh manifest — once one exists these
|
|
558
|
+
// keys are already crouter-owned through it.
|
|
549
559
|
const LEGACY_ROOT_BINDINGS = [
|
|
550
560
|
{ key: 'M-c', markers: ['display-menu', 'promote to orchestrator', 'crtr canvas chord', 'new issue / todo'] },
|
|
551
561
|
{ key: 'M-]', markers: ['run-shell', 'crtr node cycle --dir next', "--pane '#{pane_id}'"] },
|
|
552
562
|
{ key: 'M-[', markers: ['run-shell', 'crtr node cycle --dir prev', "--pane '#{pane_id}'"] },
|
|
553
563
|
{ key: 'M-v', markers: ['switch-client', '-T crtr-view'] },
|
|
554
564
|
];
|
|
565
|
+
// The inbox toggle is a crouter-owned root binding, resolved from keybindings.json
|
|
566
|
+
// and installed through the manifest sweep like every sibling. Root bindings
|
|
567
|
+
// matching these markers that are NOT tracked in the manifest are hl-owned
|
|
568
|
+
// strays; reconciliation removes them (and the state file naming their key) so
|
|
569
|
+
// the manifest stays the sole owner of the inbox command.
|
|
570
|
+
const LEGACY_INBOX_MARKERS = ['run-shell', 'hl inbox toggle'];
|
|
571
|
+
const LEGACY_INBOX_DEFAULT_KEY = 'M-i';
|
|
572
|
+
export function legacyInboxKeyFile() {
|
|
573
|
+
const stateHome = process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
|
|
574
|
+
return join(stateHome, 'humanloop', 'inbox-key');
|
|
575
|
+
}
|
|
555
576
|
function adoptLegacyBindings() {
|
|
556
577
|
for (const legacy of LEGACY_ROOT_BINDINGS) {
|
|
557
578
|
const existing = bindingAt(ROOT_TABLE, legacy.key);
|
|
@@ -560,10 +581,42 @@ function adoptLegacyBindings() {
|
|
|
560
581
|
}
|
|
561
582
|
}
|
|
562
583
|
}
|
|
584
|
+
// Enforce the manifest's sole ownership of the inbox command: any hl-owned
|
|
585
|
+
// inbox binding not tracked in the manifest is unbound, and the humanloop
|
|
586
|
+
// state file is discarded. Runs on every reconciliation regardless of whether
|
|
587
|
+
// a manifest already exists. Checks the default key plus the key the state
|
|
588
|
+
// file records (the file's value only locates a stray binding — it is never
|
|
589
|
+
// imported into crouter config). A key tracked in the manifest is crouter's
|
|
590
|
+
// own and left alone; a foreign non-hl binding never matches the markers and
|
|
591
|
+
// is preserved. Idempotent: with no file and no untracked hl binding, a no-op.
|
|
592
|
+
function removeLegacyInboxBindings(manifestPairs) {
|
|
593
|
+
const owned = new Set(manifestPairs.filter((pair) => pair.table === ROOT_TABLE).map((pair) => pair.key));
|
|
594
|
+
const keys = new Set([LEGACY_INBOX_DEFAULT_KEY]);
|
|
595
|
+
const file = legacyInboxKeyFile();
|
|
596
|
+
try {
|
|
597
|
+
const configured = readFileSync(file, 'utf8').trim();
|
|
598
|
+
if (configured !== '')
|
|
599
|
+
keys.add(configured);
|
|
600
|
+
}
|
|
601
|
+
catch { /* no state file: only the default key can hold a stale binding */ }
|
|
602
|
+
for (const key of keys) {
|
|
603
|
+
if (owned.has(key))
|
|
604
|
+
continue;
|
|
605
|
+
const existing = bindingAt(ROOT_TABLE, key);
|
|
606
|
+
if (existing !== null && LEGACY_INBOX_MARKERS.every((marker) => existing.includes(marker))) {
|
|
607
|
+
tmux(['unbind-key', '-T', ROOT_TABLE, key]);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
rmSync(file, { force: true });
|
|
612
|
+
}
|
|
613
|
+
catch { /* best-effort */ }
|
|
614
|
+
}
|
|
563
615
|
function actionCommand(id) {
|
|
564
616
|
switch (id) {
|
|
565
617
|
case 'crtr.tmux.node.next': return ['run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
|
|
566
618
|
case 'crtr.tmux.node.previous': return ['run-shell', `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
|
|
619
|
+
case 'crtr.tmux.inbox.toggle': return inboxToggleTmuxCommand();
|
|
567
620
|
case 'crtr.tmux.view.next': return ['run-shell', `crtr surface view cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
|
|
568
621
|
case 'crtr.tmux.view.previous': return ['run-shell', `crtr surface view cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
|
|
569
622
|
default: throw new Error(`no tmux command for ${id}`);
|
|
@@ -580,6 +633,7 @@ export function installTmuxBindings(bindings = resolveUserKeybindings()) {
|
|
|
580
633
|
}
|
|
581
634
|
if (!manifest.existed)
|
|
582
635
|
adoptLegacyBindings();
|
|
636
|
+
removeLegacyInboxBindings(manifest.pairs);
|
|
583
637
|
for (const pair of manifest.pairs) {
|
|
584
638
|
const result = tmux(['unbind-key', '-T', pair.table, pair.key]);
|
|
585
639
|
if (!result.ok && bindingAt(pair.table, pair.key) !== null) {
|
|
@@ -606,7 +660,7 @@ export function installTmuxBindings(bindings = resolveUserKeybindings()) {
|
|
|
606
660
|
for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
|
|
607
661
|
install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
|
|
608
662
|
}
|
|
609
|
-
for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous']) {
|
|
663
|
+
for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous', 'crtr.tmux.inbox.toggle']) {
|
|
610
664
|
for (const gesture of bindings.gestures(id))
|
|
611
665
|
install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
|
|
612
666
|
}
|