@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -15,7 +15,30 @@ import { RemoteCanvasSource } from '../remote-canvas-source.js';
15
15
  import { closeNode } from '../../runtime/close.js';
16
16
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
17
17
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
18
+ import { loadPins, savePins } from './pins.js';
18
19
  import { createCoalescedRebuilder } from './rebuild-coalescer.js';
20
+ import { matchesTerminalInput, resolveUserKeybindings, } from '../../keybindings/index.js';
21
+ const BROWSE_NAV_ACTIONS = [
22
+ 'crtr.browse.quit', 'crtr.browse.down', 'crtr.browse.up', 'crtr.browse.first', 'crtr.browse.last',
23
+ 'crtr.browse.expand-or-child', 'crtr.browse.collapse-or-parent', 'crtr.browse.toggle-fold',
24
+ 'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
25
+ 'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
26
+ 'crtr.browse.scope', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
27
+ 'crtr.browse.revive-now', 'crtr.browse.focus',
28
+ ];
29
+ const BROWSE_SEARCH_ACTIONS = [
30
+ 'crtr.browse.search.cancel', 'crtr.browse.search.commit', 'crtr.browse.search.delete',
31
+ 'crtr.browse.search.quit',
32
+ ];
33
+ function firstMatching(bindings, ids, raw) {
34
+ return ids.find((id) => matchesTerminalInput(bindings, id, raw)) ?? null;
35
+ }
36
+ export function resolveBrowseNavAction(bindings, raw) {
37
+ return firstMatching(bindings, BROWSE_NAV_ACTIONS, raw);
38
+ }
39
+ export function resolveBrowseSearchAction(bindings, raw) {
40
+ return firstMatching(bindings, BROWSE_SEARCH_ACTIONS, raw);
41
+ }
19
42
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
20
43
  // press restores the structural `tree` view, then relevance/recency, then back.
21
44
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
@@ -23,10 +46,10 @@ import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './re
23
46
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
24
47
  * render.ts headerHeight), the footer, and the preview panel when shown. Kept
25
48
  * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
