@crouton-kit/crouter 0.3.68 → 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 (176) 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 +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  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 +365 -94
  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/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -108,20 +108,38 @@ test('pruneNode: absent id is a no-op', () => {
108
108
  assert.equal(t.nodes.size, before);
109
109
  });
110
110
  // ── tab predicates ───────────────────────────────────────────────────────────
111
- test('tabPredicate: All / Live / Dormant / Flagged', () => {
112
- assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Flagged']);
111
+ test('tabPredicate: All / Live / Dormant / Attention / Pinned', () => {
112
+ assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Attention', 'Pinned']);
113
113
  const active = ROWS[0]; // root1 active
114
114
  const idle = ROWS[1]; // child-a idle
115
115
  const done = ROWS[2]; // grand-x done
116
- const flagged = ROWS[3]; // child-b active asks 2
116
+ const attention = ROWS[3]; // child-b active asks 2
117
117
  assert.equal(tabPredicate('All', done), true);
118
118
  assert.equal(tabPredicate('Live', active), true);
119
119
  assert.equal(tabPredicate('Live', idle), true);
120
120
  assert.equal(tabPredicate('Live', done), false);
121
121
  assert.equal(tabPredicate('Dormant', done), true);
122
122
  assert.equal(tabPredicate('Dormant', active), false);
123
- assert.equal(tabPredicate('Flagged', flagged), true);
124
- assert.equal(tabPredicate('Flagged', active), false);
123
+ // Attention = pending asks OR a fault.
124
+ assert.equal(tabPredicate('Attention', attention), true);
125
+ assert.equal(tabPredicate('Attention', active), false);
126
+ assert.equal(tabPredicate('Attention', { ...active, asks: 0, hanging: { kind: 'provider' } }), true);
127
+ // Pinned reads the isPinned arg, not the row.
128
+ assert.equal(tabPredicate('Pinned', active), false);
129
+ assert.equal(tabPredicate('Pinned', active, true), true);
130
+ });
131
+ // ── flatten: pins ────────────────────────────────────────────────────────────
132
+ test('flatten: Pinned tab shows only pinned rows', () => {
133
+ const pinned = new Set(['child-c']);
134
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'Pinned', query: '', pinned });
135
+ // child-c is directly matched; root2 rides along as its ancestor (context).
136
+ const matched = v.filter((r) => r.matched).map((r) => r.id);
137
+ assert.deepEqual(matched, ['child-c']);
138
+ });
139
+ test('flatten: pinned rows float to the top in a flat sort', () => {
140
+ const pinned = new Set(['lonely']); // an idle straggler that would otherwise sort late
141
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'All', query: '', sort: 'recency', pinned });
142
+ assert.equal(v[0].id, 'lonely');
125
143
  });
126
144
  // ── flatten: collapse / expand ───────────────────────────────────────────────
127
145
  const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
@@ -71,19 +71,36 @@ test('a burst of SSE-style triggers produces ONE round of remote requests, not N
71
71
  res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
72
72
  });
73
73
  });
74
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
75
- const addr = server.address();
76
- const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
77
- const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
78
- const rebuilder = createCoalescedRebuilder(async () => {
79
- source.invalidate();
80
- await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
81
- }, 20);
82
- for (let i = 0; i < 6; i++)
83
- rebuilder.trigger();
84
- await new Promise((r) => setTimeout(r, 80));
85
- assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
86
- await new Promise((r) => server.close(() => r()));
74
+ // The server MUST close in a `finally` — a listening TCP server keeps its
75
+ // process's event loop non-empty forever. Under `node --test`, every test
76
+ // file is its own child process; if an assertion in this test throws before
77
+ // the old inline `server.close()` at the end of the function ran, the
78
+ // server (and the process) never exited, wedging one `--test-concurrency`
79
+ // slot permanently and hanging the whole suite (see the CI-hang postmortem).
80
+ try {
81
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
82
+ const addr = server.address();
83
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
84
+ const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
85
+ const rebuilder = createCoalescedRebuilder(async () => {
86
+ source.invalidate();
87
+ await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
88
+ }, 20);
89
+ for (let i = 0; i < 6; i++)
90
+ rebuilder.trigger();
91
+ // Poll for the request to land instead of sleeping a fixed duration —
92
+ // deterministic under CI's variable CPU load, and doesn't slow down the
93
+ // common case (returns as soon as the debounce + request round trip land).
94
+ const deadline = Date.now() + 2000;
95
+ while (hits < 1 && Date.now() < deadline) {
96
+ await new Promise((r) => setTimeout(r, 10));
97
+ }
98
+ assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
99
+ }
100
+ finally {
101
+ server.closeAllConnections();
102
+ await new Promise((r) => server.close(() => r()));
103
+ }
87
104
  });
88
105
  // Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
89
106
  // helper in isolation. Every test above instantiates `createCoalescedRebuilder`
@@ -37,6 +37,7 @@ function state(over = {}) {
37
37
  sort: 'tree',
38
38
  preview: false,
39
39
  residentsOnly: false,
40
+ pinned: new Set(),
40
41
  ...over,
41
42
  };
42
43
  }
