@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
@@ -52,7 +52,7 @@ const MAX_FRAME_BYTES = BROKER_READ_CAPS.maxLineBytes - 4 * 1024 * 1024;
52
52
  * see slash-commands.ts's REMOTE_SAFE_BUILTIN_NAMES gate). */
53
53
  const DRIVE_FRAME_TYPES = new Set([
54
54
  'prompt', 'steer', 'follow_up', 'abort', 'bash',
55
- 'set_model', 'cycle_model', 'cycle_thinking', 'set_thinking_level', 'dequeue',
55
+ 'set_model', 'cycle_model', 'cycle_ladder', 'cycle_thinking', 'set_thinking_level', 'dequeue',
56
56
  'set_auto_retry', 'set_auto_compaction', 'compact', 'new_session',
57
57
  'switch_session', 'fork', 'set_session_name', 'navigate_tree', 'reload',
58
58
  'export', 'clone', 'share', 'request_control', 'release_control', 'reload_auth',
@@ -64,6 +64,10 @@ export class InputController {
64
64
  hooks;
65
65
  /** The currently-rendered blocking dialog, if any (for supersede/dismiss). */
66
66
  dialog;
67
+ /** The request id of {@link dialog} — so a broker-driven `extension_ui_dismiss`
68
+ * tears down ONLY the matching overlay (correlated dismissal), never an
69
+ * unrelated one. */
70
+ dialogId;
67
71
  /** The currently-open native picker overlay, if any (for supersede/dismiss). */
68
72
  picker;
69
73
  /** Monotonic open counter — a slow read-op reply whose `seq` is no longer the
@@ -92,8 +96,11 @@ export class InputController {
92
96
  }
93
97
  /** Render extension UI requests from the broker. Blocking dialogs route their
94
98
  * answer back to the broker; non-blocking notify requests become the viewer's
95
- * normal notice line. Supersedes any dialog already on screen (e.g. a re-route
96
- * on control handoff) by dismissing it first. */
99
+ * normal notice line and NOTHING else a notify must never tear down an
100
+ * unrelated blocking dialog whose broker request is still pending. A dialog is
101
+ * torn down only by (a) a NEW blocking dialog superseding it (control handoff
102
+ * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
103
+ * from the broker (the request was aborted/timed out out-of-band). */
97
104
  attachDialog(req) {
98
105
  if (req.method === 'notify') {
99
106
  const prefix = req.notifyType === 'error' ? 'Error: ' : req.notifyType === 'warning' ? 'Warning: ' : '';
@@ -102,12 +109,26 @@ export class InputController {
102
109
  return;
103
110
  }
104
111
  this.dialog?.dismiss();
112
+ this.dialogId = req.id;
105
113
  this.dialog = renderDialog(this.tui, req, (resp) => {
106
114
  this.dialog = undefined;
115
+ this.dialogId = undefined;
107
116
  this.hooks.onDialogResponse(resp);
108
117
  }, this.keybindings);
109
118
  this.tui.requestRender();
110
119
  }
120
+ /** Broker-driven correlated dismissal: the broker resolved this request itself
121
+ * (its request was aborted out-of-band — e.g. a local OAuth loopback callback
122
+ * won the race against this manual-paste dialog — or its timeout fired). Tear
123
+ * down ONLY the overlay whose id matches; leave any other dialog untouched. */
124
+ dismissDialog(id) {
125
+ if (this.dialogId !== id)
126
+ return;
127
+ this.dialog?.dismiss();
128
+ this.dialog = undefined;
129
+ this.dialogId = undefined;
130
+ this.tui.requestRender();
131
+ }
111
132
  /** Feed the latest engine state so read-only commands (e.g. `/session`) report
112
133
  * current values. Optional; the controller works without it. */
113
134
  setState(state) {
@@ -174,6 +195,7 @@ export class InputController {
174
195
  onGraph: this.hooks.onGraph,
175
196
  onQuit: this.hooks.onQuit,
176
197
  onCopy: this.hooks.onCopy,
198
+ onColor: this.hooks.onColor,
177
199
  openModelPicker: () => void this.openModelPicker(),
178
200
  openSessionPicker: () => void this.openSessionPicker(),
179
201
  openForkPicker: () => void this.openForkPicker(),
@@ -182,6 +204,7 @@ export class InputController {
182
204
  openScopedModelsPicker: () => void this.openScopedModelsPicker(),
183
205
  openLoginPicker: this.hooks.openLoginPicker ? () => this.hooks.openLoginPicker() : undefined,
184
206
  openLogoutPicker: this.hooks.openLogoutPicker ? () => this.hooks.openLogoutPicker() : undefined,
207
+ onExplicitModelSet: this.hooks.onExplicitModelSet,
185
208
  };
186
209
  }
187
210
  // -------------------------------------------------------------------------
@@ -299,10 +322,20 @@ export class InputController {
299
322
  this.tui.requestRender();
300
323
  }
301
324
  openModelPicker() {
302
- return this.openPicker({ type: 'list_models' }, 'list_models', 'model', (data, close) => buildModelPicker(this.tui, data, this.send, close));
325
+ return this.openPicker({ type: 'list_models' }, 'list_models', 'model', (data, close) => buildModelPicker(this.tui, data, this.send, close, this.hooks.onExplicitModelSet));
303
326
  }
327
+ /** Unlike the other pickers, the session picker opens IMMEDIATELY — its
328
+ * component owns async loaders (with a Loading header), so the multi-second
329
+ * session-dir scan runs off the open path instead of blocking it. */
304
330
  openSessionPicker() {
305
- return this.openPicker({ type: 'list_sessions', scope: 'cwd' }, 'list_sessions', 'session', (data, close) => buildSessionPicker(this.tui, data, this.hooks.onRequest, this.keybindings, this.send, close));
331
+ const request = this.hooks.onRequest;
332
+ if (request === undefined) {
333
+ this.notify("The session picker isn't available in this viewer");
334
+ return Promise.resolve();
335
+ }
336
+ this.openSeq++; // supersede any in-flight fetch-first picker open
337
+ this.showPicker((controls) => buildSessionPicker(this.tui, this.state?.sessionFile, request, this.keybindings, this.send, controls.close));
338
+ return Promise.resolve();
306
339
  }
307
340
  openTreePicker() {
308
341
  return this.openPicker({ type: 'get_tree' }, 'get_tree', 'tree', (data, close) => buildTreePicker(this.tui, data, this.send, close));
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // Scope (v1): only ```mermaid fences. The wider termrender directive vocabulary
20
20
  // is deliberately out of scope — see the base prompt's Diagrams note.
21
- import { isRendererReady, renderMarkdown } from '@crouton-kit/humanloop';
21
+ import { ensureRenderer, isRendererReady, renderMarkdown } from '@crouton-kit/humanloop';
22
22
  /** A complete mermaid fence: ```mermaid (opening info string) ... closing ```.
23
23
  * Line-anchored (m flag), non-greedy body (captured). An UNCLOSED fence (still
24
24
  * streaming) does not match, so mid-stream text is left raw and only settles to
@@ -71,7 +71,10 @@ function stripBlankEdges(lines) {
71
71
  * diagrams have no width control and overflow rather than truncate), which is
72
72
  * stripped here, so an approximate pane width is fine. */
73
73
  export function transformMermaidFences(text, width) {
74
- if (!textHasMermaid(text) || !isRendererReady())
74
+ if (!textHasMermaid(text))
75
+ return text;
76
+ ensureRenderer();
77
+ if (!isRendererReady())
75
78
  return text;
76
79
  return text.replace(MERMAID_FENCE, (whole, body) => {
77
80
  try {
@@ -1,5 +1,5 @@
1
1
  import { type Component, type KeybindingsManager, type TUI } from '@earendil-works/pi-tui';
2
- import type { ClientToBroker, GetSettingsData, GetTreeData, ListModelsData, ListScopedModelsData, ListSessionsData } from '../../core/runtime/broker-protocol.js';
2
+ import type { ClientToBroker, GetSettingsData, GetTreeData, ListModelsData, ListScopedModelsData } from '../../core/runtime/broker-protocol.js';
3
3
  import type { ReadOpRequest } from './view-socket.js';
4
4
  /** A built picker overlay: the `component` to show, and the `focus` target the
5
5
  * caller must `setFocus` (the component itself, or the inner list that owns
@@ -28,12 +28,13 @@ type Notify = (message: string) => void;
28
28
  /** `/model` (and ctrl+l) — the real model selector with search + scope toggle.
29
29
  * Select → `set_model` with the resolved `provider/id` (the broker's
30
30
  * `findModelSpec` requires that form). */
31
- export declare function buildModelPicker(tui: TUI, data: ListModelsData, send: Send, close: Close): Picker;
32
- /** `/resume` — the real session selector. Reconstructs from async loaders that
33
- * fetch each scope on demand (cwd is pre-fetched so the current-session marker
34
- * shows immediately; the `all` scope loads only on toggle). Select
35
- * `switch_session`. Rename/delete are out of scope (no command frame). */
36
- export declare function buildSessionPicker(tui: TUI, prefetchedCwd: ListSessionsData, request: Request, keybindings: KeybindingsManager, send: Send, close: Close): Picker;
31
+ export declare function buildModelPicker(tui: TUI, data: ListModelsData, send: Send, close: Close, onSelect?: (model: string) => void): Picker;
32
+ /** `/resume` — the real session selector. Opens INSTANTLY and reconstructs from
33
+ * async loaders that fetch each scope on demand the component's own Loading
34
+ * header covers the scan (listing a large session dir takes seconds, so the
35
+ * fetch must stay off the open path). Select → `switch_session`. Rename/delete
36
+ * are out of scope (no command frame). */
37
+ export declare function buildSessionPicker(tui: TUI, currentSessionFile: string | undefined, request: Request, keybindings: KeybindingsManager, send: Send, close: Close): Picker;
37
38
  /** `/tree` — the real tree navigator (pure data). Select → `navigate_tree`.
38
39
  * Label-edit is out of scope (no command frame) → no `onLabelChange`. */
39
40
  export declare function buildTreePicker(tui: TUI, data: GetTreeData, send: Send, close: Close): Picker;
@@ -51,7 +51,7 @@ function modelRegistryAdapter(all, available) {
51
51
  /** `/model` (and ctrl+l) — the real model selector with search + scope toggle.
52
52
  * Select → `set_model` with the resolved `provider/id` (the broker's
53
53
  * `findModelSpec` requires that form). */
54
- export function buildModelPicker(tui, data, send, close) {
54
+ export function buildModelPicker(tui, data, send, close, onSelect) {
55
55
  const all = data.models;
56
56
  const availableSet = new Set(data.availableIds);
57
57
  const available = all.filter((m) => availableSet.has(`${m.provider}/${m.id}`));
@@ -60,29 +60,23 @@ export function buildModelPicker(tui, data, send, close) {
60
60
  : undefined;
61
61
  const component = new ModelSelectorComponent(tui, current, SettingsManager.inMemory(), modelRegistryAdapter(all, available), data.scopedModels, (model) => {
62
62
  send({ type: 'set_model', model: `${model.provider}/${model.id}` });
63
+ onSelect?.(`${model.provider}/${model.id}`);
63
64
  close();
64
65
  }, () => close());
65
66
  return { component, focus: component };
66
67
  }
67
- /** `/resume` — the real session selector. Reconstructs from async loaders that
68
- * fetch each scope on demand (cwd is pre-fetched so the current-session marker
69
- * shows immediately; the `all` scope loads only on toggle). Select
70
- * `switch_session`. Rename/delete are out of scope (no command frame). */
71
- export function buildSessionPicker(tui, prefetchedCwd, request, keybindings, send, close) {
68
+ /** `/resume` — the real session selector. Opens INSTANTLY and reconstructs from
69
+ * async loaders that fetch each scope on demand the component's own Loading
70
+ * header covers the scan (listing a large session dir takes seconds, so the
71
+ * fetch must stay off the open path). Select → `switch_session`. Rename/delete
72
+ * are out of scope (no command frame). */
73
+ export function buildSessionPicker(tui, currentSessionFile, request, keybindings, send, close) {
72
74
  const revive = (d) => d.sessions.map((s) => ({ ...s, created: new Date(s.created), modified: new Date(s.modified) }));
73
- let firstCwd = true;
74
- const currentLoader = async () => {
75
- if (firstCwd) {
76
- firstCwd = false;
77
- return revive(prefetchedCwd);
78
- }
79
- return revive((await request({ type: 'list_sessions', scope: 'cwd' })));
80
- };
81
- const allLoader = async () => revive((await request({ type: 'list_sessions', scope: 'all' })));
82
- const component = new SessionSelectorComponent(currentLoader, allLoader, (path) => {
75
+ const loader = (scope) => async () => revive((await request({ type: 'list_sessions', scope })));
76
+ const component = new SessionSelectorComponent(loader('cwd'), loader('all'), (path) => {
83
77
  send({ type: 'switch_session', path });
84
78
  close();
85
- }, () => close(), () => close(), () => tui.requestRender(), { showRenameHint: false, keybindings: keybindings }, prefetchedCwd.currentSessionFile);
79
+ }, () => close(), () => close(), () => tui.requestRender(), { showRenameHint: false, keybindings: keybindings }, currentSessionFile);
86
80
  return { component, focus: component };
87
81
  }
88
82
  /** `/tree` — the real tree navigator (pure data). Select → `navigate_tree`.
@@ -46,10 +46,19 @@ export interface SlashContext {
46
46
  openTreePicker?: () => void;
47
47
  openSettingsPicker?: () => void;
48
48
  openScopedModelsPicker?: () => void;
49
+ /** An EXPLICIT model choice was just issued via `/model <arg>` — mirrors
50
+ * `InputControllerHooks.onExplicitModelSet` (the picker path fires it from
51
+ * `buildModelPicker`). attach-cmd persists the choice as the node kind's
52
+ * default once the broker confirms with `model_changed`. */
53
+ onExplicitModelSet?: (model: string) => void;
49
54
  /** Open the native /login provider selector. */
50
55
  openLoginPicker?: () => void;
51
56
  /** Open the native /logout provider selector. */
52
57
  openLogoutPicker?: () => void;
58
+ /** `/color` — recolor the editor's name chip (the "bubble" /rename names).
59
+ * `null` clears back to the default chip. Wired by attach-cmd (it owns the
60
+ * TitledEditor); absent → only the tmux pane outline is painted. */
61
+ onColor?: (color: string | null) => void;
53
62
  }
54
63
  /** Canvas slash-commands — implemented NATIVE in the viewer, the ONLY live host:
55
64
  * the broker binds mode:'print', so broker pi-extensions never run their
@@ -37,6 +37,8 @@ export const CANVAS_SLASH_COMMANDS = [
37
37
  { name: 'resume-node', description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node' },
38
38
  { name: 'view', description: 'Open a view in a popup — bare for the picker, or /view <name> to open that view directly' },
39
39
  { name: 'context', description: 'Browse this node + its subscriptions’ context dirs in a read-only nvim popup' },
40
+ { name: 'rename', description: 'Rename this node everywhere — node row, viewer window, session chip, tmux pane: /rename <name>' },
41
+ { name: 'color', description: 'Color this node — tmux pane outline + name chip: /color <color> (red, blue, #rrggbb, …) or /color none' },
40
42
  ];
41
43
  const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
42
44
  /** Native canvas commands that shell a LOCAL `crtr` mutation or read against
@@ -46,7 +48,15 @@ const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
46
48
  * local node, `/context` opens local `crtr node inspect context`. `/graph`
47
49
  * (the in-process GRAPH overlay, already remote-safe — see graph-overlay.ts)
48
50
  * and `/view` (a self-contained popup unrelated to `nodeId`) stay available. */
49
- const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context']);
51
+ const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename']);
52
+ /** tmux named colours accepted by `/color` (plus `colourN`/`#rrggbb`, validated
53
+ * by pattern). Mirrors tmux's colour-name table so a typo is rejected with the
54
+ * options rather than silently ignored by tmux. */
55
+ const TMUX_NAMED_COLORS = new Set([
56
+ 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
57
+ 'brightred', 'brightgreen', 'brightyellow', 'brightblue', 'brightmagenta', 'brightcyan',
58
+ 'orange', 'grey', 'gray', 'default',
59
+ ]);
50
60
  /** Builtins with no Phase-4 engine method — scoped out (review m2). */
51
61
  const SCOPED_OUT = new Set(['trust']);
52
62
  /** Valid `/settings thinking` levels — the `SetThinkingLevelFrame['level']`
@@ -153,6 +163,7 @@ export function dispatchSlashCommand(text, ctx) {
153
163
  if (!arg)
154
164
  return openOrHint(ctx.openModelPicker, ctx, 'model', '/model <provider/id>');
155
165
  ctx.send({ type: 'set_model', model: arg });
166
+ ctx.onExplicitModelSet?.(arg);
156
167
  return true;
157
168
  case 'resume':
158
169
  if (!arg)
@@ -232,7 +243,7 @@ export function dispatchSlashCommand(text, ctx) {
232
243
  }
233
244
  /** Append the native canvas commands to a command list, skipping any already
234
245
  * present (so they survive whatever list Unit Q feeds in — builtins, or the
235
- * broker's `get_commands` result). The four canvas commands are viewer-owned,
246
+ * broker's `get_commands` result). The canvas commands are viewer-owned,
236
247
  * so they are always surfaced in autocomplete regardless of the source list —
237
248
  * EXCEPT `REMOTE_UNSAFE_CANVAS_NAMES` when `remote`, which are structurally
238
249
  * omitted rather than offered-then-blocked, so a remote attach's autocomplete
@@ -256,7 +267,7 @@ export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS, remo
256
267
  // ---------------------------------------------------------------------------
257
268
  // Canvas commands — native reimplementations of the canvas chrome extensions.
258
269
  // ---------------------------------------------------------------------------
259
- /** Dispatch one of the four native canvas commands. Always returns `true` (the
270
+ /** Dispatch one of the native canvas commands. Always returns `true` (the
260
271
  * command is viewer-owned — never forwarded to the engine as a prompt). */
261
272
  function dispatchCanvasCommand(name, arg, ctx) {
262
273
  // Structural omission (withCanvasCommands) keeps these out of remote-mode
@@ -269,10 +280,8 @@ function dispatchCanvasCommand(name, arg, ctx) {
269
280
  }
270
281
  switch (name) {
271
282
  case 'graph':
272
- // `/graph` is inherently the in-process GRAPH overlay (canvas-nav.ts
273
- // `toggleGraph`), NOT a tmux popup. The overlay is Unit C/Q's surface; this
274
- // unit registers the command + autocomplete entry and triggers the overlay
275
- // via the `onGraph` hook the integration unit wires in.
283
+ // `/graph` opens the attach viewer's in-process GRAPH overlay, not a tmux
284
+ // popup. Dispatch reaches it through the viewer integration's `onGraph` hook.
276
285
  if (ctx.onGraph)
277
286
  ctx.onGraph();
278
287
  else
@@ -293,6 +302,11 @@ function dispatchCanvasCommand(name, arg, ctx) {
293
302
  // A read-only nvim explorer over this node's + its subscriptions' context
294
303
  // dirs, as a self-contained popup.
295
304
  return openContext(ctx);
305
+ case 'rename':
306
+ renameNode(arg, ctx);
307
+ return true;
308
+ case 'color':
309
+ return colorNode(arg, ctx);
296
310
  default:
297
311
  ctx.notify(`/${name} is not available in attach`);
298
312
  return true;
@@ -351,6 +365,112 @@ function promoteNode(arg, ctx) {
351
365
  });
352
366
  });
353
367
  }
368
+ /** `/rename <name>` — rename this node EVERYWHERE the name shows (à la Claude
369
+ * Code's rename): (1) the pi session name, which is what the editor's title
370
+ * chip ("bubble") paints — sent as a `set_session_name` frame so the chip
371
+ * updates on the session_info_changed round-trip; (2) the tmux pane title
372
+ * (visible with pane-border-status / terminal titles); (3) the canvas node row
373
+ * via `crtr node config --name`, which also renames the live viewer window. */
374
+ function renameNode(arg, ctx) {
375
+ const nodeId = (ctx.nodeId ?? process.env['CRTR_NODE_ID'] ?? '').trim();
376
+ if (nodeId === '') {
377
+ ctx.notify('/rename: no node to rename (viewer has no node id)');
378
+ return;
379
+ }
380
+ if (arg === '') {
381
+ ctx.notify('Usage: /rename <name>');
382
+ return;
383
+ }
384
+ // 1. Session name → the editor title chip repaints when the state echoes back.
385
+ ctx.send({ type: 'set_session_name', name: arg });
386
+ // 2. tmux pane title (best-effort; no-op outside tmux).
387
+ const pane = process.env['TMUX_PANE'];
388
+ if (process.env['TMUX'] !== undefined && pane !== undefined && pane !== '') {
389
+ execFile('tmux', ['select-pane', '-t', pane, '-T', arg], () => {
390
+ /* best-effort */
391
+ });
392
+ }
393
+ // 3. Canvas node row + live viewer window.
394
+ execFile('crtr', ['node', 'config', '--node', nodeId, '--name', arg, '--json'], { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
395
+ if (err) {
396
+ // On a non-zero exit crtr still prints the structured error to stdout.
397
+ let msg = null;
398
+ try {
399
+ const payload = JSON.parse(stdout);
400
+ msg = typeof payload.message === 'string' ? payload.message : null;
401
+ }
402
+ catch {
403
+ msg = null;
404
+ }
405
+ ctx.notify(`rename failed: ${msg ?? err.message}`);
406
+ return;
407
+ }
408
+ ctx.notify(`Renamed node → ${arg}`);
409
+ });
410
+ }
411
+ /** Promisified one-shot tmux call — resolves stdout ('' on error; best-effort). */
412
+ function tmuxCall(args) {
413
+ return new Promise((resolve) => {
414
+ execFile('tmux', args, { timeout: 5_000 }, (err, stdout) => resolve(err ? '' : stdout));
415
+ });
416
+ }
417
+ /** Paint (or clear) THIS pane's border color. `pane-border-style` is a WINDOW
418
+ * option — tmux silently hoists a `set-option -p` to window scope, painting
419
+ * every pane — so per-pane color rides a per-pane user option instead: the
420
+ * color lands in `@crtr_color` on this pane only, and the window's border
421
+ * styles are set ONCE to a format tmux expands per pane (`fg=#{@crtr_color}`
422
+ * when set, else the style that was in effect before — baked in at install
423
+ * time so uncolored panes keep the user's theme). Clearing just unsets
424
+ * `@crtr_color`; the format's fallback branch restores the original look. */
425
+ async function paintPaneBorder(pane, value, clear) {
426
+ if (clear) {
427
+ await tmuxCall(['set-option', '-p', '-u', '-t', pane, '@crtr_color']);
428
+ return;
429
+ }
430
+ await tmuxCall(['set-option', '-p', '-t', pane, '@crtr_color', value]);
431
+ for (const opt of ['pane-border-style', 'pane-active-border-style']) {
432
+ const cur = (await tmuxCall(['show-options', '-wqv', '-t', pane, opt])).trim();
433
+ if (cur.includes('@crtr_color'))
434
+ continue; // our format is already installed on this window
435
+ // Bake the pre-existing effective style into the fallback branch (window
436
+ // value, else global, else tmux's defaults) — commas escaped so they don't
437
+ // split the #{?,,} conditional.
438
+ let base = cur !== '' ? cur : (await tmuxCall(['show-options', '-gqv', opt])).trim();
439
+ if (base === '')
440
+ base = opt === 'pane-active-border-style' ? 'fg=green' : 'default';
441
+ const fmt = `#{?#{!=:#{@crtr_color},},fg=#{@crtr_color},${base.replace(/,/g, '#,')}}`;
442
+ await tmuxCall(['set-option', '-w', '-t', pane, opt, fmt]);
443
+ }
444
+ }
445
+ /** `/color <color>` — color this node's two identity surfaces in one stroke:
446
+ * the tmux PANE OUTLINE (via `paintPaneBorder` above — per-pane, not the whole
447
+ * window) and the editor's NAME CHIP (the bubble `/rename` names), via the
448
+ * `onColor` hook attach-cmd wires. Ephemeral by design: the border rides the
449
+ * tmux pane and the chip lives in this viewer process. `/color none` clears
450
+ * both. Remote-SAFE: both surfaces are local to this viewer. */
451
+ function colorNode(arg, ctx) {
452
+ const value = arg.trim().toLowerCase();
453
+ if (value === '') {
454
+ ctx.notify('Usage: /color <color> — a name (red, blue, orange…), colourN, or #rrggbb; /color none to clear');
455
+ return true;
456
+ }
457
+ const clear = value === 'none' || value === 'clear';
458
+ const valid = clear || TMUX_NAMED_COLORS.has(value) || /^colour\d{1,3}$/.test(value) || /^#[0-9a-f]{6}$/.test(value);
459
+ if (!valid) {
460
+ ctx.notify(`Invalid color "${value}" — use a tmux colour name (${['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'orange'].join(', ')}, …), colourN, or #rrggbb`);
461
+ return true;
462
+ }
463
+ // Pane outline — per-pane @crtr_color + a per-pane-expanded window format,
464
+ // so only THIS node's pane recolors (fire-and-forget; best-effort).
465
+ const pane = process.env['TMUX_PANE'];
466
+ const inTmux = process.env['TMUX'] !== undefined && pane !== undefined && pane !== '';
467
+ if (inTmux)
468
+ void paintPaneBorder(pane, value, clear);
469
+ // Name chip — the viewer owns the editor, so this rides the onColor hook.
470
+ ctx.onColor?.(clear ? null : value);
471
+ ctx.notify(clear ? 'Node color cleared' : `Node colored ${value}${inTmux ? '' : ' (chip only — no tmux pane)'}`);
472
+ return true;
473
+ }
354
474
  /** `/resume-node`. Opens `crtr canvas browse` as a tmux popup,
355
475
  * scoped to this node's cwd; on Enter it focuses the chosen node back into this
356
476
  * pane via `crtr node focus --pane` (which `canvas browse` shells). */
@@ -1,7 +1,12 @@
1
1
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
2
2
  import { type EditorOptions, type EditorTheme, type TUI } from '@earendil-works/pi-tui';
3
3
  /** Thinking levels pi cycles through (shift+tab), lowest → highest budget. */
4
- export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
4
+ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
5
+ /** Build a title-chip styler for a `/color` token (a tmux colour name, `#rrggbb`,
6
+ * or `colourN`): the colour as the chip BACKGROUND with contrast-picked bold
7
+ * text, matching the thinking-chip treatment. `undefined` for an unrenderable
8
+ * token (e.g. `default`) — the caller keeps its fallback style. */
9
+ export declare function colorChipStyle(token: string): ((s: string) => string) | undefined;
5
10
  /** The default (thinking `off`) title chip: reverse video + a space of padding
6
11
  * each side, so the name reads as a label sitting on the border rule. Used as
7
12
  * the fallback when no thinking color applies. */
@@ -20,6 +25,7 @@ export declare function thinkingTitleStyle(level: string | undefined, fallback:
20
25
  * and truncates when it only partially fits. Exported pure for the overflow
21
26
  * regression test. */
22
27
  export declare function composeTopBorder(width: number, title: string, info: string, titleStyle: (s: string) => string, borderColor: (s: string) => string): string;
28
+ export declare function outlineCursorLine(line: string): string | undefined;
23
29
  export declare class TitledEditor extends CustomEditor {
24
30
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
25
31
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -51,5 +57,10 @@ export declare class TitledEditor extends CustomEditor {
51
57
  titleStyle: (s: string) => string;
52
58
  /** Mode badge colorizer, matched to the current mode type. */
53
59
  modeStyle: (s: string) => string;
60
+ /** Whether the hosting tmux pane / terminal window has FOCUS (terminal focus
61
+ * reporting, DECSET 1004 — attach-cmd flips this on `\x1b[I` / `\x1b[O`).
62
+ * Distinct from TUI component focus: the editor keeps component focus while
63
+ * its pane is blurred. False → the fake cursor renders as an outline. */
64
+ paneFocused: boolean;
54
65
  render(width: number): string[];
55
66
  }
@@ -6,16 +6,16 @@
6
6
  // on each state update), mirroring pi's `getThinkingBorderColor`.
7
7
  // Both are pure render-layer chrome; nothing here touches the socket or session.
8
8
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
- import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
- /** The thinking color scale is a deliberate cool→warm ramp between a blue and
11
- * its orange complement, evenly spaced across the five active levels so each
12
- * step stays readable without jumping through unrelated hues. `off` falls
13
- * back to the caller's default border color. The endpoints are deliberately
14
- * deep (not bright) hues: the same colors double as the title-chip BACKGROUND
15
- * under bold white text, so every step must clear ~4.5:1 white-on-color
16
- * contrast — a lighter blue/orange washes the label out. */
9
+ import { CURSOR_MARKER, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
+ /** The standard thinking levels form a deliberate cool→warm ramp between blue
11
+ * and its orange complement. `max` is a distinct, intense cyan endpoint so it
12
+ * never falls back to the neutral default border. `off` alone falls back to
13
+ * the caller's default border color. The colors double as title-chip
14
+ * BACKGROUNDS under bold white text, so each must remain dark enough for the
15
+ * label to read clearly. */
17
16
  const THINKING_START = [46, 92, 190]; // deep blue
18
17
  const THINKING_END = [166, 98, 24]; // deep amber
18
+ const THINKING_MAX = [0, 145, 170]; // intense cyan
19
19
  function interpolateRgb(start, end, t) {
20
20
  return [
21
21
  Math.round(start[0] + (end[0] - start[0]) * t),
@@ -30,7 +30,55 @@ const THINKING_RGB = {
30
30
  medium: interpolateRgb(THINKING_START, THINKING_END, 2 / 4),
31
31
  high: interpolateRgb(THINKING_START, THINKING_END, 3 / 4),
32
32
  xhigh: interpolateRgb(THINKING_START, THINKING_END, 4 / 4),
33
+ max: THINKING_MAX,
33
34
  };
35
+ /** Named colours `/color` accepts, as RGB — mirrors the tmux colour-name table
36
+ * (so the chip and the pane border read as the same hue). */
37
+ const NAMED_RGB = {
38
+ black: [0, 0, 0],
39
+ red: [205, 49, 49],
40
+ green: [13, 188, 121],
41
+ yellow: [229, 229, 16],
42
+ blue: [36, 114, 200],
43
+ magenta: [188, 63, 188],
44
+ cyan: [17, 168, 205],
45
+ white: [229, 229, 229],
46
+ orange: [228, 152, 68],
47
+ grey: [128, 128, 128],
48
+ gray: [128, 128, 128],
49
+ brightred: [241, 76, 76],
50
+ brightgreen: [35, 209, 139],
51
+ brightyellow: [245, 245, 67],
52
+ brightblue: [59, 142, 234],
53
+ brightmagenta: [214, 112, 214],
54
+ brightcyan: [41, 184, 219],
55
+ };
56
+ /** Build a title-chip styler for a `/color` token (a tmux colour name, `#rrggbb`,
57
+ * or `colourN`): the colour as the chip BACKGROUND with contrast-picked bold
58
+ * text, matching the thinking-chip treatment. `undefined` for an unrenderable
59
+ * token (e.g. `default`) — the caller keeps its fallback style. */
60
+ export function colorChipStyle(token) {
61
+ const t = token.trim().toLowerCase();
62
+ const c256 = /^colour(\d{1,3})$/.exec(t);
63
+ if (c256 !== null)
64
+ return (s) => `\x1b[48;5;${c256[1]}m\x1b[97m\x1b[1m ${s} \x1b[0m`;
65
+ let rgb;
66
+ const hex = /^#([0-9a-f]{6})$/.exec(t);
67
+ if (hex !== null) {
68
+ const h = hex[1];
69
+ rgb = [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
70
+ }
71
+ else {
72
+ rgb = NAMED_RGB[t];
73
+ }
74
+ if (rgb === undefined)
75
+ return undefined;
76
+ const [r, g, b] = rgb;
77
+ // Pick black-on-light / white-on-dark so the name stays readable on any hue.
78
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
79
+ const fg = luma > 160 ? '\x1b[30m' : '\x1b[97m';
80
+ return (s) => `\x1b[48;2;${r};${g};${b}m${fg}\x1b[1m ${s} \x1b[0m`;
81
+ }
34
82
  /** The default (thinking `off`) title chip: reverse video + a space of padding
35
83
  * each side, so the name reads as a label sitting on the border rule. Used as
36
84
  * the fallback when no thinking color applies. */
@@ -71,6 +119,39 @@ export function composeTopBorder(width, title, info, titleStyle, borderColor) {
71
119
  const fill = Math.max(0, width - chipW - visibleWidth(infoChip));
72
120
  return chip + borderColor('─'.repeat(fill)) + infoChip;
73
121
  }
122
+ /** The stock pi-tui editor paints its fake cursor as a solid reverse-video
123
+ * block (`\x1b[7m…\x1b[0m`) unconditionally — in a wall of attach panes every
124
+ * pane shows an identical solid white cursor, and nothing tells you which one
125
+ * keys actually go to. When the hosting pane/terminal is UNFOCUSED we rewrite
126
+ * that cell into a MUTED cursor, mirroring how real terminals de-emphasize
127
+ * the hardware cursor on blur. A pixel-true full-cell outline isn't possible
128
+ * in text cells, and hollow-box glyphs (▯/⍘/□) render undersized, so the
129
+ * muted form is a full-size block in a dim gray background — same footprint
130
+ * as the focused cursor, clearly not the bright active one (ruling: tried the
131
+ * glyphs, Silas preferred the dim block). The rewrite anchors on pi-tui's
132
+ * zero-width CURSOR_MARKER (emitted right before the fake cursor while the
133
+ * editor component has TUI focus) so it can never touch reverse video
134
+ * elsewhere in the line. Returns undefined when the line carries no cursor.
135
+ * Exported pure for tests. */
136
+ const SOLID_CURSOR_PREFIX = '\x1b[7m';
137
+ const SOLID_CURSOR_RESET = '\x1b[0m';
138
+ /** Dim gray bg, default fg — the char under the cursor stays readable. */
139
+ const MUTED_CURSOR_ON = '\x1b[48;5;240m';
140
+ export function outlineCursorLine(line) {
141
+ const m = line.indexOf(CURSOR_MARKER);
142
+ if (m === -1)
143
+ return undefined;
144
+ const start = m + CURSOR_MARKER.length;
145
+ if (!line.startsWith(SOLID_CURSOR_PREFIX, start))
146
+ return undefined;
147
+ const gStart = start + SOLID_CURSOR_PREFIX.length;
148
+ const end = line.indexOf(SOLID_CURSOR_RESET, gStart);
149
+ if (end === -1)
150
+ return undefined;
151
+ const grapheme = line.slice(gStart, end);
152
+ const replacement = `${MUTED_CURSOR_ON}${grapheme}\x1b[0m`;
153
+ return line.slice(0, start) + replacement + line.slice(end + SOLID_CURSOR_RESET.length);
154
+ }
74
155
  export class TitledEditor extends CustomEditor {
75
156
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
76
157
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -114,8 +195,22 @@ export class TitledEditor extends CustomEditor {
114
195
  titleStyle = defaultTitleStyle;
115
196
  /** Mode badge colorizer, matched to the current mode type. */
116
197
  modeStyle = defaultTitleStyle;
198
+ /** Whether the hosting tmux pane / terminal window has FOCUS (terminal focus
199
+ * reporting, DECSET 1004 — attach-cmd flips this on `\x1b[I` / `\x1b[O`).
200
+ * Distinct from TUI component focus: the editor keeps component focus while
201
+ * its pane is blurred. False → the fake cursor renders as an outline. */
202
+ paneFocused = true;
117
203
  render(width) {
118
204
  const lines = super.render(width);
205
+ if (!this.paneFocused) {
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const hollowed = outlineCursorLine(lines[i]);
208
+ if (hollowed !== undefined) {
209
+ lines[i] = hollowed;
210
+ break;
211
+ }
212
+ }
213
+ }
119
214
  if ((this.title || this.info) && lines.length > 0) {
120
215
  // Replace the stock top border; dashes in the current (thinking-aware)
121
216
  // border color.
@@ -1,6 +1,6 @@
1
1
  // `crtr canvas browse` — the interactive full-screen canvas navigator.
2
2
  //
3
- // A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Flagged), an
3
+ // A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Attention/Pinned), an
4
4
  // auto-collapsed tree, and `/` fuzzy search. Enter resumes the chosen node via
5
5
  // `crtr node focus` (the ONLY sanctioned open — reviveNode, never `pi --session`).
6
6
  // Owns the screen, so it returns void and writes nothing to stdout itself.
@@ -14,7 +14,7 @@ import { readState } from '../core/config.js';
14
14
  export const browseLeaf = defineLeaf({
15
15
  name: 'browse',
16
16
  description: 'open the interactive canvas navigator (tabs/tree/search)',
17
- whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Flagged), an expandable tree (children auto-collapsed; → to expand), and `/` fuzzy search that auto-expands ancestors of matches; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
17
+ whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Attention/Pinned), an expandable tree (children auto-collapsed; → to expand), `/` fuzzy search, `f` to pin, `m` to message and `R` to rename a node in place, and `?` for the full keymap; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
18
18
  help: {
19
19
  name: 'canvas browse',
20
20
  summary: 'interactive full-screen canvas navigator — tabs, an auto-collapsed tree, and `/` fuzzy search; Enter resumes the chosen node via `crtr node focus`. Outside a TTY it prints the static forest and exits',
@@ -9,7 +9,7 @@ import { appendInbox } from '../../core/feed/inbox.js';
9
9
  import { closeSync, existsSync, linkSync, openSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
- import { inbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
12
+ import { openInbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
13
13
  import { killPane } from './shared.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // stranded-answer healing
@@ -158,8 +158,8 @@ export const humanInbox = defineLeaf({
158
158
  ],
159
159
  },
160
160
  run: async () => {
161
- await inbox([interactionsRoot(process.cwd())]);
162
- // humanloop's inbox() writes response.json but never calls pushFinal — so a
161
+ await openInbox({ roots: [interactionsRoot(process.cwd())] });
162
+ // humanloop's openInbox() writes response.json but never calls pushFinal — so a
163
163
  // deck drained here would strand (no answer-back, leaked bridge node). Heal
164
164
  // every resolved-but-live interaction: deliver its answer to the asker + reap.
165
165
  const delivered = await healStrandedInteractions(process.cwd());
@@ -766,7 +766,6 @@ export const humanRun = defineLeaf({
766
766
  try {
767
767
  const res = await launchReview(rc.file, {
768
768
  output: rc.output,
769
- noTmux: true,
770
769
  });
771
770
  await pushFinal(rc.job_id, JSON.stringify(res));
772
771
  }