26
- function viewportHeight(rowsTotal, search, previewOn) {
49
+ function viewportHeight(rowsTotal, inputLine, previewOn) {
27
50
  const rows = Math.max(8, rowsTotal);
28
51
  const previewH = previewOn ? PREVIEW_HEIGHT : 0;
29
- return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
52
+ return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
30
53
  }
31
54
  /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
32
55
  * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
@@ -44,6 +67,7 @@ export function wireEventStreamRebuild(eventStream, rebuild) {
44
67
  export async function runBrowse(opts = {}) {
45
68
  const source = opts.source ?? localCanvasSource;
46
69
  const isRemote = source instanceof RemoteCanvasSource;
70
+ const bindings = resolveUserKeybindings();
47
71
  // No TTY → print the static forest and exit 0 (no raw mode).
48
72
  if (!process.stdin.isTTY) {
49
73
  process.stdout.write(await renderForestFromSource(source) + '\n');
@@ -103,6 +127,14 @@ export async function runBrowse(opts = {}) {
103
127
  preview: true, // default ON (decision)
104
128
  residentsOnly: true, // default ON: hide one-shot workers (decision)
105
129
  pendingClose: null,
130
+ pinned: loadPins(),
131
+ help: false,
132
+ rename: false,
133
+ renameBuf: '',
134
+ renameTarget: null,
135
+ message: false,
136
+ messageBuf: '',
137
+ messageTarget: null,
106
138
  };
107
139
  let visible = [];
108
140
  // The DashboardRow ref the tree holds for a visible id. Enrichment mutates this
@@ -193,6 +225,7 @@ export async function runBrowse(opts = {}) {
193
225
  cwdScope: state.cwdScope,
194
226
  residentsOnly: state.residentsOnly,
195
227
  sort: state.sort,
228
+ pinned: state.pinned,
196
229
  });
197
230
  if (keepId !== undefined) {
198
231
  const idx = visible.findIndex((v) => v.id === keepId);
@@ -207,7 +240,8 @@ export async function runBrowse(opts = {}) {
207
240
  const flush = async () => {
208
241
  const size = getTerminalSize();
209
242
  const previewOn = state.preview && visible.length > 0;
210
- const viewport = viewportHeight(size.rows, state.search, previewOn);
243
+ const inputMode = state.search || state.rename || state.message;
244
+ const viewport = viewportHeight(size.rows, inputMode, previewOn);
211
245
  // Keep the cursor inside the viewport window.
212
246
  if (state.cursor < state.scrollOffset)
213
247
  state.scrollOffset = state.cursor;
@@ -238,6 +272,9 @@ export async function runBrowse(opts = {}) {
238
272
  query: state.query, search: state.search, totalNodes,
239
273
  cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
240
274
  residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
275
+ pinned: state.pinned, help: state.help,
276
+ rename: state.rename, renameBuf: state.renameBuf,
277
+ message: state.message, messageBuf: state.messageBuf,
241
278
  readOnly: isRemote,
242
279
  }, size, caps);
243
280
  process.stdout.write(frame);
@@ -296,6 +333,13 @@ export async function runBrowse(opts = {}) {
296
333
  n.row.status = m.status; // real post-close status (done / canceled)
297
334
  n.row.streaming = false;
298
335
  n.row.viewed = false;
336
+ // A finalized node is no longer hanging and owes no answer — clear both so it
337
+ // actually LEAVES the Attention tab (asks>0 || hanging) and stops rendering the
338
+ // stale `⚠ down` rail. Without this, closing a faulted/asking node from the
339
+ // Attention tab looks like a no-op: the row was finalized in the db but its
340
+ // stale fault/ask kept it matching the tab, so it never disappeared.
341
+ n.row.hanging = null;
342
+ n.row.asks = 0;
299
343
  }
300
344
  // Keep the cursor at the same INDEX (the row that shifts up into the closed
301
345
  // slot), not pinned to the now-gone node — recompute() leaves state.cursor
@@ -375,7 +419,8 @@ export async function runBrowse(opts = {}) {
375
419
  });
376
420
  };
377
421
  const onKeySearch = (input, key) => {
378
- if (key.escape) {
422
+ const action = resolveBrowseSearchAction(bindings, { input, ...key });
423
+ if (action === 'crtr.browse.search.cancel') {
379
424
  // Cancel the search: drop the query AND the relevance ranking it switched
380
425
  // on, returning to the tree. Synchronous: we're leaving search mode.
381
426
  state.search = false;
@@ -385,7 +430,7 @@ export async function runBrowse(opts = {}) {
385
430
  scheduleFlush();
386
431
  return;
387
432
  }
388
- if (key.return) {
433
+ if (action === 'crtr.browse.search.commit') {
389
434
  // Commit: keep the filter, drop search mode, land on the first match. Must
390
435
  // recompute synchronously first so the match index reflects the final query
391
436
  // even if a coalesced repaint is still pending.
@@ -397,17 +442,16 @@ export async function runBrowse(opts = {}) {
397
442
  scheduleFlush();
398
443
  return;
399
444
  }
400
- if (key.backspace) {
445
+ if (action === 'crtr.browse.search.delete') {
401
446
  state.query = state.query.slice(0, -1);
402
447
  scheduleSearchRepaint();
403
448
  return;
404
449
  }
405
- // Any ctrl-combo: Ctrl+C quits; everything else is swallowed (never typed).
406
- if (key.ctrl) {
407
- if (input === 'c')
408
- quit();
450
+ // Bound quit acts before text capture; other ctrl-combos are swallowed.
451
+ if (action === 'crtr.browse.search.quit')
452
+ quit();
453
+ if (key.ctrl)
409
454
  return;
410
- }
411
455
  // Append every printable char in the chunk. Fast typing batches multiple
412
456
  // chars into one stdin read, so we must take them all (not just a length-1
413
457
  // chunk) or keystrokes get dropped. Filtering to >= ' ' strips control/escape
@@ -419,18 +463,114 @@ export async function runBrowse(opts = {}) {
419
463
  scheduleSearchRepaint();
420
464
  }
421
465
  };
422
- const onKeyNav = (input, key) => {
423
- // Ctrl-combos first: only Ctrl+C is meaningful (quit); swallow the rest so
424
- // Ctrl+L / Ctrl+J / Ctrl+Q etc. don't masquerade as l/j/q commands.
466
+ // Recenter the cursor on the launching node (CRTR_NODE_ID), expanding its
467
+ // ancestors so it is actually visible. No-op outside a node or when it's filtered
468
+ // out of the current tab/scope. Same move the boot performs, on demand (`.`).
469
+ const recenterOnSelf = () => {
470
+ const selfId = process.env['CRTR_NODE_ID'];
471
+ if (selfId === undefined || selfId === '' || !tree.nodes.has(selfId))
472
+ return;
473
+ for (let p = tree.nodes.get(selfId)?.parentId ?? null; p !== null; p = tree.nodes.get(p)?.parentId ?? null) {
474
+ state.collapsed.delete(p);
475
+ }
476
+ recompute(selfId);
477
+ };
478
+ // Toggle the pin on a node: mutate the in-memory set, persist best-effort, and
479
+ // recompute so it floats (flat sorts) / (dis)appears from the Pinned tab.
480
+ const togglePin = (id) => {
481
+ if (state.pinned.has(id))
482
+ state.pinned.delete(id);
483
+ else
484
+ state.pinned.add(id);
485
+ savePins(state.pinned);
486
+ recompute(id);
487
+ };
488
+ // Commit an inline rename: `crtr node config <id> --name <name>` (also renames a
489
+ // live viewer window), then reflect the new name into the snapshot in place.
490
+ const commitRename = () => {
491
+ const id = state.renameTarget;
492
+ const name = state.renameBuf.trim();
493
+ state.rename = false;
494
+ if (id !== null && name !== '') {
495
+ try {
496
+ execFileSync('crtr', ['node', 'config', id, '--name', name], { stdio: 'ignore' });
497
+ }
498
+ catch {
499
+ /* best-effort — a rename failure never wedges the picker */
500
+ }
501
+ const n = tree.nodes.get(id);
502
+ if (n !== undefined)
503
+ n.row.name = name;
504
+ }
505
+ recompute(state.renameTarget ?? undefined);
506
+ scheduleFlush();
507
+ };
508
+ // Commit an inline message: `crtr node msg --to <id> <body>` (immediate inbox
509
+ // delivery, best-effort revive if dormant). Fire-and-forget — browse stays put.
510
+ const commitMessage = () => {
511
+ const id = state.messageTarget;
512
+ const body = state.messageBuf.trim();
513
+ state.message = false;
514
+ if (id !== null && body !== '') {
515
+ try {
516
+ execFileSync('crtr', ['node', 'msg', '--to', id, body], { stdio: 'ignore' });
517
+ }
518
+ catch {
519
+ /* best-effort — delivery failure never wedges the picker */
520
+ }
521
+ }
522
+ scheduleFlush();
523
+ };
524
+ // Shared text-input keystroke handling for the rename/message sub-modes: Esc
525
+ // cancels, Enter commits, Backspace deletes, Ctrl+C quits, printable chars append.
526
+ const onKeyText = (input, key, get, set, commit, cancel) => {
527
+ if (key.escape) {
528
+ cancel();
529
+ return;
530
+ }
531
+ if (key.return) {
532
+ commit();
533
+ return;
534
+ }
535
+ if (key.backspace) {
536
+ set(get().slice(0, -1));
537
+ scheduleFlush();
538
+ return;
539
+ }
425
540
  if (key.ctrl) {
426
541
  if (input === 'c')
427
542
  quit();
428
543
  return;
429
544
  }
430
- // Close-out confirm sub-state: a streaming node is awaiting y/n. Swallow every
431
- // other key so the confirm is modal.
545
+ const printable = [...input].filter((c) => c >= ' ').join('');
546
+ if (printable.length > 0) {
547
+ set(get() + printable);
548
+ scheduleFlush();
549
+ }
550
+ };
551
+ const onKeyRename = (input, key) => onKeyText(input, key, () => state.renameBuf, (s) => { state.renameBuf = s; }, commitRename, () => { state.rename = false; scheduleFlush(); });
552
+ const onKeyMessage = (input, key) => onKeyText(input, key, () => state.messageBuf, (s) => { state.messageBuf = s; }, commitMessage, () => { state.message = false; scheduleFlush(); });
553
+ // Help overlay: any key dismisses (Ctrl+C still quits outright).
554
+ const onKeyHelp = (input, key) => {
555
+ if (key.ctrl && input === 'c') {
556
+ quit();
557
+ return;
558
+ }
559
+ state.help = false;
560
+ scheduleFlush();
561
+ };
562
+ const onKeyNav = (input, key) => {
563
+ const raw = { input, ...key };
564
+ const action = resolveBrowseNavAction(bindings, raw);
565
+ // Unbound ctrl-combos are swallowed so they never masquerade as printable commands.
566
+ if (key.ctrl && action === null)
567
+ return;
568
+ // Preserve an explicitly bound control-combo quit while the close confirmation is open.
569
+ // Other non-confirming input dismisses the modal without also running navigation actions.
570
+ if (state.pendingClose !== null && action === 'crtr.browse.quit' && key.ctrl)
571
+ quit();
432
572
  if (state.pendingClose !== null) {
433
- if (input === 'y' || input === 'Y') {
573
+ if (matchesTerminalInput(bindings, 'crtr.browse.confirm-close', raw)) {
434
574
  const target = state.pendingClose;
435
575
  void doClose(target).then(() => scheduleFlush()).catch(() => scheduleFlush());
436
576
  return;
@@ -441,32 +581,31 @@ export async function runBrowse(opts = {}) {
441
581
  return;
442
582
  }
443
583
  const row = curRow();
444
- // Quit.
445
- if (input === 'q' || key.escape)
584
+ // Quit and move.
585
+ if (action === 'crtr.browse.quit')
446
586
  quit();
447
- // Move.
448
- if (key.upArrow || input === 'k') {
587
+ if (action === 'crtr.browse.up') {
449
588
  state.cursor = Math.max(0, state.cursor - 1);
450
589
  scheduleFlush();
451
590
  return;
452
591
  }
453
- if (key.downArrow || input === 'j') {
592
+ if (action === 'crtr.browse.down') {
454
593
  state.cursor = Math.max(0, Math.min(visible.length - 1, state.cursor + 1));
455
594
  scheduleFlush();
456
595
  return;
457
596
  }
458
- if (input === 'g') {
597
+ if (action === 'crtr.browse.first') {
459
598
  state.cursor = 0;
460
599
  scheduleFlush();
461
600
  return;
462
601
  }
463
- if (input === 'G') {
602
+ if (action === 'crtr.browse.last') {
464
603
  state.cursor = Math.max(0, visible.length - 1);
465
604
  scheduleFlush();
466
605
  return;
467
606
  }
468
607
  // Expand / descend.
469
- if (key.rightArrow || input === 'l') {
608
+ if (action === 'crtr.browse.expand-or-child') {
470
609
  if (row !== undefined && row.hasChildren) {
471
610
  if (!isExpanded(row.id)) {
472
611
  state.collapsed.delete(row.id);
@@ -480,7 +619,7 @@ export async function runBrowse(opts = {}) {
480
619
  return;
481
620
  }
482
621
  // Collapse / ascend.
483
- if (key.leftArrow || input === 'h') {
622
+ if (action === 'crtr.browse.collapse-or-parent') {
484
623
  if (row !== undefined && row.hasChildren && isExpanded(row.id)) {
485
624
  state.collapsed.add(row.id);
486
625
  recompute(row.id);
@@ -497,7 +636,7 @@ export async function runBrowse(opts = {}) {
497
636
  return;
498
637
  }
499
638
  // Toggle collapse.
500
- if (input === ' ') {
639
+ if (action === 'crtr.browse.toggle-fold') {
501
640
  if (row !== undefined && row.hasChildren) {
502
641
  if (isExpanded(row.id))
503
642
  state.collapsed.add(row.id);
@@ -508,19 +647,20 @@ export async function runBrowse(opts = {}) {
508
647
  scheduleFlush();
509
648
  return;
510
649
  }
511
- // Tabs.
512
- if (key.tab || input === ']') {
650
+ // Tabs. next/previous and tab.1–5 (including Pinned) all resolve through the
651
+ // catalog, so each numbered tab is user-remappable like any other binding.
652
+ if (action === 'crtr.browse.tab-next') {
513
653
  cycleTab(1);
514
654
  scheduleFlush();
515
655
  return;
516
656
  }
517
- if (key.shiftTab || input === '[') {
657
+ if (action === 'crtr.browse.tab-previous') {
518
658
  cycleTab(-1);
519
659
  scheduleFlush();
520
660
  return;
521
661
  }
522
- if (input >= '1' && input <= '4') {
523
- const idx = Number(input) - 1;
662
+ if (action?.startsWith('crtr.browse.tab.')) {
663
+ const idx = Number(action.slice(-1)) - 1;
524
664
  if (idx < TABS.length) {
525
665
  state.tab = TABS[idx];
526
666
  state.cursor = 0;
@@ -533,7 +673,7 @@ export async function runBrowse(opts = {}) {
533
673
  // Close-out the selected node (+ its exclusive subtree). If anything in that
534
674
  // subtree is actively streaming, confirm first (y/n); otherwise close at once.
535
675
  // No-op against a remote canvas — mutations are disabled (footer says so).
536
- if (input === 'x') {
676
+ if (action === 'crtr.browse.close-subtree') {
537
677
  if (isRemote)
538
678
  return;
539
679
  if (row === undefined) {
@@ -549,29 +689,72 @@ export async function runBrowse(opts = {}) {
549
689
  return;
550
690
  }
551
691
  // Sort / scope / residents / preview.
552
- if (input === 's') {
692
+ if (action === 'crtr.browse.sort') {
553
693
  cycleSort();
554
694
  scheduleFlush();
555
695
  return;
556
696
  }
557
- if (input === 'c') {
697
+ if (action === 'crtr.browse.scope') {
558
698
  toggleScope();
559
699
  scheduleFlush();
560
700
  return;
561
701
  }
562
- if (input === 'r') {
702
+ if (action === 'crtr.browse.residents') {
563
703
  toggleResidents();
564
704
  scheduleFlush();
565
705
  return;
566
706
  }
567
- if (input === 'p') {
707
+ if (action === 'crtr.browse.preview') {
568
708
  state.preview = !state.preview;
569
709
  scheduleFlush();
570
710
  return;
571
711
  }
712
+ // Help overlay.
713
+ if (input === '?') {
714
+ state.help = true;
715
+ scheduleFlush();
716
+ return;
717
+ }
718
+ // Pin / unpin (local, view-only — works against a remote canvas too).
719
+ if (input === 'f') {
720
+ if (row !== undefined)
721
+ togglePin(row.id);
722
+ scheduleFlush();
723
+ return;
724
+ }
725
+ // Recenter on the launching node.
726
+ if (input === '.') {
727
+ recenterOnSelf();
728
+ scheduleFlush();
729
+ return;
730
+ }
731
+ // Rename the node inline (mutation — no-op on a remote canvas).
732
+ if (input === 'R') {
733
+ if (isRemote)
734
+ return;
735
+ if (row !== undefined) {
736
+ state.rename = true;
737
+ state.renameTarget = row.id;
738
+ state.renameBuf = rowOf(row.id)?.name ?? '';
739
+ }
740
+ scheduleFlush();
741
+ return;
742
+ }
743
+ // Message the node without resuming it (mutation — no-op on a remote canvas).
744
+ if (input === 'm') {
745
+ if (isRemote)
746
+ return;
747
+ if (row !== undefined) {
748
+ state.message = true;
749
+ state.messageTarget = row.id;
750
+ state.messageBuf = '';
751
+ }
752
+ scheduleFlush();
753
+ return;
754
+ }
572
755
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
573
756
  // name match floats to the top as you type.
574
- if (input === '/') {
757
+ if (action === 'crtr.browse.search') {
575
758
  state.search = true;
576
759
  state.query = '';
577
760
  state.sort = 'relevance';
@@ -587,7 +770,7 @@ export async function runBrowse(opts = {}) {
587
770
  // --now` (browse goes through commands, never touching the runtime directly). 'k' is
588
771
  // vim-up here, so the kick is on SHIFT-K.
589
772
  // No-op against a remote canvas — mutations are disabled (footer says so).
590
- if (input === 'K') {
773
+ if (action === 'crtr.browse.revive-now') {
591
774
  if (isRemote)
592
775
  return;
593
776
  if (row !== undefined) {
@@ -600,7 +783,7 @@ export async function runBrowse(opts = {}) {
600
783
  }
601
784
  // Resume. No-op against a remote canvas — mutations/focus are disabled
602
785
  // (footer says so).
603
- if (key.return) {
786
+ if (action === 'crtr.browse.focus') {
604
787
  if (isRemote)
605
788
  return;
606
789
  if (row !== undefined)
@@ -664,8 +847,14 @@ export async function runBrowse(opts = {}) {
664
847
  const onData = (data) => {
665
848
  try {
666
849
  const { input, key } = parseKeypress(data);
667
- if (state.search)
850
+ if (state.help)
851
+ onKeyHelp(input, key);
852
+ else if (state.search)
668
853
  onKeySearch(input, key);
854
+ else if (state.rename)
855
+ onKeyRename(input, key);
856
+ else if (state.message)
857
+ onKeyMessage(input, key);
669
858
  else
670
859
  onKeyNav(input, key);
671
860
  }
@@ -1,6 +1,6 @@
1
1
  import type { DashboardRow } from '../render.js';
2
2
  import type { NodeStatus } from '../types.js';
3
- export type Tab = 'All' | 'Live' | 'Dormant' | 'Flagged';
3
+ export type Tab = 'All' | 'Live' | 'Dormant' | 'Attention' | 'Pinned';
4
4
  export declare const TABS: readonly Tab[];
5
5
  /** How the visible rows are ordered.
6
6
  * tree — spanning-tree order, ancestors shown for context (the default).
@@ -11,11 +11,12 @@ export declare const TABS: readonly Tab[];
11
11
  export type SortMode = 'tree' | 'attention' | 'relevance' | 'recency';
12
12
  export declare const SORTS: readonly SortMode[];
13
13
  /** Does a node belong to this tab's slice?
14
- * All — every node.
15
- * Live — active | idle.
16
- * Dormant — done | dead | canceled.
17
- * Flagged has > 0 pending human asks. */
18
- export declare function tabPredicate(tab: Tab, row: DashboardRow): boolean;
14
+ * All — every node.
15
+ * Live — active | idle.
16
+ * Dormant — done | dead | canceled.
17
+ * Attention needs a human: has pending asks OR is parked on a fault.
18
+ * Pinned — user-starred (a local, view-only preference; `isPinned`). */
19
+ export declare function tabPredicate(tab: Tab, row: DashboardRow, isPinned?: boolean): boolean;
19
20
  export interface TreeNode {
20
21
  row: DashboardRow;
21
22
  depth: number;
@@ -138,6 +139,9 @@ export interface FlattenOpts {
138
139
  /** Ordering. `tree` keeps the spanning tree + ancestor context; `relevance` /
139
140
  * `recency` produce a FLAT ranked list of directly-matched rows. */
140
141
  sort?: SortMode;
142
+ /** User-starred node ids (local, view-only). Gates the Pinned tab, and floats
143
+ * pinned rows to the top of every FLAT sort mode. undefined = none pinned. */
144
+ pinned?: Set<string>;
141
145
  }
142
146
  /** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
143
147
  export declare function cwdMatch(scope: string | null | undefined, row: DashboardRow): boolean;
@@ -4,19 +4,21 @@
4
4
  // it is exhaustively unit-testable. The app layer (app.ts) wires the canvas data
5
5
  // access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
6
6
  // drives flatten() on each keystroke.
7
- export const TABS = ['All', 'Live', 'Dormant', 'Flagged'];
7
+ export const TABS = ['All', 'Live', 'Dormant', 'Attention', 'Pinned'];
8
8
  export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
9
9
  /** Does a node belong to this tab's slice?
10
- * All — every node.
11
- * Live — active | idle.
12
- * Dormant — done | dead | canceled.
13
- * Flagged has > 0 pending human asks. */
14
- export function tabPredicate(tab, row) {
10
+ * All — every node.
11
+ * Live — active | idle.
12
+ * Dormant — done | dead | canceled.
13
+ * Attention needs a human: has pending asks OR is parked on a fault.
14
+ * Pinned — user-starred (a local, view-only preference; `isPinned`). */
15
+ export function tabPredicate(tab, row, isPinned = false) {
15
16
  switch (tab) {
16
17
  case 'All': return true;
17
18
  case 'Live': return row.status === 'active' || row.status === 'idle';
18
19
  case 'Dormant': return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
19
- case 'Flagged': return row.asks > 0;
20
+ case 'Attention': return row.asks > 0 || row.hanging != null;
21
+ case 'Pinned': return isPinned;
20
22
  }
21
23
  }
22
24
  /** Sort rank for roots/stragglers — live first (active, then idle), dormant
@@ -383,7 +385,8 @@ export function lifecycleMatch(residentsOnly, row) {
383
385
  * always reachable.
384
386
  */
385
387
  export function flatten(tree, opts) {
386
- const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree' } = opts;
388
+ const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree', pinned } = opts;
389
+ const isPinned = (id) => pinned?.has(id) === true;
387
390
  // 1. Directly-matched nodes: tab predicate AND cwd scope AND lifecycle AND query.
388
391
  // `matched` honors the residents-only filter (drives top-level rows, ancestor
389
392
  // context, force-expand, and the flat search results below). `matchedAll` is
@@ -394,7 +397,7 @@ export function flatten(tree, opts) {
394
397
  const matched = new Set();
395
398
  const matchedAll = new Set();
396
399
  for (const [id, node] of tree.nodes) {
397
- if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
400
+ if (tabPredicate(tab, node.row, isPinned(id)) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
398
401
  matchedAll.add(id);
399
402
  if (lifecycleMatch(residentsOnly, node.row))
400
403
  matched.add(id);
@@ -407,21 +410,26 @@ export function flatten(tree, opts) {
407
410
  const ids = [...matched];
408
411
  const createdOf = (id) => tree.nodes.get(id)?.row.created ?? '';
409
412
  const byRecency = (a, b) => createdOf(b).localeCompare(createdOf(a));
413
+ // Pinned rows float above everything in every flat mode — "keep this at the
414
+ // top of my list" is the whole point of a pin. Ordering WITHIN each pin tier
415
+ // still follows the active sort.
416
+ const pinRank = (id) => (isPinned(id) ? 0 : 1);
410
417
  if (sort === 'attention') {
411
418
  // Tiered (attached/streaming/live first), newest-message-first within tier.
412
419
  const rowOf = (id) => tree.nodes.get(id).row;
413
- ids.sort((a, b) => (attentionTier(rowOf(a)) - attentionTier(rowOf(b))) ||
420
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) ||
421
+ (attentionTier(rowOf(a)) - attentionTier(rowOf(b))) ||
414
422
  (attentionMtime(rowOf(b)) - attentionMtime(rowOf(a))) ||
415
423
  byRecency(a, b));
416
424
  }
417
425
  else if (sort === 'recency' || query === '') {
418
- ids.sort(byRecency);
426
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) || byRecency(a, b));
419
427
  }
420
428
  else {
421
429
  const score = new Map();
422
430
  for (const id of ids)
423
431
  score.set(id, scoreRow(query, tree.nodes.get(id).row));
424
- ids.sort((a, b) => (score.get(b) - score.get(a)) || byRecency(a, b));
432
+ ids.sort((a, b) => (pinRank(a) - pinRank(b)) || (score.get(b) - score.get(a)) || byRecency(a, b));
425
433
  }
426
434
  return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
427
435
  }
@@ -0,0 +1,4 @@
1
+ /** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
2
+ export declare function loadPins(): Set<string>;
3
+ /** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
4
+ export declare function savePins(pins: Set<string>): void;
@@ -0,0 +1,29 @@
1
+ // pins.ts — the durable per-user "pinned" set for the canvas browser.
2
+ //
3
+ // A pin is a LOCAL, view-only preference, NOT a canvas property: starring a node
4
+ // keeps it at the top of YOUR navigator across sessions and machines, and it works
5
+ // against a remote canvas too (you are pinning in your own view, mutating nothing
6
+ // on the canvas). Stored as a flat id list at ~/.crouter/browse-pins.json so every
7
+ // `canvas browse` invocation shares it regardless of cwd/scope. Read once at boot,
8
+ // mutated in memory, written best-effort on each toggle.
9
+ import { join } from 'node:path';
10
+ import { userScopeRoot } from '../../scope.js';
11
+ import { readJsonIfExists, atomicWriteJson } from '../../fs-utils.js';
12
+ const PINS_FILE = 'browse-pins.json';
13
+ function pinsPath() {
14
+ return join(userScopeRoot(), PINS_FILE);
15
+ }
16
+ /** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
17
+ export function loadPins() {
18
+ const raw = readJsonIfExists(pinsPath());
19
+ return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
20
+ }
21
+ /** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
22
+ export function savePins(pins) {
23
+ try {
24
+ atomicWriteJson(pinsPath(), [...pins]);
25
+ }
26
+ catch {
27
+ /* best-effort: a pin that doesn't survive the session is a minor loss */
28
+ }
29
+ }
@@ -6,7 +6,7 @@ export declare const PROMPT_LINES = 3;
6
6
  export declare const REPLY_LINES = 3;
7
7
  export declare const PREVIEW_BODY: number;
8
8
  export declare const PREVIEW_HEIGHT: number;
9
- export declare function headerHeight(search: boolean): number;
9
+ export declare function headerHeight(inputLine: boolean): number;
10
10
  export interface RenderState {
11
11
  tree: Tree;
12
12
  visible: VisibleRow[];
@@ -25,6 +25,18 @@ export interface RenderState {
25
25
  /** Lifecycle filter: when true, `terminal` (one-shot worker) nodes are hidden
26
26
  * — surfaced as a status-line cue. */
27
27
  residentsOnly: boolean;
28
+ /** User-starred node ids — drives the ★ row marker + the Pinned tab. */
29
+ pinned: Set<string>;
30
+ /** Inline rename input: when true, the header shows a rename prompt bound to
31
+ * `renameBuf` and the footer becomes commit/cancel. */
32
+ rename?: boolean;
33
+ renameBuf?: string;
34
+ /** Inline message input: when true, the header shows a message prompt bound to
35
+ * `messageBuf` (delivered via `crtr node msg`). */
36
+ message?: boolean;
37
+ messageBuf?: string;
38
+ /** When true, the whole body is replaced by the keybinding help overlay. */
39
+ help?: boolean;
28
40
  /** When set, a close-out (`x`) is awaiting y/n confirmation because the node (or
29
41
  * a descendant) is actively streaming. Holds the node id being confirmed; the
30
42
  * footer becomes the y/n prompt. null/undefined = no pending confirm. */