@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +129 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +196 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +266 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +178 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +334 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +105 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +24 -12
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +26 -12
  176. package/dist/core/runtime/launch.js +78 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +39 -1
  184. package/dist/core/runtime/nodes.js +69 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -1,68 +1,84 @@
1
1
  // canvas-nav.ts — pi extension for pi-native canvas agent nodes.
2
2
  //
3
- // Renders a navigable "spine" of the node graph as chrome around the editor.
4
- // The editor itself is "you"; the three lanes are your neighbours, stacked so
5
- // the spine reads top→bottom (managers · peers · you · reports):
3
+ // A BASE/GRAPH state machine drawn as chrome around the editor. The editor is
4
+ // "you" (this node); the chrome shows your place in the canvas graph.
6
5
  //
7
- // ABOVE EDITOR crtr-asks ⚑ N waiting (only when N > 0)
8
- // ABOVE EDITOR crtr-managers ↑ managers <name> ● … (or ↑ (root))
9
- // ABOVE EDITOR crtr-siblings ↔ peers <name> ○ … (omitted when none)
10
- // ───────────── EDITOR (you) ─────────────
11
- // BELOW EDITOR crtr-reports ↓ reports <name> ○ … · ctx <k>
6
+ // BASE (default, passive) a vertical stack: your manager above the editor,
7
+ // your live reports below it. Captures NO keys; typing is never touched.
12
8
  //
13
- // Navigation (only on an EMPTY editor, so composing is never disturbed):
14
- // Alt+kmanagers (up) Alt+j reports (down)
15
- // Alt+h / Alt+l peers (left / right)
16
- // focus the selected node · esc clears the selection
17
- // Selection is shown by weight + a caret NOT the status dot (which encodes
18
- // active / idle / done ✓ / dead ✗) and not colour alone, so it reads under
19
- // NO_COLOR and on any background.
9
+ // GRAPH (modal, opt-in) a NERDTree-style tree of your local graph (ancestry
10
+ // rootyou → your subtree, with peers) drawn into one tall widget.
11
+ // While in GRAPH the extension consumes EVERY key and interprets it:
12
+ // j/k move · h/l fold · g/G top/bottom · ↵ focus · m focus manager ·
13
+ // e expand→tmux · x kill (y/n confirm) · esc back to BASE
14
+ // plus any user-defined graphBinds (additive; built-ins are reserved).
20
15
  //
21
- // INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
16
+ // Enter/leave GRAPH with the `/graph` slash command, the `prefixKey` shortcut
17
+ // (default alt+g, configurable), or the tmux alt+c menu's `g` item. Inside tmux
18
+ // alt+c is a tmux display-menu (not a pi key), so prefix chords (m/e/1-9/custom)
19
+ // are tmux menu items that route through `crtr canvas chord`.
22
20
  //
23
- // Refresh triggers:
24
- // session_start initial paint once we have a ctx.ui handle
25
- // turn_end — statuses may have changed during the turn
26
- // • background timer (ASK_POLL_MS) — polls `crtr canvas attention count` and
27
- // repaints whenever the count changes
21
+ // Two selection signals, both NO_COLOR-safe:
22
+ // SELF row = reverse video (ESC[7m), full width — an attribute, not a color.
23
+ // CURSOR = + bold on the row. Status stays on the colored dot.
28
24
  //
29
- // Double-timer prevention (copied from canvas-inbox-watcher):
30
- // `liveTimer` is module-level. A /reload re-enters this factory and clears
31
- // the previous interval before starting a new one — exactly one timer lives.
25
+ // ⚑K pending-asks is PER-NODE, inline on each waiting node's own row (manager,
26
+ // reports, tree rows; self shows a trailing line in BASE). ⤳M direct-children
27
+ // badge shows only on orchestrator rows.
28
+ //
29
+ // INERT when CRTR_NODE_ID is absent (a plain pi session or legacy job agent).
32
30
  //
33
31
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles
34
32
  // inside crouter's own tsc build without a dep on the pi packages.
35
33
  import { execFile, execFileSync } from 'node:child_process';
36
34
  import { existsSync, readFileSync } from 'node:fs';
37
35
  import { join } from 'node:path';
38
- import { getNode, subscribersOf, subscriptionsOf, jobDir } from '../core/canvas/index.js';
36
+ import { getNode, subscribersOf, subscriptionsOf, jobDir, fullName } from '../core/canvas/index.js';
37
+ import { readConfig } from '../core/config.js';
39
38
  // ---------------------------------------------------------------------------
40
- // Module-level state — persist across /reload to prevent stacking
39
+ // Module-level state — persists across /reload so guards don't stack and fold
40
+ // state / current view survive a hot-swap.
41
41
  // ---------------------------------------------------------------------------
42
42
  /** The one live background timer. Cleared and replaced on every re-registration. */
43
43
  let liveTimer;
44
44
  /** The one live onTerminalInput unsubscribe. Cleared/replaced on /reload so
45
45
  * exactly one key tap exists (mirrors the liveTimer double-guard). */
46
46
  let liveUnsub;
47
- /** Last-known ask count — cached across renders so the UI stays cheap. */
48
- let cachedAskCount = 0;
47
+ let view = 'base';
48
+ /** Fold state — node ids whose children are hidden in GRAPH. Survives renders
49
+ * AND BASE↔GRAPH toggles. Keyed by id so a topology change never corrupts it;
50
+ * stale ids are harmless (ignored when absent). */
51
+ const collapsed = new Set();
52
+ /** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
53
+ let cursorId;
54
+ /** GRAPH viewport scroll offset (row index of the top visible row). */
55
+ let scrollTop = 0;
56
+ /** Transient y/n confirm gate inside GRAPH (kill / confirm-binds). */
57
+ let pendingConfirm;
58
+ /** Per-node pending-ask counts, refreshed by the timer; renders read this. */
59
+ let asksMap = {};
49
60
  // ---------------------------------------------------------------------------
50
61
  // Tuning constants
51
62
  // ---------------------------------------------------------------------------