@@ -15,6 +15,7 @@ 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';
19
20
  // Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
20
21
  // press restores the structural `tree` view, then relevance/recency, then back.
@@ -23,10 +24,10 @@ import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './re
23
24
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
24
25
  * render.ts headerHeight), the footer, and the preview panel when shown. Kept
25
26
  * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
26
- function viewportHeight(rowsTotal, search, previewOn) {
27
+ function viewportHeight(rowsTotal, inputLine, previewOn) {
27
28
  const rows = Math.max(8, rowsTotal);
28
29
  const previewH = previewOn ? PREVIEW_HEIGHT : 0;
29
- return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
30
+ return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
30
31
  }
31
32
  /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
32
33
  * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
@@ -103,6 +104,14 @@ export async function runBrowse(opts = {}) {
103
104
  preview: true, // default ON (decision)
104
105
  residentsOnly: true, // default ON: hide one-shot workers (decision)
105
106
  pendingClose: null,
107
+ pinned: loadPins(),
108
+ help: false,
109
+ rename: false,
110
+ renameBuf: '',
111
+ renameTarget: null,
112
+ message: false,
113
+ messageBuf: '',
114
+ messageTarget: null,
106
115
  };
107
116
  let visible = [];
108
117
  // The DashboardRow ref the tree holds for a visible id. Enrichment mutates this
@@ -193,6 +202,7 @@ export async function runBrowse(opts = {}) {
193
202
  cwdScope: state.cwdScope,
194
203
  residentsOnly: state.residentsOnly,
195
204
  sort: state.sort,
205
+ pinned: state.pinned,
196
206
  });
