@crouton-kit/crouter 0.3.70 → 0.3.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -1,640 +0,0 @@
1
- // canvas-nav.ts — pi extension for pi-native canvas agent nodes.
2
- //
3
- // A BASE/GRAPH state machine drawn as chrome around the editor. The editor is
4
- // "you" (this node); the chrome shows your place in the canvas graph.
5
- //
6
- // BASE (default, passive) — a vertical stack: your manager above the editor,
7
- // your live reports below it. Captures NO keys; typing is never touched.
8
- //
9
- // GRAPH (modal, opt-in) — a NERDTree-style tree of your local graph (ancestry
10
- // root → you → your subtree, with peers) drawn into one tall widget.
11
- // While in GRAPH the extension consumes EVERY key and interprets it:
12
- // j/k move · h/l fold · g/G top/bottom · ↵ focus · m focus manager ·
13
- // e expand→tmux · x kill (y/n confirm) · esc back to BASE
14
- // plus any user-defined graphBinds (additive; built-ins are reserved).
15
- //
16
- // Enter/leave GRAPH with the `/graph` slash command, the `prefixKey` shortcut
17
- // (default alt+g, configurable), or the tmux alt+c menu's `g` item. Inside tmux
18
- // alt+c is a tmux display-menu (not a pi key), so prefix chords (m/e/1-9/custom)
19
- // are tmux menu items that route through `crtr canvas chord`.
20
- //
21
- // Selection / attachment signals:
22
- // CURSOR (selected) = reverse-video bar (ESC[7m), full width — an attribute,
23
- // not a colour, so it reads under NO_COLOR.
24
- // ATTACHED (watched) = a coloured background bar — a human is currently
25
- // viewing the node: a `focuses` viewport points at it
26
- // (tmux host) or ≥1 helloed viewer is connected to its
27
- // broker (job/attach.json). Running is a separate axis,
28
- // signaled by the overlay-aware glyph (●/○/⟳/⚠) alone on
29
- // its host — which may be an unwatched backstage pane or
30
- // a paneless broker).
31
- // SELF = bold name — a quiet "you are here" marker.
32
- //
33
- // Folding is auto by default: a branch stays COLLAPSED unless its subtree holds
34
- // a running ('active') agent or self. h/l override that per-node and persist.
35
- // A ▸ caret marks an expandable (collapsed-with-kids) row — but NOT the cursor
36
- // row, whose reverse-video bar already sets it apart.
37
- //
38
- // ⚑K pending-asks is PER-NODE, inline on each waiting node's own row (manager,
39
- // reports, tree rows; self shows a trailing ⚑ line in BASE). ⤳M direct-children
40
- // badge shows only on orchestrator rows.
41
- //
42
- // INERT when CRTR_NODE_ID is absent (a plain pi session or legacy job agent).
43
- //
44
- // Plain TS-with-types — no imports from @earendil-works/* so this compiles
45
- // inside crouter's own tsc build without a dep on the pi packages.
46
- import { execFile } from 'node:child_process';
47
- import { fullName } from '../core/canvas/index.js';
48
- import { beginFrame, cNode, managerOf, liveReports, sortedChildIds, subtreeIds, climbRoot, computeSubtreeActivity, buildGraphModel, renderGraphRow, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, childBadge, liveBelowBadge, askBadge, activityCell, focusedNodeIds, graphWidgetBudget, fetchAsksMap, shortId, DIM, RESET, BOLD, YELLOW, GRAPH_HINT, } from '../core/canvas/nav-model.js';
49
- import { readConfig } from '../core/config.js';
50
- import { onNavRerender } from './widget-order-bus.js';
51
- // ---------------------------------------------------------------------------
52
- // Module-level state — persists across /reload so guards don't stack and fold
53
- // state / current view survive a hot-swap.
54
- // ---------------------------------------------------------------------------
55
- /** The one live background timer. Cleared and replaced on every re-registration. */
56
- let liveTimer;
57
- /** The one live onTerminalInput unsubscribe. Cleared/replaced on /reload so
58
- * exactly one key tap exists (mirrors the liveTimer double-guard). */
59
- let liveUnsub;
60
- let view = 'base';
61
- /** Manual fold OVERRIDES in GRAPH, keyed by id (so a topology change can't
62
- * corrupt them; stale ids are ignored). They override the default policy —
63
- * collapsed UNLESS the subtree holds a running ('active') agent or self (see
64
- * computeDefaultExpanded). `h` collapses → userCollapsed; `l` expands →
65
- * userExpanded. Both survive renders AND BASE↔GRAPH toggles. */
66
- const userCollapsed = new Set();
67
- const userExpanded = new Set();
68
- /** A live view of the manual fold overrides for the pure nav-model layer; the
69
- * Sets above are mutated in place, so this reference stays current across
70
- * renders and BASE↔GRAPH toggles. */
71
- const folds = { userExpanded, userCollapsed };
72
- /** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
73
- let cursorId;
74
- /** GRAPH viewport scroll offset (row index of the top visible row). */
75
- let scrollTop = 0;
76
- /** Transient y/n confirm gate inside GRAPH (kill / confirm-binds). */
77
- let pendingConfirm;
78
- /** Per-node pending-ask counts, refreshed by the timer; renders read this. */
79
- let asksMap = {};
80
- // ---------------------------------------------------------------------------
81
- // Tuning constants
82
- // ---------------------------------------------------------------------------
83
- const ASK_POLL_MS = 5_000;
84
- const RENDER_DEBOUNCE_MS = 150;
85
- // ---------------------------------------------------------------------------
86
- // Key decoding — recognizers tolerant of legacy, kitty/CSI-u and
87
- // modifyOtherKeys encodings (pi enables the kitty / modifyOtherKeys protocols,
88
- // and tmux with `extended-keys csi-u` delivers modified keys as CSI-u, not the
89
- // legacy ESC-prefix form). Mirrors pi-tui's parseKey, kept dependency-free.
90
- // ---------------------------------------------------------------------------
91
- const CSI_U_RE = /^\x1b\[(\d+)(?::\d*)?(?::\d+)?(?:;(\d+))?(?::\d+)?u$/;
92
- const MOK_RE = /^\x1b\[27;(\d+);(\d+)~$/;
93
- /** True when a decoded CSI-u modifier (already `mod-1`) is Alt and nothing else
94
- * besides lock keys. */
95
- function isAltOnly(mod) {
96
- return (mod & 2) !== 0 && (mod & (1 | 4 | 8 | 16 | 32)) === 0;
97
- }
98
- /** Recognize Alt+<letter> across legacy, kitty/CSI-u and modifyOtherKeys. */
99
- function isAltKey(data, letter) {
100
- const code = letter.charCodeAt(0);
101
- if (data === `\x1b${letter}`)
102
- return true;
103
- const u = CSI_U_RE.exec(data);
104
- if (u !== null) {
105
- const mod = u[2] !== undefined ? parseInt(u[2], 10) - 1 : 0;
106
- return parseInt(u[1], 10) === code && isAltOnly(mod);
107
- }
108
- const m = MOK_RE.exec(data);
109
- if (m !== null) {
110
- return parseInt(m[2], 10) === code && isAltOnly(parseInt(m[1], 10) - 1);
111
- }
112
- return false;
113
- }
114
- /** Recognize a PLAIN letter (no Alt) across the bare byte and kitty CSI-u
115
- * single-char form. Uppercase letters also match lowercase-code + Shift. */
116
- function isPlain(data, ch) {
117
- if (data === ch)
118
- return true;
119
- const lower = ch.toLowerCase();
120
- const needShift = ch !== lower;
121
- const code = lower.charCodeAt(0);
122
- const m = /^\x1b\[(\d+)(?:;(\d+))?u$/.exec(data);
123
- if (m !== null) {
124
- if (parseInt(m[1], 10) !== code)
125
- return false;
126
- const mod = m[2] !== undefined ? parseInt(m[2], 10) - 1 : 0;
127
- return needShift ? (mod & 1) !== 0 && (mod & ~1) === 0 : mod === 0;
128
- }
129
- return false;
130
- }
131
- /** Plain Enter across legacy and kitty (ESC [ 13 u). */
132
- function isEnterKey(data) {
133
- return data === '\r' || data === '\n' || /^\x1b\[13(?:;1)?u$/.test(data);
134
- }
135
- /** Plain Escape across legacy and kitty (ESC [ 27 u). */
136
- function isEscKey(data) {
137
- return data === '\x1b' || /^\x1b\[27(?:;1)?u$/.test(data);
138
- }
139
- /** Extract the bare letter of an `alt+<letter>` prefix spec (else undefined). */
140
- function altLetterOf(spec) {
141
- const m = /^alt\+([a-zA-Z])$/.exec(spec ?? '');
142
- return m ? m[1].toLowerCase() : undefined;
143
- }
144
- // Built-in GRAPH keys are reserved; graphBinds may only ADD other keys.
145
- const RESERVED_GRAPH_KEYS = new Set(['j', 'k', 'h', 'l', 'g', 'G', 'm', 'e', 'x', 'y', 'n']);
146
- /** Split a `run` string argv-style and interpolate {id|self|name|manager|lane|
147
- * subtree}. A bare `{subtree}` token expands to several argv elements; every
148
- * other placeholder substitutes in place (kept as one element so a multi-word
149
- * name survives as a single argument under execFile). */
150
- function interpolateArgv(run, vars) {
151
- const out = [];
152
- for (const tok of run.split(/\s+/).filter((t) => t !== '')) {
153
- if (tok === '{subtree}') {
154
- for (const part of (vars['subtree'] ?? '').split(/\s+/).filter((p) => p !== ''))
155
- out.push(part);
156
- continue;
157
- }
158
- out.push(tok.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''));
159
- }
160
- return out;
161
- }
162
- // ---------------------------------------------------------------------------
163
- // Extension
164
- // ---------------------------------------------------------------------------
165
- /**
166
- * Register the canvas nav chrome on `pi`.
167
- *
168
- * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
169
- * inert in a non-canvas pi session.
170
- */
171
- export function registerCanvasNav(pi) {
172
- const nodeId = process.env['CRTR_NODE_ID'];
173
- if (nodeId === undefined || nodeId.trim() === '')
174
- return; // not a canvas node
175
- // Captured from session_start; used in every subsequent render.
176
- let ui;
177
- let renderScheduled = false;
178
- // Run mode captured at session_start. The nav chrome + ask-poll timer are
179
- // interactive-only; a headless ('print') broker leaves this non-'tui' so the
180
- // timer no-ops (no per-tick `crtr` shell-out) and no chrome is rendered.
181
- let liveMode;
182
- // Cache config once (binds rarely change within a session; readConfig is sync
183
- // and never throws). prefixKey drives the non-tmux GRAPH toggle shortcut.
184
- let navConfig;
185
- try {
186
- navConfig = readConfig('user').canvasNav;
187
- }
188
- catch {
189
- navConfig = { prefixBinds: {}, graphBinds: {} };
190
- }
191
- const prefixAltLetter = altLetterOf(navConfig.prefixKey);
192
- // -------------------------------------------------------------------------
193
- // Renderers
194
- // -------------------------------------------------------------------------
195
- /** BASE: manager line above the editor, reports stack below it. */
196
- const renderBase = () => {
197
- if (ui === undefined)
198
- return;
199
- // One subtree-activity pass (rooted at the ancestry root) feeds the ⇣N
200
- // live-work-below badge on both the manager line and every report row.
201
- const activity = computeSubtreeActivity(climbRoot(nodeId), nodeId);
202
- const mgr = managerOf(nodeId);
203
- if (mgr === undefined) {
204
- // Root node: no manager → drop the widget rather than show "↑ (root)" chrome.
205
- ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
206
- }
207
- else {
208
- const mn = cNode(mgr);
209
- const name = navLabel(mn, mgr);
210
- const mgrLine = truncate(`↑ ${name} ${nodeGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${cycleBadge(mn)}${childBadge(mn)}${liveBelowBadge(mn, activity.activeBelow)}${askBadge(mgr, asksMap)}${activityCell(mgr, mn)}`);
211
- ui.setWidget('crtr-managers', [mgrLine], { placement: 'aboveEditor' });
212
- }
213
- const reports = liveReports(nodeId);
214
- const lines = [];
215
- // Report rows only — no "↓ reports (N)" header (the label carries no signal).
216
- if (reports.length > 0) {
217
- const nameW = Math.min(20, Math.max(...reports.map((id) => navLabel(cNode(id), id).length)));
218
- for (const id of reports) {
219
- const n = cNode(id);
220
- const name = navLabel(n, id).padEnd(nameW);
221
- const kind = `${DIM}${(n?.kind ?? '').padEnd(6)}${RESET}`;
222
- const tokens = `${DIM}${tokensCell(id).padStart(5)}${RESET}`;
223
- lines.push(truncate(` ${nodeGlyph(n)} ${name} ${kind} ${tokens}${cycleBadge(n)}${childBadge(n)}${liveBelowBadge(n, activity.activeBelow)}${askBadge(id, asksMap)}${activityCell(id, n)}`));
224
- }
225
- }
226
- // Self's own pending asks (no self row in BASE) → a trailing inline line.
227
- const selfAsks = asksMap[nodeId] ?? 0;
228
- if (selfAsks > 0)
229
- lines.push(`${YELLOW}⚑${selfAsks}${RESET}`);
230
- // Nothing to show → drop the widget rather than render an empty bar.
231
- ui.setWidget('crtr-base', lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
232
- // Drop GRAPH chrome so nothing bleeds through.
233
- ui.setWidget('crtr-graph', undefined, { placement: 'belowEditor' });
234
- };
235
- /** GRAPH: the fold-aware tree + a one-line hint/footer, viewport-bounded. */
236
- const renderGraph = () => {
237
- if (ui === undefined)
238
- return;
239
- // One subtree-activity pass feeds BOTH the fold policy (which rows show) and
240
- // the ⇣N live-work-below badge — computed once here, never re-walked per row.
241
- const activity = computeSubtreeActivity(climbRoot(nodeId), nodeId);
242
- const rows = buildGraphModel(nodeId, folds, activity.expand);
243
- // Re-resolve the cursor id → row (it may have vanished under a fold or a
244
- // close); clamp to nearest visible row.
245
- let cursorIdx = rows.findIndex((r) => r.id === cursorId);
246
- if (cursorIdx < 0) {
247
- cursorIdx = rows.findIndex((r) => r.id === nodeId);
248
- if (cursorIdx < 0)
249
- cursorIdx = 0;
250
- }
251
- cursorId = rows[cursorIdx]?.id ?? nodeId;
252
- // Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
253
- // the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
254
- // rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
255
- // list rather than hitting pi's hard truncation. The passes settle the
256
- // mutual dependency between "how many rows fit" and "are indicators shown":
257
- // each ↑/↓ indicator steals a tree row, which can push the cursor out of
258
- // view, which moves the window, which changes whether an indicator shows.
259
- // This needs up to 3 passes to converge (an indicator appearing shrinks the
260
- // window, the smaller window re-homes scrollTop, that re-home can toggle the
261
- // *other* indicator). Bailing early (the old 2-pass cap) left the cursor one
262
- // row off-screen for a single keypress near the bottom — the arrow vanished
263
- // and only the NEXT press scrolled. 4 passes always settles to a stable,
264
- // cursor-visible window.
265
- const treeArea = Math.max(2, graphWidgetBudget() - 1);
266
- let viewportH = treeArea;
267
- for (let pass = 0; pass < 4; pass++) {
268
- if (cursorIdx < scrollTop)
269
- scrollTop = cursorIdx;
270
- if (cursorIdx >= scrollTop + viewportH)
271
- scrollTop = cursorIdx - viewportH + 1;
272
- scrollTop = Math.max(0, Math.min(scrollTop, Math.max(0, rows.length - viewportH)));
273
- const fit = treeArea - (scrollTop > 0 ? 1 : 0) - (scrollTop + viewportH < rows.length ? 1 : 0);
274
- if (fit === viewportH)
275
- break;
276
- viewportH = Math.max(1, fit);
277
- }
278
- const end = Math.min(rows.length, scrollTop + viewportH);
279
- const lines = [];
280
- const focused = focusedNodeIds(); // one sqlite read per render pass
281
- if (scrollTop > 0)
282
- lines.push(`${DIM} ↑ ${scrollTop} more${RESET}`);
283
- for (let i = scrollTop; i < end; i++)
284
- lines.push(renderGraphRow(rows[i], i === cursorIdx, focused, activity.activeBelow, asksMap));
285
- if (end < rows.length)
286
- lines.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
287
- const hint = pendingConfirm !== undefined
288
- ? `${YELLOW}${pendingConfirm.label} ${BOLD}y/n${RESET}`
289
- : GRAPH_HINT;
290
- lines.push(truncate(`${hint} ${DIM}${cursorIdx + 1}/${rows.length}${RESET}`));
291
- ui.setWidget('crtr-graph', lines, { placement: 'belowEditor' });
292
- // Drop BASE chrome.
293
- ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
294
- ui.setWidget('crtr-base', undefined, { placement: 'belowEditor' });
295
- };
296
- const render = () => {
297
- if (ui === undefined)
298
- return;
299
- // Fresh snapshot per render: drop last frame's memoized node/telemetry/edge
300
- // reads so this paint reflects current disk+db state, then read-once within it.
301
- beginFrame();
302
- try {
303
- if (view === 'graph')
304
- renderGraph();
305
- else
306
- renderBase();
307
- }
308
- catch {
309
- /* render is best-effort; never throw out of a handler */
310
- }
311
- };
312
- const scheduleRender = () => {
313
- if (renderScheduled)
314
- return;
315
- renderScheduled = true;
316
- setTimeout(() => {
317
- renderScheduled = false;
318
- render();
319
- }, RENDER_DEBOUNCE_MS);
320
- };
321
- // Let canvas-recap ask us to re-assert the manager line so its recap card
322
- // stays the topmost aboveEditor chrome (pi's widget store is insertion-
323
- // ordered; re-setting crtr-managers drops it below crtr-recap).
324
- onNavRerender(scheduleRender);
325
- // -------------------------------------------------------------------------
326
- // Actions (all shell out; the extension stays tmux/revive-free)
327
- // -------------------------------------------------------------------------
328
- const shellCrtr = (argv, onDone) => {
329
- try {
330
- execFile('crtr', argv, (err) => {
331
- if (err != null && ui?.notify != null) {
332
- try {
333
- ui.notify(`crtr ${argv[0]} failed`, 'error');
334
- }
335
- catch { /* best-effort */ }
336
- }
337
- if (onDone !== undefined) {
338
- try {
339
- onDone();
340
- }
341
- catch { /* best-effort */ }
342
- }
343
- });
344
- }
345
- catch {
346
- /* best-effort */
347
- }
348
- };
349
- const focusTarget = (id) => shellCrtr(['node', 'focus', id]);
350
- const enterGraph = () => {
351
- view = 'graph';
352
- pendingConfirm = undefined;
353
- scrollTop = 0;
354
- if (cursorId === undefined || cNode(cursorId) === null)
355
- cursorId = nodeId;
356
- render();
357
- };
358
- const exitGraph = () => {
359
- view = 'base';
360
- pendingConfirm = undefined;
361
- render();
362
- };
363
- const toggleGraph = () => {
364
- if (view === 'graph')
365
- exitGraph();
366
- else
367
- enterGraph();
368
- };
369
- /** Template vars for a graphBind, resolved against the CURSOR node. */
370
- const graphVars = (cur) => {
371
- const cn = cNode(cur);
372
- return {
373
- id: cur,
374
- self: nodeId,
375
- lane: cur,
376
- name: cn !== null ? fullName(cn) : cur,
377
- manager: managerOf(cur) ?? '',
378
- subtree: subtreeIds(cur).join(' '),
379
- };
380
- };
381
- // -------------------------------------------------------------------------
382
- // GRAPH modal key handler — consumes EVERY key while in GRAPH.
383
- // -------------------------------------------------------------------------
384
- const handleGraphKey = (data) => {
385
- // y/n confirm gate takes precedence over everything.
386
- if (pendingConfirm !== undefined) {
387
- if (isPlain(data, 'y')) {
388
- const act = pendingConfirm.action;
389
- pendingConfirm = undefined;
390
- act();
391
- render();
392
- return { consume: true };
393
- }
394
- pendingConfirm = undefined; // any other key cancels
395
- render();
396
- return { consume: true };
397
- }
398
- // Let the prefix shortcut (alt+g) through so pi's registerShortcut can
399
- // toggle us back to BASE; esc also exits, handled below.
400
- if (prefixAltLetter !== undefined && isAltKey(data, prefixAltLetter))
401
- return undefined;
402
- if (isEscKey(data)) {
403
- exitGraph();
404
- return { consume: true };
405
- }
406
- const rows = buildGraphModel(nodeId, folds);
407
- let idx = rows.findIndex((r) => r.id === cursorId);
408
- if (idx < 0)
409
- idx = Math.max(0, rows.findIndex((r) => r.id === nodeId));
410
- const cur = rows[idx];
411
- if (isPlain(data, 'j')) {
412
- idx = Math.min(rows.length - 1, idx + 1);
413
- cursorId = rows[idx]?.id ?? cursorId;
414
- render();
415
- return { consume: true };
416
- }
417
- if (isPlain(data, 'k')) {
418
- idx = Math.max(0, idx - 1);
419
- cursorId = rows[idx]?.id ?? cursorId;
420
- render();
421
- return { consume: true };
422
- }
423
- if (isPlain(data, 'g')) {
424
- cursorId = rows[0]?.id ?? cursorId;
425
- render();
426
- return { consume: true };
427
- }
428
- if (isPlain(data, 'G')) {
429
- cursorId = rows[rows.length - 1]?.id ?? cursorId;
430
- render();
431
- return { consume: true };
432
- }
433
- if (isPlain(data, 'h')) {
434
- if (cur !== undefined && cur.hasKids && !cur.collapsed) {
435
- userCollapsed.add(cur.id);
436
- userExpanded.delete(cur.id);
437
- }
438
- else {
439
- const p = managerOf(cursorId ?? nodeId);
440
- if (p !== undefined && rows.some((r) => r.id === p))
441
- cursorId = p;
442
- }
443
- render();
444
- return { consume: true };
445
- }
446
- if (isPlain(data, 'l')) {
447
- if (cur !== undefined && cur.collapsed && cur.hasKids) {
448
- userExpanded.add(cur.id);
449
- userCollapsed.delete(cur.id);
450
- }
451
- else if (cur !== undefined && cur.hasKids) {
452
- const c = sortedChildIds(cur.id)[0];
453
- if (c !== undefined)
454
- cursorId = c;
455
- }
456
- render();
457
- return { consume: true };
458
- }
459
- if (isEnterKey(data)) {
460
- if (cursorId !== undefined)
461
- focusTarget(cursorId);
462
- render();
463
- return { consume: true };
464
- }
465
- if (isPlain(data, 'm')) {
466
- const mgr = managerOf(nodeId);
467
- if (mgr !== undefined)
468
- focusTarget(mgr);
469
- render();
470
- return { consume: true };
471
- }
472
- if (isPlain(data, 'x')) {
473
- const target = cursorId ?? nodeId;
474
- const n = cNode(target);
475
- const nm = n !== null ? fullName(n) : shortId(target);
476
- pendingConfirm = { label: `kill ${nm}?`, action: () => shellCrtr(['node', 'lifecycle', 'close', '--node', target], render) };
477
- render();
478
- return { consume: true };
479
- }
480
- if (isPlain(data, 'e')) {
481
- // Expand → tmux: spread the cursor node's local subtree into a tiled tmux
482
- // window (`crtr surface tmux-spread`).
483
- shellCrtr(['surface', 'tmux-spread', cursorId ?? nodeId], render);
484
- render();
485
- return { consume: true };
486
- }
487
- // Custom graphBinds — additive only (built-in keys reserved).
488
- for (const [key, bind] of Object.entries(navConfig.graphBinds)) {
489
- if (key.length !== 1 || RESERVED_GRAPH_KEYS.has(key))
490
- continue;
491
- if (!isPlain(data, key))
492
- continue;
493
- const target = cursorId ?? nodeId;
494
- const argv = interpolateArgv(bind.run, graphVars(target));
495
- if (argv.length === 0)
496
- return { consume: true };
497
- if (bind.confirm === true) {
498
- const n = cNode(target);
499
- const nm = n !== null ? fullName(n) : shortId(target);
500
- pendingConfirm = { label: `${bind.desc ?? bind.run} ${nm}?`, action: () => shellCrtr(argv, render) };
501
- }
502
- else {
503
- shellCrtr(argv, render);
504
- }
505
- render();
506
- return { consume: true };
507
- }
508
- // Modal: swallow everything else so stray keys never reach the editor.
509
- return { consume: true };
510
- };
511
- // Pre-editor key tap. BASE passes EVERY key through (composing is never
512
- // disturbed); GRAPH is fully modal. One persistent tap (preserving the
513
- // /reload single-unsub guard); its body branches on `view`.
514
- const handleKey = (data) => {
515
- try {
516
- if (ui === undefined)
517
- return undefined;
518
- if (view === 'base')
519
- return undefined;
520
- return handleGraphKey(data);
521
- }
522
- catch {
523
- return undefined;
524
- }
525
- };
526
- // -------------------------------------------------------------------------
527
- // Slash command + shortcut to toggle GRAPH (registered once per load;
528
- // pi dedupes duplicate names on /reload).
529
- // -------------------------------------------------------------------------
530
- if (typeof pi.registerCommand === 'function') {
531
- pi.registerCommand('graph', {
532
- description: 'Toggle the canvas GRAPH view (NERDTree of your local graph)',
533
- handler: async (_args, ctx) => {
534
- if (ui === undefined)
535
- ui = ctx.ui;
536
- toggleGraph();
537
- },
538
- });
539
- }
540
- if (typeof pi.registerShortcut === 'function' && navConfig.prefixKey !== undefined && navConfig.prefixKey !== '') {
541
- try {
542
- pi.registerShortcut(navConfig.prefixKey, {
543
- description: 'Toggle the canvas GRAPH view',
544
- handler: async (ctx) => {
545
- if (ui === undefined)
546
- ui = ctx.ui;
547
- toggleGraph();
548
- },
549
- });
550
- }
551
- catch {
552
- /* shortcut spec rejected by pi — /graph + the alt+c menu still work */
553
- }
554
- }
555
- // -------------------------------------------------------------------------
556
- // Event handlers
557
- // -------------------------------------------------------------------------
558
- pi.on('session_start', (_event, ctx) => {
559
- liveMode = ctx.mode;
560
- // The nav chrome (widgets, key taps, the ask-poll timer) is interactive-only.
561
- // A headless (print-mode) broker loads this extension but renders no chrome
562
- // and must not poll. Under tmux ctx.mode is always 'tui' — byte-identical.
563
- if (ctx.mode !== 'tui')
564
- return;
565
- ui = ctx.ui;
566
- // Fresh session / hot-swap: start in BASE and clear any legacy or
567
- // inactive-view widgets so nothing stale bleeds through.
568
- view = 'base';
569
- pendingConfirm = undefined;
570
- for (const key of ['crtr-asks', 'crtr-siblings', 'crtr-reports', 'crtr-graph']) {
571
- try {
572
- ctx.ui.setWidget(key, undefined, { placement: 'belowEditor' });
573
- }
574
- catch { /* ignore */ }
575
- try {
576
- ctx.ui.setWidget(key, undefined, { placement: 'aboveEditor' });
577
- }
578
- catch { /* ignore */ }
579
- }
580
- // Register the modal key tap once. Double-guard against /reload stacking
581
- // (mirrors liveTimer): clear any previous tap before adding ours.
582
- if (liveUnsub !== undefined) {
583
- try {
584
- liveUnsub();
585
- }
586
- catch { /* ignore */ }
587
- liveUnsub = undefined;
588
- }
589
- try {
590
- if (typeof ctx.ui.onTerminalInput === 'function') {
591
- liveUnsub = ctx.ui.onTerminalInput(handleKey);
592
- }
593
- }
594
- catch {
595
- /* onTerminalInput unavailable — chrome stays display-only */
596
- }
597
- scheduleRender();
598
- });
599
- pi.on('turn_end', (_event, _ctx) => {
600
- scheduleRender();
601
- });
602
- // -------------------------------------------------------------------------
603
- // Background timer — per-node ask polling (one shell-out) + periodic refresh
604
- // -------------------------------------------------------------------------
605
- if (liveTimer !== undefined)
606
- clearInterval(liveTimer);
607
- const timer = setInterval(() => {
608
- // Inert in a headless ('print') broker — never shell out to `crtr` per tick.
609
- if (liveMode !== 'tui')
610
- return;
611
- try {
612
- const rootId = climbRoot(nodeId);
613
- const fresh = fetchAsksMap(rootId);
614
- // Repaint only when the map actually changed — avoids constant flicker.
615
- if (JSON.stringify(fresh) !== JSON.stringify(asksMap)) {
616
- asksMap = fresh;
617
- scheduleRender();
618
- }
619
- }
620
- catch {
621
- /* timer is best-effort */
622
- }
623
- }, ASK_POLL_MS);
624
- if (typeof timer.unref === 'function')
625
- timer.unref();
626
- liveTimer = timer;
627
- pi.on('session_shutdown', () => {
628
- clearInterval(timer);
629
- if (liveTimer === timer)
630
- liveTimer = undefined;
631
- if (liveUnsub !== undefined) {
632
- try {
633
- liveUnsub();
634
- }
635
- catch { /* ignore */ }
636
- liveUnsub = undefined;
637
- }
638
- });
639
- }
640
- export default registerCanvasNav;
@@ -1,6 +0,0 @@
1
- /** Called by canvas-nav (once per session_start) to expose its scheduleRender. */
2
- export declare function onNavRerender(cb: () => void): void;
3
- /** Called by canvas-recap after it sets/clears `crtr-recap` to nudge canvas-nav
4
- * to re-assert `crtr-managers` below it. No-op if nav has not registered (e.g.
5
- * nav inert, or recap-only test harness). */
6
- export declare function requestNavRerender(): void;
@@ -1,34 +0,0 @@
1
- // widget-order-bus.ts — a tiny in-process bus that lets canvas-recap keep its
2
- // recap widget pinned ABOVE canvas-nav's manager line.
3
- //
4
- // Why this exists: pi's interactive widget store (and the broker's relay) is
5
- // insertion-ordered, and re-setting an existing widget key moves it to the END
6
- // (bottom) — see interactive-mode.js `setExtensionWidget` (delete+set). So the
7
- // LAST extension to call setWidget for an aboveEditor key renders closest to the
8
- // editor. canvas-recap only paints during idle, when canvas-nav has not re-set
9
- // `crtr-managers` for a long time, so a naive recap setWidget lands BELOW the
10
- // manager line. The fix: after canvas-recap changes its widget, it asks
11
- // canvas-nav to re-assert `crtr-managers`, which re-inserts that key after
12
- // `crtr-recap` and so drops it below the recap — making the recap the topmost
13
- // chrome.
14
- //
15
- // Both extensions load in the SAME pi process, so this module is a shared
16
- // singleton via Node's module cache. No @earendil-works/* imports — compiles in
17
- // crouter's own tsc build.
18
- /** canvas-nav registers its re-render here on session_start; canvas-recap calls
19
- * requestNavRerender() after it shows/clears the recap so the manager line
20
- * re-inserts (and thus orders) below the recap widget. */
21
- let navRerender;
22
- /** Called by canvas-nav (once per session_start) to expose its scheduleRender. */
23
- export function onNavRerender(cb) {
24
- navRerender = cb;
25
- }
26
- /** Called by canvas-recap after it sets/clears `crtr-recap` to nudge canvas-nav
27
- * to re-assert `crtr-managers` below it. No-op if nav has not registered (e.g.
28
- * nav inert, or recap-only test harness). */
29
- export function requestNavRerender() {
30
- try {
31
- navRerender?.();
32
- }
33
- catch { /* best-effort */ }
34
- }