52
- const ASK_POLL_MS = 5_000; // how often to shell out for ask count
53
- const RENDER_DEBOUNCE_MS = 150; // coalesce rapid turn_end bursts
63
+ const ASK_POLL_MS = 5_000;
64
+ const RENDER_DEBOUNCE_MS = 150;
65
+ /** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
66
+ * array widget; anything beyond it pi truncates with its own "... (widget
67
+ * truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
68
+ const PI_MAX_WIDGET_LINES = 10;
69
+ const VIEWPORT_FALLBACK_ROWS = 30;
54
70
  // ---------------------------------------------------------------------------
55
- // ANSI styling. pi wraps widget string[] lines in Text components that render
56
- // embedded escapes (the same path used internally for theme.fg(...)), and it
57
- // measures width with an ANSI-aware visibleWidth so raw escapes are safe here
58
- // and need no pi-tui dependency. Selection uses theme-agnostic attributes
59
- // (bold/dim weight + a ▸ caret) so it pops on any terminal; status uses the
60
- // standard 8 colors on the dot, which read on both light and dark backgrounds.
71
+ // ANSI styling. pi renders embedded escapes in widget lines and measures width
72
+ // ANSI-aware, so raw escapes are safe and need no pi-tui dependency. Selection
73
+ // uses theme-agnostic ATTRIBUTES (reverse / bold), never colour alone, so it
74
+ // reads under NO_COLOR and on any background; status uses the standard 8 colors
75
+ // on the dot only.
61
76
  // ---------------------------------------------------------------------------
62
77
  const ESC = '\x1b[';
63
78
  const RESET = `${ESC}0m`;
64
79
  const BOLD = `${ESC}1m`;
65
80
  const DIM = `${ESC}2m`;
81
+ const REVERSE = `${ESC}7m`;
66
82
  const GREEN = `${ESC}32m`;
67
83
  const RED = `${ESC}31m`;
68
84
  const YELLOW = `${ESC}33m`;
@@ -77,12 +93,10 @@ function coloredGlyph(node) {
77
93
  case 'idle': return `${GRAY}○${RESET}`;
78
94
  case 'done': return `${CYAN}✓${RESET}`;
79
95
  case 'dead': return `${RED}✗${RESET}`;
96
+ case 'canceled': return `${YELLOW}⊘${RESET}`;
80
97
  default: return '?';
81
98
  }
82
99
  }
83
- // ---------------------------------------------------------------------------
84
- // ANSI-aware truncation — single-row, no pi-tui dep
85
- // ---------------------------------------------------------------------------
86
100
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
87
101
  /** Visible width, ignoring ANSI escapes. */