197
207
  if (keepId !== undefined) {
198
208
  const idx = visible.findIndex((v) => v.id === keepId);
@@ -207,7 +217,8 @@ export async function runBrowse(opts = {}) {
207
217
  const flush = async () => {
208
218
  const size = getTerminalSize();
209
219
  const previewOn = state.preview && visible.length > 0;
210
- const viewport = viewportHeight(size.rows, state.search, previewOn);
220
+ const inputMode = state.search || state.rename || state.message;
221
+ const viewport = viewportHeight(size.rows, inputMode, previewOn);
211
222
  // Keep the cursor inside the viewport window.
212
223
  if (state.cursor < state.scrollOffset)
213
224
  state.scrollOffset = state.cursor;
@@ -238,6 +249,9 @@ export async function runBrowse(opts = {}) {
238
249
  query: state.query, search: state.search, totalNodes,
239
250
  cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
240
251
  residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
252
+ pinned: state.pinned, help: state.help,
253
+ rename: state.rename, renameBuf: state.renameBuf,
254
+ message: state.message, messageBuf: state.messageBuf,
241
255
  readOnly: isRemote,
242
256
  }, size, caps);
243
257
  process.stdout.write(frame);
@@ -419,6 +433,102 @@ export async function runBrowse(opts = {}) {
419
433
  scheduleSearchRepaint();
420
434
  }
421
435
  };
436
+ // Recenter the cursor on the launching node (CRTR_NODE_ID), expanding its
437
+ // ancestors so it is actually visible. No-op outside a node or when it's filtered
438
+ // out of the current tab/scope. Same move the boot performs, on demand (`.`).
439
+ const recenterOnSelf = () => {
440
+ const selfId = process.env['CRTR_NODE_ID'];
441
+ if (selfId === undefined || selfId === '' || !tree.nodes.has(selfId))
442
+ return;
443
+ for (let p = tree.nodes.get(selfId)?.parentId ?? null; p !== null; p = tree.nodes.get(p)?.parentId ?? null) {
444
+ state.collapsed.delete(p);
445
+ }
446
+ recompute(selfId);
447
+ };
448
+ // Toggle the pin on a node: mutate the in-memory set, persist best-effort, and
449
+ // recompute so it floats (flat sorts) / (dis)appears from the Pinned tab.
450
+ const togglePin = (id) => {
451
+ if (state.pinned.has(id))
452
+ state.pinned.delete(id);
453
+ else
454
+ state.pinned.add(id);
455
+ savePins(state.pinned);
456
+ recompute(id);
457
+ };
458
+ // Commit an inline rename: `crtr node config <id> --name <name>` (also renames a
459
+ // live viewer window), then reflect the new name into the snapshot in place.
460
+ const commitRename = () => {
461
+ const id = state.renameTarget;
462
+ const name = state.renameBuf.trim();
463
+ state.rename = false;
464
+ if (id !== null && name !== '') {
465
+ try {
466
+ execFileSync('crtr', ['node', 'config', id, '--name', name], { stdio: 'ignore' });
467
+ }
468
+ catch {
469
+ /* best-effort — a rename failure never wedges the picker */
470
+ }
471
+ const n = tree.nodes.get(id);
472
+ if (n !== undefined)
473
+ n.row.name = name;
474
+ }
475
+ recompute(state.renameTarget ?? undefined);
476
+ scheduleFlush();
477
+ };
478
+ // Commit an inline message: `crtr node msg --to <id> <body>` (immediate inbox
479
+ // delivery, best-effort revive if dormant). Fire-and-forget — browse stays put.
480
+ const commitMessage = () => {
481
+ const id = state.messageTarget;
482
+ const body = state.messageBuf.trim();
483
+ state.message = false;
484
+ if (id !== null && body !== '') {
485
+ try {
486
+ execFileSync('crtr', ['node', 'msg', '--to', id, body], { stdio: 'ignore' });
487
+ }
488
+ catch {
489
+ /* best-effort — delivery failure never wedges the picker */
490
+ }
491
+ }
492
+ scheduleFlush();
493
+ };
494
+ // Shared text-input keystroke handling for the rename/message sub-modes: Esc
495
+ // cancels, Enter commits, Backspace deletes, Ctrl+C quits, printable chars append.
496
+ const onKeyText = (input, key, get, set, commit, cancel) => {
497
+ if (key.escape) {
498
+ cancel();
499
+ return;
500
+ }
501
+ if (key.return) {
502
+ commit();
503
+ return;
504
+ }
505
+ if (key.backspace) {
506
+ set(get().slice(0, -1));
507
+ scheduleFlush();
508
+ return;
509
+ }
510
+ if (key.ctrl) {
511
+ if (input === 'c')
512
+ quit();
513
+ return;
514
+ }
515
+ const printable = [...input].filter((c) => c >= ' ').join('');
516
+ if (printable.length > 0) {
517
+ set(get() + printable);
518
+ scheduleFlush();
519
+ }
520
+ };
521
+ const onKeyRename = (input, key) => onKeyText(input, key, () => state.renameBuf, (s) => { state.renameBuf = s; }, commitRename, () => { state.rename = false; scheduleFlush(); });
522
+ const onKeyMessage = (input, key) => onKeyText(input, key, () => state.messageBuf, (s) => { state.messageBuf = s; }, commitMessage, () => { state.message = false; scheduleFlush(); });
523
+ // Help overlay: any key dismisses (Ctrl+C still quits outright).
524
+ const onKeyHelp = (input, key) => {
525
+ if (key.ctrl && input === 'c') {
526
+ quit();
527
+ return;
528
+ }
529
+ state.help = false;
530
+ scheduleFlush();
531
+ };
422
532
  const onKeyNav = (input, key) => {
423
533
  // Ctrl-combos first: only Ctrl+C is meaningful (quit); swallow the rest so
424
534
  // Ctrl+L / Ctrl+J / Ctrl+Q etc. don't masquerade as l/j/q commands.
@@ -519,7 +629,7 @@ export async function runBrowse(opts = {}) {
519
629
  scheduleFlush();
520
630
  return;
521
631
  }
522
- if (input >= '1' && input <= '4') {
632
+ if (input >= '1' && input <= '5') {
523
633
  const idx = Number(input) - 1;
524
634
  if (idx < TABS.length) {
525
635
  state.tab = TABS[idx];
@@ -569,6 +679,49 @@ export async function runBrowse(opts = {}) {
569
679
  scheduleFlush();
570
680
  return;
571
681
  }
682
+ // Help overlay.
683
+ if (input === '?') {
684
+ state.help = true;
685
+ scheduleFlush();
686
+ return;
687
+ }
688
+ // Pin / unpin (local, view-only — works against a remote canvas too).
689
+ if (input === 'f') {
690
+ if (row !== undefined)
691
+ togglePin(row.id);
692
+ scheduleFlush();
693
+ return;
694
+ }
695
+ // Recenter on the launching node.
696
+ if (input === '.') {
697
+ recenterOnSelf();
698
+ scheduleFlush();
699
+ return;
700
+ }
701
+ // Rename the node inline (mutation — no-op on a remote canvas).
702
+ if (input === 'R') {
703
+ if (isRemote)
704
+ return;
705
+ if (row !== undefined) {
706
+ state.rename = true;
707
+ state.renameTarget = row.id;
708
+ state.renameBuf = rowOf(row.id)?.name ?? '';
709
+ }
710
+ scheduleFlush();
711
+ return;
712
+ }
713
+ // Message the node without resuming it (mutation — no-op on a remote canvas).
714
+ if (input === 'm') {
715
+ if (isRemote)
716
+ return;
717
+ if (row !== undefined) {
718
+ state.message = true;
719
+ state.messageTarget = row.id;
720
+ state.messageBuf = '';
721
+ }
722
+ scheduleFlush();
723
+ return;
724
+ }
572
725
  // Search. Starting a search ranks by relevance (decision) so the best prompt/
573
726
  // name match floats to the top as you type.
574
727
  if (input === '/') {
@@ -664,8 +817,14 @@ export async function runBrowse(opts = {}) {
664
817
  const onData = (data) => {
665
818
  try {
666
819
  const { input, key } = parseKeypress(data);
667
- if (state.search)
820
+ if (state.help)
821
+ onKeyHelp(input, key);
822
+ else if (state.search)
668
823
  onKeySearch(input, key);
824
+ else if (state.rename)
825
+ onKeyRename(input, key);
826
+ else if (state.message)
827
+ onKeyMessage(input, key);
669
828
  else
670
829
  onKeyNav(input, key);
671
830
  }
@@ -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. */