88
102
  function visibleWidth(s) {
@@ -91,7 +105,7 @@ function visibleWidth(s) {
91
105
  /** Truncate to `max` VISIBLE columns: escape sequences are copied through
92
106
  * verbatim (so a cut never lands mid-escape) and the result always ends in
93
107
  * RESET, so a clipped style can't bleed into the editor below. */
94
- function truncate(s, max = 180) {
108
+ function truncate(s, max = fillWidth()) {
95
109
  if (visibleWidth(s) <= max)
96
110
  return s;
97
111
  let out = '';
@@ -112,6 +126,27 @@ function truncate(s, max = 180) {
112
126
  }
113
127
  return `${out}…${RESET}`;
114
128
  }
129
+ /** Visible columns available to ONE widget line — the cap for every line we
130
+ * emit, and the width a full-width reverse-video SELF bar fills to.
131
+ *
132
+ * pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
133
+ * a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
134
+ * content width is `columns - 2` (a 1-col margin on each side). A line wider
135
+ * than that wraps, and the overflow spills onto a second row as a stray
136
+ * reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
137
+ function fillWidth() {
138
+ return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
139
+ }
140
+ /** Wrap `content` in a full-width reverse-video bar. REVERSE is re-asserted
141
+ * after every embedded RESET so a colored cell (the status dot) doesn't punch
142
+ * a hole in the bar; the visible width is padded out to `width`; the line
143
+ * closes with a real RESET. */
144
+ function reverseFill(content, width) {
145
+ const clipped = truncate(content, width);
146
+ const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${REVERSE}`);
147
+ const pad = Math.max(0, width - visibleWidth(clipped));
148
+ return `${REVERSE}${reasserted}${' '.repeat(pad)}${RESET}`;
149
+ }
115
150
  function readTelemetry(nodeId) {
116
151
  try {
117
152
  const p = join(jobDir(nodeId), 'telemetry.json');
@@ -126,138 +161,189 @@ function readTelemetry(nodeId) {
126
161
  function fmtTokens(n) {
127
162
  return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
128
163
  }
164
+ function tokensCell(id) {
165
+ return fmtTokens(readTelemetry(id).tokens_in ?? 0);
166
+ }
167
+ function shortId(id) {
168
+ return id.slice(0, 8);
169
+ }
129
170
  // ---------------------------------------------------------------------------
130
- // Ask countshells out synchronously with a tight timeout so the timer
131
- // callback is cheap (< 2 s). Result is cached; the UI reads only the cache.
171
+ // Per-node ask counts ONE shell-out per poll. `crtr canvas attention map`
172
+ // buckets a whole sub-DAG's pending asks by node in a single process, so the
173
+ // timer stays cheap (< 2 s) regardless of how many nodes are visible. --json
174
+ // gives a parseable {counts} blob (the default render is XML chrome).
132
175
  // ---------------------------------------------------------------------------
133
- function fetchAskCount(nodeId) {
176
+ function fetchAsksMap(rootId) {
134
177
  try {
135
- const raw = execFileSync('crtr', ['canvas', 'attention', 'count', '--node', nodeId], {
136
- timeout: 2_000,
178
+ const raw = execFileSync('crtr', ['canvas', 'attention', 'map', '--view', rootId, '--json'], {
179
+ timeout: 2_500,
137
180
  encoding: 'utf8',
138
181
  });
139
182
  const parsed = JSON.parse(raw.trim());
140
- return typeof parsed.count === 'number' ? parsed.count : 0;
183
+ return parsed.counts ?? {};
141
184
  }
142
185
  catch {
143
- return 0;
186
+ return {};
144
187
  }
145
188
  }
146
- function toNeighbor(refId) {
147
- const node = getNode(refId);
148
- return { id: refId, name: node?.name ?? refId.slice(0, 8), node };
149
- }
150
- /** Managers — who this node reports up to (the UP direction). */
151
- function managersOf(nodeId) {
189
+ // ---------------------------------------------------------------------------
190
+ // Graph queries (dependency-free, straight off the canvas db)
191
+ // ---------------------------------------------------------------------------
192
+ /** First manager (by created) — the UP step for the ancestry spine. */
193
+ function managerOf(id) {
152
194
  try {
153
- return subscribersOf(nodeId).map((ref) => toNeighbor(ref.node_id));
195
+ return subscribersOf(id)[0]?.node_id;
154
196
  }
155
197
  catch {
156
- return [];
198
+ return undefined;
157
199
  }
158
200
  }
159
- /** Live reports — children (the DOWN direction). Finished/dead workers fall
160
- * off: a terminal agent that's done its job no longer needs a chrome slot. */
161
- function reportsOf(nodeId) {
201
+ /** Live reports (active|idle) of a node — the DOWN set in BASE. */
202
+ function liveReports(id) {
162
203
  try {
163
- return subscriptionsOf(nodeId)
164
- .map((ref) => toNeighbor(ref.node_id))
165
- .filter((n) => n.node?.status === 'active' || n.node?.status === 'idle');
204
+ return subscriptionsOf(id)
205
+ .map((s) => s.node_id)
206
+ .filter((cid) => {
207
+ const st = getNode(cid)?.status;
208
+ return st === 'active' || st === 'idle';
209
+ });
166
210
  }
167
211
  catch {
168
212
  return [];
169
213
  }
170
214
  }
171
- /** Peersother live reports of this node's managers (the SIDE direction):
172
- * nodes that share a manager with us, minus ourselves. Deduped across multiple
173
- * managers; like reports, only active/idle peers earn a chrome slot. */
174
- function siblingsOf(nodeId) {
215
+ /** All direct children (edges) used for the badge and fold counts. */
216
+ function childCount(id) {
175
217
  try {
176
- const seen = new Set([nodeId]);
177
- const out = [];
178
- for (const mgr of subscribersOf(nodeId)) {
179
- for (const ref of subscriptionsOf(mgr.node_id)) {
180
- if (seen.has(ref.node_id))
181
- continue;
182
- seen.add(ref.node_id);
183
- const nb = toNeighbor(ref.node_id);
184
- if (nb.node?.status === 'active' || nb.node?.status === 'idle')
185
- out.push(nb);
186
- }
187
- }
188
- return out;
218
+ return subscriptionsOf(id).length;
189
219
  }
190
220
  catch {
191
- return [];
221
+ return 0;
192
222
  }
193
223
  }
194
- const FOCUS_HINT = `${DIM} ↵ focus · esc cancel${RESET}`;
195
- /** Dim, fixed-width lane label so the slot columns line up across rows. */
196
- function laneLabel(glyph, word) {
197
- return `${DIM}${glyph} ${word.padEnd(8)}${RESET}`;
224
+ /** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
225
+ function climbRoot(self) {
226
+ let cur = self;
227
+ const seen = new Set([cur]);
228
+ for (;;) {
229
+ const mgr = managerOf(cur);
230
+ if (mgr === undefined || seen.has(mgr))
231
+ break;
232
+ seen.add(mgr);
233
+ cur = mgr;
234
+ }
235
+ return cur;
198
236
  }
199
- /** One neighbor slot. Selection is carried by WEIGHT + a caret — never by the
200
- * status dot (it already encodes active/idle/done/dead) and never by colour
201
- * alone, so it reads under NO_COLOR and on any background:
202
- * selected → `▸ name ●` bold, leading caret
203
- * unselected ` name ●` dim, caret column reserved (no horizontal jitter)
204
- * The trailing glyph stays status-colored in both states. */
205
- function slot(n, selected) {
206
- const glyph = coloredGlyph(n.node);
207
- if (selected)
208
- return `${BOLD}▸ ${n.name}${RESET} ${glyph}`;
209
- return `${DIM} ${n.name}${RESET} ${glyph}`;
237
+ /** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
238
+ function subtreeIds(root) {
239
+ const out = [];
240
+ const seen = new Set([root]);
241
+ const q = subscriptionsOf(root).map((s) => s.node_id);
242
+ while (q.length > 0) {
243
+ const id = q.shift();
244
+ if (seen.has(id))
245
+ continue;
246
+ seen.add(id);
247
+ out.push(id);
248
+ for (const s of subscriptionsOf(id))
249
+ if (!seen.has(s.node_id))
250
+ q.push(s.node_id);
251
+ }
252
+ return out;
210
253
  }
211
- /** Join one lane's slots, marking the selected index and emitting the focus
212
- * hint only when this lane actually holds the selection. */
213
- function laneSlots(neighbors, selIdx) {
214
- const body = neighbors.map((n, i) => slot(n, i === selIdx)).join(' ');
215
- return { body, hint: selIdx >= 0 ? FOCUS_HINT : '' };
254
+ // ---------------------------------------------------------------------------
255
+ // Shared cell builders
256
+ // ---------------------------------------------------------------------------
257
+ /** ⤳M direct-children badge only on orchestrator rows. */
258
+ function childBadge(node) {
259
+ if (node === null || node.mode !== 'orchestrator')
260
+ return '';
261
+ const m = childCount(node.node_id);
262
+ return m > 0 ? ` ${DIM}⤳${m}${RESET}` : '';
216
263
  }
217
- /** managers <slots> (or ↑ (root) when this node reports to no one) */
218
- function buildManagersLines(managers, cursor) {
219
- if (managers.length === 0)
220
- return [`${DIM}↑ ${'(root)'.padEnd(8)}${RESET}`];
221
- const selIdx = cursor.lane === 'up' ? cursor.idx : -1;
222
- const { body, hint } = laneSlots(managers, selIdx);
223
- return [truncate(`${laneLabel('↑', 'managers')} ${body}${hint}`)];
264
+ /** ⚑K pending-asks badge for a node, read from the cached map. */
265
+ function askBadge(id) {
266
+ const k = asksMap[id] ?? 0;
267
+ return k > 0 ? ` ${YELLOW}⚑${k}${RESET}` : '';
224
268
  }
225
- /** peers <slots> (the whole row is omitted when this node has no peers) */
226
- function buildSiblingsLines(siblings, cursor) {
227
- if (siblings.length === 0)
228
- return undefined;
229
- const selIdx = cursor.lane === 'side' ? cursor.idx : -1;
230
- const { body, hint } = laneSlots(siblings, selIdx);
231
- return [truncate(`${laneLabel('', 'peers')} ${body}${hint}`)];
269
+ /** Sort rank for sibling ordering live nodes (active, then idle) ahead of
270
+ * terminal ones, so sessions still running surface at the TOP of each child
271
+ * group instead of being buried under finished/failed ones. */
272
+ function statusRank(id) {
273
+ switch (getNode(id)?.status) {
274
+ case 'active': return 0;
275
+ case 'idle': return 1;
276
+ case 'done': return 2;
277
+ case 'canceled': return 3;
278
+ case 'dead': return 4;
279
+ default: return 5;
280
+ }
232
281
  }
233
- /** reports <slots> · ctx <k> (slots (none) when this node has no reports) */
234
- function buildReportsLines(nodeId, reports, cursor) {
235
- const tel = readTelemetry(nodeId);
236
- const ctx = tel.tokens_in != null && tel.tokens_in > 0 ? `${DIM}· ctx ${fmtTokens(tel.tokens_in)}${RESET}` : '';
237
- const label = laneLabel('↓', 'reports');
238
- if (reports.length === 0) {
239
- return [truncate(`${label} ${DIM}(none)${RESET}${ctx !== '' ? ` ${ctx}` : ''}`)];
282
+ /** Direct children, live-first the sibling order used both when flattening
283
+ * the tree and when stepping into a subtree (`l`). Array.sort is stable, so
284
+ * equal-status siblings keep their creation order. */
285
+ function sortedChildIds(id) {
286
+ try {
287
+ return subscriptionsOf(id).map((s) => s.node_id).sort((a, b) => statusRank(a) - statusRank(b));
240
288
  }
241
- const selIdx = cursor.lane === 'down' ? cursor.idx : -1;
242
- const { body, hint } = laneSlots(reports, selIdx);
243
- const tail = ctx !== '' ? ` ${ctx}` : '';
244
- return [truncate(`${label} ${body}${tail}${hint}`)];
289
+ catch {
290
+ return [];
291
+ }
292
+ }
293
+ function buildGraphModel(self) {
294
+ const rootId = climbRoot(self);
295
+ const rows = [];
296
+ const visited = new Set();
297
+ const walk = (id, prefix, isRoot, isLast) => {
298
+ if (visited.has(id)) {
299
+ const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
300
+ rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true });
301
+ return;
302
+ }
303
+ visited.add(id);
304
+ const kids = sortedChildIds(id);
305
+ const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
306
+ rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false });
307
+ if (collapsed.has(id))
308
+ return; // folded — don't descend
309
+ const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
310
+ for (let i = 0; i < kids.length; i++)
311
+ walk(kids[i], childPrefix, false, i === kids.length - 1);
312
+ };
313
+ walk(rootId, '', true, true);
314
+ return rows;
245
315
  }
316
+ /** Render one GRAPH row. SELF → reverse fill; CURSOR → ▸ + bold caret/name. */
317
+ function renderGraphRow(r, isCursor) {
318
+ if (r.cycle) {
319
+ const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
320
+ return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
321
+ }
322
+ const node = getNode(r.id);
323
+ const dot = coloredGlyph(node);
324
+ const rawName = node !== null ? fullName(node) : shortId(r.id);
325
+ const name = isCursor ? `${BOLD}${rawName}${RESET}` : rawName;
326
+ const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
327
+ const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
328
+ const caret = isCursor ? `${BOLD}▸${RESET} ` : ' ';
329
+ const fold = r.hasKids && collapsed.has(r.id) ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
330
+ const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${childBadge(node)}${fold}${askBadge(r.id)}`;
331
+ return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
332
+ }
333
+ /** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
334
+ * MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
335
+ * scroll chrome — so never exceed it (and shrink on a very short terminal).
336
+ * The viewport scrolls WITHIN this cap as the cursor moves. */
337
+ function graphWidgetBudget() {
338
+ const rows = process.stdout.rows ?? VIEWPORT_FALLBACK_ROWS;
339
+ return Math.max(4, Math.min(PI_MAX_WIDGET_LINES, rows - 4));
340
+ }
341
+ const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · e expand · x kill · m mgr · esc${RESET}`;
246
342
  // ---------------------------------------------------------------------------
247
- // Key decoding — Alt+j / Alt+k reach us in different encodings depending on the
248
- // terminal's active keyboard protocol. pi enables the kitty / modifyOtherKeys
249
- // protocols, and a tmux with `extended-keys csi-u` then delivers a *modified*
250
- // key as a CSI-u sequence — NOT the legacy ESC-prefix form. Comparing against a
251
- // single literal ("\x1bj") silently fails on any such terminal, so we accept
252
- // every encoding:
253
- //
254
- // legacy ESC j "\x1bj"
255
- // kitty / csi-u ESC [ 106 ; 3 u "\x1b[106;3u" (mod 3 → alt)
256
- // modifyOtherKeys ESC [ 27 ; 3 ; 106 ~ "\x1b[27;3;106~"
257
- //
258
- // The CSI-u modifier value is `mod-1` as a bitmask (shift 1, alt 2, ctrl 4,
259
- // super 8, …); Alt-alone is bit 2 set with shift/ctrl/super/etc. all clear
260
- // (lock bits ignored). Mirrors pi-tui's own parseKey, kept dependency-free.
343
+ // Key decoding — recognizers tolerant of legacy, kitty/CSI-u and
344
+ // modifyOtherKeys encodings (pi enables the kitty / modifyOtherKeys protocols,
345
+ // and tmux with `extended-keys csi-u` delivers modified keys as CSI-u, not the
346
+ // legacy ESC-prefix form). Mirrors pi-tui's parseKey, kept dependency-free.
261
347
  // ---------------------------------------------------------------------------
262
348
  const CSI_U_RE = /^\x1b\[(\d+)(?::\d*)?(?::\d+)?(?:;(\d+))?(?::\d+)?u$/;
263
349
  const MOK_RE = /^\x1b\[27;(\d+);(\d+)~$/;
@@ -270,7 +356,7 @@ function isAltOnly(mod) {
270
356
  function isAltKey(data, letter) {
271
357
  const code = letter.charCodeAt(0);
272
358
  if (data === `\x1b${letter}`)
273
- return true; // legacy ESC-prefix
359
+ return true;
274
360
  const u = CSI_U_RE.exec(data);
275
361
  if (u !== null) {
276
362
  const mod = u[2] !== undefined ? parseInt(u[2], 10) - 1 : 0;
@@ -282,6 +368,23 @@ function isAltKey(data, letter) {
282
368
  }
283
369
  return false;
284
370
  }
371
+ /** Recognize a PLAIN letter (no Alt) across the bare byte and kitty CSI-u
372
+ * single-char form. Uppercase letters also match lowercase-code + Shift. */
373
+ function isPlain(data, ch) {
374
+ if (data === ch)
375
+ return true;
376
+ const lower = ch.toLowerCase();
377
+ const needShift = ch !== lower;
378
+ const code = lower.charCodeAt(0);
379
+ const m = /^\x1b\[(\d+)(?:;(\d+))?u$/.exec(data);
380
+ if (m !== null) {
381
+ if (parseInt(m[1], 10) !== code)
382
+ return false;
383
+ const mod = m[2] !== undefined ? parseInt(m[2], 10) - 1 : 0;
384
+ return needShift ? (mod & 1) !== 0 && (mod & ~1) === 0 : mod === 0;
385
+ }
386
+ return false;
387
+ }
285
388
  /** Plain Enter across legacy and kitty (ESC [ 13 u). */
286
389
  function isEnterKey(data) {
287
390
  return data === '\r' || data === '\n' || /^\x1b\[13(?:;1)?u$/.test(data);
@@ -290,6 +393,29 @@ function isEnterKey(data) {
290
393
  function isEscKey(data) {
291
394
  return data === '\x1b' || /^\x1b\[27(?:;1)?u$/.test(data);
292
395
  }
396
+ /** Extract the bare letter of an `alt+<letter>` prefix spec (else undefined). */
397
+ function altLetterOf(spec) {
398
+ const m = /^alt\+([a-zA-Z])$/.exec(spec ?? '');
399
+ return m ? m[1].toLowerCase() : undefined;
400
+ }
401
+ // Built-in GRAPH keys are reserved; graphBinds may only ADD other keys.
402
+ const RESERVED_GRAPH_KEYS = new Set(['j', 'k', 'h', 'l', 'g', 'G', 'm', 'e', 'x', 'y', 'n']);
403
+ /** Split a `run` string argv-style and interpolate {id|self|name|manager|lane|
404
+ * subtree}. A bare `{subtree}` token expands to several argv elements; every
405
+ * other placeholder substitutes in place (kept as one element so a multi-word
406
+ * name survives as a single argument under execFile). */
407
+ function interpolateArgv(run, vars) {
408
+ const out = [];
409
+ for (const tok of run.split(/\s+/).filter((t) => t !== '')) {
410
+ if (tok === '{subtree}') {
411
+ for (const part of (vars['subtree'] ?? '').split(/\s+/).filter((p) => p !== ''))
412
+ out.push(part);
413
+ continue;
414
+ }
415
+ out.push(tok.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''));
416
+ }
417
+ return out;
418
+ }
293
419
  // ---------------------------------------------------------------------------
294
420
  // Extension
295
421
  // ---------------------------------------------------------------------------
@@ -305,52 +431,130 @@ export function registerCanvasNav(pi) {
305
431
  return; // not a canvas node
306
432
  // Captured from session_start; used in every subsequent render.
307
433
  let ui;
308
- // Debounce flag — prevents stacked renders from rapid turn_end bursts.
309
434
  let renderScheduled = false;
310
- // Spine cursor across the three lanes (see Lane / Cursor above): which lane
311
- // is active ('none' = nothing selected, chrome calm) and the index within it.
312
- // Driven by the key tap below.
313
- let cursor = { lane: 'none', idx: 0 };
435
+ // Cache config once (binds rarely change within a session; readConfig is sync
436
+ // and never throws). prefixKey drives the non-tmux GRAPH toggle shortcut.
437
+ let navConfig;
438
+ try {
439
+ navConfig = readConfig('user').canvasNav;
440
+ }
441
+ catch {
442
+ navConfig = { prefixBinds: {}, graphBinds: {} };
443
+ }
444
+ const prefixAltLetter = altLetterOf(navConfig.prefixKey);
314
445
  // -------------------------------------------------------------------------
315
- // Core render — pushes all three widgets in one pass
446
+ // Renderers
316
447
  // -------------------------------------------------------------------------
317
- // Re-clamp the cursor against the current lane's length (the graph may have
318
- // shrunk since the last keypress); collapse to 'none' if the lane emptied.
319
- const clampCursor = (managers, siblings, reports) => {
320
- if (cursor.lane === 'none')
448
+ /** BASE: manager line above the editor, reports stack below it. */
449
+ const renderBase = () => {
450
+ if (ui === undefined)
321
451
  return;
322
- const len = cursor.lane === 'up' ? managers.length :
323
- cursor.lane === 'side' ? siblings.length :
324
- reports.length;
325
- if (len === 0) {
326
- cursor = { lane: 'none', idx: 0 };
452
+ const mgr = managerOf(nodeId);
453
+ if (mgr === undefined) {
454
+ // Root node: no manager → drop the widget rather than show "↑ (root)" chrome.
455
+ ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
456
+ }
457
+ else {
458
+ const mn = getNode(mgr);
459
+ const name = mn !== null ? fullName(mn) : shortId(mgr);
460
+ const mgrLine = truncate(`↑ ${name} ${coloredGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${childBadge(mn)}${askBadge(mgr)}`);
461
+ ui.setWidget('crtr-managers', [mgrLine], { placement: 'aboveEditor' });
462
+ }
463
+ const reports = liveReports(nodeId);
464
+ const lines = [];
465
+ // Report rows only — no "↓ reports (N)" header (the label carries no signal).
466
+ if (reports.length > 0) {
467
+ const nameW = Math.min(20, Math.max(...reports.map((id) => {
468
+ const n = getNode(id);
469
+ return (n !== null ? fullName(n) : shortId(id)).length;
470
+ })));
471
+ for (const id of reports) {
472
+ const n = getNode(id);
473
+ const name = (n !== null ? fullName(n) : shortId(id)).padEnd(nameW);
474
+ const kind = `${DIM}${(n?.kind ?? '').padEnd(6)}${RESET}`;
475
+ const tokens = `${DIM}${tokensCell(id).padStart(5)}${RESET}`;
476
+ lines.push(truncate(` ${coloredGlyph(n)} ${name} ${kind} ${tokens}${childBadge(n)}${askBadge(id)}`));
477
+ }
478
+ }
479
+ // Self's own pending asks (no self row in BASE) → a trailing inline line.
480
+ const selfAsks = asksMap[nodeId] ?? 0;
481
+ if (selfAsks > 0)
482
+ lines.push(`${YELLOW}⚑${selfAsks}${RESET}`);
483
+ // Nothing to show → drop the widget rather than render an empty bar.
484
+ ui.setWidget('crtr-base', lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
485
+ // Drop GRAPH chrome so nothing bleeds through.
486
+ ui.setWidget('crtr-graph', undefined, { placement: 'belowEditor' });
487
+ };
488
+ /** GRAPH: the fold-aware tree + a one-line hint/footer, viewport-bounded. */
489
+ const renderGraph = () => {
490
+ if (ui === undefined)
327
491
  return;
492
+ const rows = buildGraphModel(nodeId);
493
+ // Re-resolve the cursor id → row (it may have vanished under a fold or a
494
+ // close); clamp to nearest visible row.
495
+ let cursorIdx = rows.findIndex((r) => r.id === cursorId);
496
+ if (cursorIdx < 0) {
497
+ cursorIdx = rows.findIndex((r) => r.id === nodeId);
498
+ if (cursorIdx < 0)
499
+ cursorIdx = 0;
500
+ }
501
+ cursorId = rows[cursorIdx]?.id ?? nodeId;
502
+ // Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
503
+ // the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
504
+ // rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
505
+ // list rather than hitting pi's hard truncation. The passes settle the
506
+ // mutual dependency between "how many rows fit" and "are indicators shown":
507
+ // each ↑/↓ indicator steals a tree row, which can push the cursor out of
508
+ // view, which moves the window, which changes whether an indicator shows.
509
+ // This needs up to 3 passes to converge (an indicator appearing shrinks the
510
+ // window, the smaller window re-homes scrollTop, that re-home can toggle the
511
+ // *other* indicator). Bailing early (the old 2-pass cap) left the cursor one
512
+ // row off-screen for a single keypress near the bottom — the arrow vanished
513
+ // and only the NEXT press scrolled. 4 passes always settles to a stable,
514
+ // cursor-visible window.
515
+ const treeArea = Math.max(2, graphWidgetBudget() - 1);
516
+ let viewportH = treeArea;
517
+ for (let pass = 0; pass < 4; pass++) {
518
+ if (cursorIdx < scrollTop)
519
+ scrollTop = cursorIdx;
520
+ if (cursorIdx >= scrollTop + viewportH)
521
+ scrollTop = cursorIdx - viewportH + 1;
522
+ scrollTop = Math.max(0, Math.min(scrollTop, Math.max(0, rows.length - viewportH)));
523
+ const fit = treeArea - (scrollTop > 0 ? 1 : 0) - (scrollTop + viewportH < rows.length ? 1 : 0);
524
+ if (fit === viewportH)
525
+ break;
526
+ viewportH = Math.max(1, fit);
328
527
  }
329
- cursor.idx = Math.max(0, Math.min(len - 1, cursor.idx));
528
+ const end = Math.min(rows.length, scrollTop + viewportH);
529
+ const lines = [];
530
+ if (scrollTop > 0)
531
+ lines.push(`${DIM} ↑ ${scrollTop} more${RESET}`);
532
+ for (let i = scrollTop; i < end; i++)
533
+ lines.push(renderGraphRow(rows[i], i === cursorIdx));
534
+ if (end < rows.length)
535
+ lines.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
536
+ const hint = pendingConfirm !== undefined
537
+ ? `${YELLOW}${pendingConfirm.label} ${BOLD}y/n${RESET}`
538
+ : GRAPH_HINT;
539
+ lines.push(truncate(`${hint} ${DIM}${cursorIdx + 1}/${rows.length}${RESET}`));
540
+ ui.setWidget('crtr-graph', lines, { placement: 'belowEditor' });
541
+ // Drop BASE chrome.
542
+ ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
543
+ ui.setWidget('crtr-base', undefined, { placement: 'belowEditor' });
330
544
  };
331
545
  const render = () => {
332
546
  if (ui === undefined)
333
547
  return;
334
548
  try {
335
- const managers = managersOf(nodeId);
336
- const siblings = siblingsOf(nodeId);
337
- const reports = reportsOf(nodeId);
338
- clampCursor(managers, siblings, reports);
339
- // ⚑ pending asks — top of the stack, omitted entirely when count is 0.
340
- ui.setWidget('crtr-asks', cachedAskCount > 0 ? [`${YELLOW}⚑ ${cachedAskCount} waiting${RESET}`] : undefined, { placement: 'aboveEditor' });
341
- // ↑ managers, then ↔ peers directly above the editor — the spine reads
342
- // top→bottom: managers · peers · [you] · reports. setWidget(…, undefined)
343
- // drops the peers row entirely when this node has none.
344
- ui.setWidget('crtr-managers', buildManagersLines(managers, cursor), { placement: 'aboveEditor' });
345
- ui.setWidget('crtr-siblings', buildSiblingsLines(siblings, cursor), { placement: 'aboveEditor' });
346
- // ↓ reports row, below the editor.
347
- ui.setWidget('crtr-reports', buildReportsLines(nodeId, reports, cursor), { placement: 'belowEditor' });
549
+ if (view === 'graph')
550
+ renderGraph();
551
+ else
552
+ renderBase();
348
553
  }
349
554
  catch {
350
555
  /* render is best-effort; never throw out of a handler */
351
556
  }
352
557
  };
353
- // Debounced render: coalesces rapid event bursts into one paint.
354
558
  const scheduleRender = () => {
355
559
  if (renderScheduled)
356
560
  return;
@@ -360,15 +564,21 @@ export function registerCanvasNav(pi) {
360
564
  render();
361
565
  }, RENDER_DEBOUNCE_MS);
362
566
  };
363
- // Bring the selected node's window forefront. Reuses the `crtr node focus`
364
- // CLI (which revives a dormant target first) via the same execFile pattern
365
- // as the ask-count poll — keeps tmux/revive logic out of the extension.
366
- const focusTarget = (id) => {
567
+ // -------------------------------------------------------------------------
568
+ // Actions (all shell out; the extension stays tmux/revive-free)
569
+ // -------------------------------------------------------------------------
570
+ const shellCrtr = (argv, onDone) => {
367
571
  try {
368
- execFile('crtr', ['node', 'focus', id], (err) => {
572
+ execFile('crtr', argv, (err) => {
369
573
  if (err != null && ui?.notify != null) {
370
574
  try {
371
- ui.notify(`focus failed: ${id.slice(0, 8)}`, 'error');
575
+ ui.notify(`crtr ${argv[0]} failed`, 'error');
576
+ }
577
+ catch { /* best-effort */ }
578
+ }
579
+ if (onDone !== undefined) {
580
+ try {
581
+ onDone();
372
582
  }
373
583
  catch { /* best-effort */ }
374
584
  }
@@ -378,104 +588,228 @@ export function registerCanvasNav(pi) {
378
588
  /* best-effort */
379
589
  }
380
590
  };
381
- // Pre-editor key tap. Only acts on an EMPTY editor so message composition
382
- // (multi-line cursor moves, history, submit) is never disturbed. Vim-style
383
- // Alt+h/j/k/l walk the spine — Alt+k UP (managers), Alt+j DOWN (reports),
384
- // Alt+h/Alt+l LEFT/RIGHT (peers) — so the bare arrow keys stay bound to pi's
385
- // normal history recall and never conflict with canvas nav.
386
- const handleKey = (data) => {
387
- try {
388
- if (ui === undefined)
389
- return undefined;
390
- let editorEmpty = true;
391
- try {
392
- editorEmpty = (ui.getEditorText?.() ?? '').trim() === '';
393
- }
394
- catch {
395
- editorEmpty = false;
396
- }
397
- if (!editorEmpty)
398
- return undefined; // composing — leave every key alone
399
- // Alt+h/j/k/l walk the spine — recognized across legacy ESC-prefix,
400
- // kitty/CSI-u and modifyOtherKeys encodings (see isAltKey above) so nav
401
- // works regardless of the terminal's keyboard protocol.
402
- const isUp = isAltKey(data, 'k');
403
- const isDown = isAltKey(data, 'j');
404
- const isLeft = isAltKey(data, 'h');
405
- const isRight = isAltKey(data, 'l');
406
- const isEnter = isEnterKey(data);
407
- const isEsc = isEscKey(data);
408
- if (!isUp && !isDown && !isLeft && !isRight && !isEnter && !isEsc) {
409
- // Any other key cancels an active selection, then passes through so the
410
- // character lands in the editor as normal.
411
- if (cursor.lane !== 'none') {
412
- cursor = { lane: 'none', idx: 0 };
413
- render();
414
- }
415
- return undefined;
416
- }
417
- const managers = managersOf(nodeId);
418
- const siblings = siblingsOf(nodeId);
419
- const reports = reportsOf(nodeId);
420
- // Move within (or hop into) a lane, cycling with wrap. Entering a lane
421
- // lands on the first slot for forward motion, the last for backward.
422
- const step = (lane, count, dir) => {
423
- if (count === 0)
424
- return;
425
- if (cursor.lane !== lane)
426
- cursor = { lane, idx: dir === 1 ? 0 : count - 1 };
427
- else
428
- cursor = { lane, idx: (cursor.idx + dir + count) % count };
429
- };
430
- if (isUp) {
431
- step('up', managers.length, +1);
591
+ const focusTarget = (id) => shellCrtr(['node', 'focus', id]);
592
+ const enterGraph = () => {
593
+ view = 'graph';
594
+ pendingConfirm = undefined;
595
+ scrollTop = 0;
596
+ if (cursorId === undefined || getNode(cursorId) === null)
597
+ cursorId = nodeId;
598
+ render();
599
+ };
600
+ const exitGraph = () => {
601
+ view = 'base';
602
+ pendingConfirm = undefined;
603
+ render();
604
+ };
605
+ const toggleGraph = () => {
606
+ if (view === 'graph')
607
+ exitGraph();
608
+ else
609
+ enterGraph();
610
+ };
611
+ /** Template vars for a graphBind, resolved against the CURSOR node. */
612
+ const graphVars = (cur) => {
613
+ const cn = getNode(cur);
614
+ return {
615
+ id: cur,
616
+ self: nodeId,
617
+ lane: cur,
618
+ name: cn !== null ? fullName(cn) : cur,
619
+ manager: managerOf(cur) ?? '',
620
+ subtree: subtreeIds(cur).join(' '),
621
+ };
622
+ };
623
+ // -------------------------------------------------------------------------
624
+ // GRAPH modal key handler — consumes EVERY key while in GRAPH.
625
+ // -------------------------------------------------------------------------
626
+ const handleGraphKey = (data) => {
627
+ // y/n confirm gate takes precedence over everything.
628
+ if (pendingConfirm !== undefined) {
629
+ if (isPlain(data, 'y')) {
630
+ const act = pendingConfirm.action;
631
+ pendingConfirm = undefined;
632
+ act();
432
633
  render();
433
634
  return { consume: true };
434
635
  }
435
- if (isDown) {
436
- step('down', reports.length, +1);
437
- render();
438
- return { consume: true };
636
+ pendingConfirm = undefined; // any other key cancels
637
+ render();
638
+ return { consume: true };
639
+ }
640
+ // Let the prefix shortcut (alt+g) through so pi's registerShortcut can
641
+ // toggle us back to BASE; esc also exits, handled below.
642
+ if (prefixAltLetter !== undefined && isAltKey(data, prefixAltLetter))
643
+ return undefined;
644
+ if (isEscKey(data)) {
645
+ exitGraph();
646
+ return { consume: true };
647
+ }
648
+ const rows = buildGraphModel(nodeId);
649
+ let idx = rows.findIndex((r) => r.id === cursorId);
650
+ if (idx < 0)
651
+ idx = Math.max(0, rows.findIndex((r) => r.id === nodeId));
652
+ const cur = rows[idx];
653
+ if (isPlain(data, 'j')) {
654
+ idx = Math.min(rows.length - 1, idx + 1);
655
+ cursorId = rows[idx]?.id ?? cursorId;
656
+ render();
657
+ return { consume: true };
658
+ }
659
+ if (isPlain(data, 'k')) {
660
+ idx = Math.max(0, idx - 1);
661
+ cursorId = rows[idx]?.id ?? cursorId;
662
+ render();
663
+ return { consume: true };
664
+ }
665
+ if (isPlain(data, 'g')) {
666
+ cursorId = rows[0]?.id ?? cursorId;
667
+ render();
668
+ return { consume: true };
669
+ }
670
+ if (isPlain(data, 'G')) {
671
+ cursorId = rows[rows.length - 1]?.id ?? cursorId;
672
+ render();
673
+ return { consume: true };
674
+ }
675
+ if (isPlain(data, 'h')) {
676
+ if (cur !== undefined && cur.hasKids && !collapsed.has(cur.id)) {
677
+ collapsed.add(cur.id);
439
678
  }
440
- if (isRight) {
441
- step('side', siblings.length, +1);
442
- render();
443
- return { consume: true };
679
+ else {
680
+ const p = managerOf(cursorId ?? nodeId);
681
+ if (p !== undefined && rows.some((r) => r.id === p))
682
+ cursorId = p;
444
683
  }
445
- if (isLeft) {
446
- step('side', siblings.length, -1);
447
- render();
448
- return { consume: true };
684
+ render();
685
+ return { consume: true };
686
+ }
687
+ if (isPlain(data, 'l')) {
688
+ if (cur !== undefined && collapsed.has(cur.id)) {
689
+ collapsed.delete(cur.id);
449
690
  }
450
- if (isEsc) {
451
- if (cursor.lane === 'none')
452
- return undefined;
453
- cursor = { lane: 'none', idx: 0 };
454
- render();
691
+ else if (cur !== undefined && cur.hasKids) {
692
+ const c = sortedChildIds(cur.id)[0];
693
+ if (c !== undefined)
694
+ cursorId = c;
695
+ }
696
+ render();
697
+ return { consume: true };
698
+ }
699
+ if (isEnterKey(data)) {
700
+ if (cursorId !== undefined)
701
+ focusTarget(cursorId);
702
+ render();
703
+ return { consume: true };
704
+ }
705
+ if (isPlain(data, 'm')) {
706
+ const mgr = managerOf(nodeId);
707
+ if (mgr !== undefined)
708
+ focusTarget(mgr);
709
+ render();
710
+ return { consume: true };
711
+ }
712
+ if (isPlain(data, 'e')) {
713
+ shellCrtr(['canvas', 'tmux-spread', nodeId]);
714
+ return { consume: true };
715
+ }
716
+ if (isPlain(data, 'x')) {
717
+ const target = cursorId ?? nodeId;
718
+ const n = getNode(target);
719
+ const nm = n !== null ? fullName(n) : shortId(target);
720
+ pendingConfirm = { label: `kill ${nm}?`, action: () => shellCrtr(['node', 'close', '--node', target], render) };
721
+ render();
722
+ return { consume: true };
723
+ }
724
+ // Custom graphBinds — additive only (built-in keys reserved).
725
+ for (const [key, bind] of Object.entries(navConfig.graphBinds)) {
726
+ if (key.length !== 1 || RESERVED_GRAPH_KEYS.has(key))
727
+ continue;
728
+ if (!isPlain(data, key))
729
+ continue;
730
+ const target = cursorId ?? nodeId;
731
+ const argv = interpolateArgv(bind.run, graphVars(target));
732
+ if (argv.length === 0)
455
733
  return { consume: true };
734
+ if (bind.confirm === true) {
735
+ const n = getNode(target);
736
+ const nm = n !== null ? fullName(n) : shortId(target);
737
+ pendingConfirm = { label: `${bind.desc ?? bind.run} ${nm}?`, action: () => shellCrtr(argv, render) };
738
+ }
739
+ else {
740
+ shellCrtr(argv, render);
456
741
  }
457
- // isEnter — focus the selected neighbor, if any; else normal submit.
458
- if (cursor.lane === 'none')
459
- return undefined; // nothing selected → submit
460
- const lane = cursor.lane === 'up' ? managers : cursor.lane === 'side' ? siblings : reports;
461
- const target = lane[cursor.idx];
462
- if (target !== undefined)
463
- focusTarget(target.id);
464
- cursor = { lane: 'none', idx: 0 };
465
742
  render();
466
743
  return { consume: true };
467
744
  }
745
+ // Modal: swallow everything else so stray keys never reach the editor.
746
+ return { consume: true };
747
+ };
748
+ // Pre-editor key tap. BASE passes EVERY key through (composing is never
749
+ // disturbed); GRAPH is fully modal. One persistent tap (preserving the
750
+ // /reload single-unsub guard); its body branches on `view`.
751
+ const handleKey = (data) => {
752
+ try {
753
+ if (ui === undefined)
754
+ return undefined;
755
+ if (view === 'base')
756
+ return undefined;
757
+ return handleGraphKey(data);
758
+ }
468
759
  catch {
469
760
  return undefined;
470
761
  }
471
762
  };
472
763
  // -------------------------------------------------------------------------
764
+ // Slash command + shortcut to toggle GRAPH (registered once per load, like
765
+ // canvas-commands.ts; pi dedupes duplicate names on /reload).
766
+ // -------------------------------------------------------------------------
767
+ if (typeof pi.registerCommand === 'function') {
768
+ pi.registerCommand('graph', {
769
+ description: 'Toggle the canvas GRAPH view (NERDTree of your local graph)',
770
+ handler: async (_args, ctx) => {
771
+ if (ui === undefined)
772
+ ui = ctx.ui;
773
+ toggleGraph();
774
+ },
775
+ });
776
+ }
777
+ if (typeof pi.registerShortcut === 'function' && navConfig.prefixKey !== undefined && navConfig.prefixKey !== '') {
778
+ try {
779
+ pi.registerShortcut(navConfig.prefixKey, {
780
+ description: 'Toggle the canvas GRAPH view',
781
+ handler: async (ctx) => {
782
+ if (ui === undefined)
783
+ ui = ctx.ui;
784
+ toggleGraph();
785
+ },
786
+ });
787
+ }
788
+ catch {
789
+ /* shortcut spec rejected by pi — /graph + the alt+c menu still work */
790
+ }
791
+ }
792
+ // -------------------------------------------------------------------------
473
793
  // Event handlers
474
794
  // -------------------------------------------------------------------------
475
795
  pi.on('session_start', (_event, ctx) => {
476
796
  ui = ctx.ui;
477
- // Register the spine-navigation key tap once. Double-guard against /reload
478
- // stacking (mirrors liveTimer): clear any previous tap before adding ours.
797
+ // Fresh session / hot-swap: start in BASE and clear any legacy or
798
+ // inactive-view widgets so nothing stale bleeds through.
799
+ view = 'base';
800
+ pendingConfirm = undefined;
801
+ for (const key of ['crtr-asks', 'crtr-siblings', 'crtr-reports', 'crtr-graph']) {
802
+ try {
803
+ ctx.ui.setWidget(key, undefined, { placement: 'belowEditor' });
804
+ }
805
+ catch { /* ignore */ }
806
+ try {
807
+ ctx.ui.setWidget(key, undefined, { placement: 'aboveEditor' });
808
+ }
809
+ catch { /* ignore */ }
810
+ }
811
+ // Register the modal key tap once. Double-guard against /reload stacking
812
+ // (mirrors liveTimer): clear any previous tap before adding ours.
479
813
  if (liveUnsub !== undefined) {
480
814
  try {
481
815
  liveUnsub();
@@ -489,7 +823,7 @@ export function registerCanvasNav(pi) {
489
823
  }
490
824
  }
491
825
  catch {
492
- /* onTerminalInput unavailable (older pi / non-interactive) — chrome stays display-only */
826
+ /* onTerminalInput unavailable — chrome stays display-only */
493
827
  }
494
828
  scheduleRender();
495
829
  });
@@ -497,16 +831,17 @@ export function registerCanvasNav(pi) {
497
831
  scheduleRender();
498
832
  });
499
833
  // -------------------------------------------------------------------------
500
- // Background timer — ask-count polling + periodic refresh
834
+ // Background timer — per-node ask polling (one shell-out) + periodic refresh
501
835
  // -------------------------------------------------------------------------
502
836
  if (liveTimer !== undefined)
503
837
  clearInterval(liveTimer);
504
838
  const timer = setInterval(() => {
505
839
  try {
506
- const fresh = fetchAskCount(nodeId);
507
- // Only repaint when the count actually changed — avoids constant flicker.
508
- if (fresh !== cachedAskCount) {
509
- cachedAskCount = fresh;
840
+ const rootId = climbRoot(nodeId);
841
+ const fresh = fetchAsksMap(rootId);
842
+ // Repaint only when the map actually changed — avoids constant flicker.
843
+ if (JSON.stringify(fresh) !== JSON.stringify(asksMap)) {
844
+ asksMap = fresh;
510
845
  scheduleRender();
511
846
  }
512
847
  }
@@ -514,12 +849,9 @@ export function registerCanvasNav(pi) {
514
849
  /* timer is best-effort */
515
850
  }
516
851
  }, ASK_POLL_MS);
517
- // unref() so the timer doesn't keep the Node process alive after everything
518
- // else has finished — matches the inbox-watcher convention.
519
852
  if (typeof timer.unref === 'function')
520
853
  timer.unref();
521
854
  liveTimer = timer;
522
- // Clear on shutdown so a /reload never discovers a live sibling timer.
523
855
  pi.on('session_shutdown', () => {
524
856
  clearInterval(timer);
525
857
  if (liveTimer === timer)