@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
@@ -0,0 +1,263 @@
1
+ // edit-diff-render.ts — Claude-Code-style diff rendering for the attach viewer.
2
+ //
3
+ // pi's builtin edit renderer (core/tools/edit.js → renderDiff) paints diff
4
+ // lines with colored FOREGROUND text plus inverse-video on changed tokens.
5
+ // This module re-skins that body the way Claude Code renders diffs: every
6
+ // added line gets a full-width tinted green BACKGROUND, every removed line a
7
+ // full-width tinted red background, with the changed span inside a 1↔1 line
8
+ // modification emphasized in a brighter tint of the same color.
9
+ //
10
+ // We deliberately keep pi's builtin edit definition as the engine — it owns
11
+ // the async preview computation (computeEditsDiff keyed off ctx.state) and
12
+ // the header/status-bg lifecycle — and only swap the rendered diff body.
13
+ // The raw diff string lives on the call component (`preview.diff`), so no
14
+ // ANSI re-parsing of pi's output is ever needed.
15
+ import { createEditToolDefinition } from '@earendil-works/pi-coding-agent';
16
+ import { Container, Spacer, Text } from '@earendil-works/pi-tui';
17
+ const FALLBACK_ADDED = { r: 181, g: 189, b: 104 }; // pi dark green
18
+ const FALLBACK_REMOVED = { r: 204, g: 102, b: 102 }; // pi dark red
19
+ const FALLBACK_CARD_BG = { r: 40, g: 50, b: 40 }; // pi dark toolSuccessBg
20
+ /** Standard xterm-256 palette → rgb (cube + grayscale ramps; 16 base approximated). */
21
+ function xterm256ToRgb(n) {
22
+ if (n < 16) {
23
+ const base = [
24
+ [0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0],
25
+ [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
26
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0],
27
+ [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
28
+ ];
29
+ const [r, g, b] = base[n] ?? [128, 128, 128];
30
+ return { r, g, b };
31
+ }
32
+ if (n < 232) {
33
+ const i = n - 16;
34
+ const steps = [0, 95, 135, 175, 215, 255];
35
+ return { r: steps[Math.floor(i / 36) % 6], g: steps[Math.floor(i / 6) % 6], b: steps[i % 6] };
36
+ }
37
+ const v = 8 + (n - 232) * 10;
38
+ return { r: v, g: v, b: v };
39
+ }
40
+ /** rgb → nearest xterm-256 cube/gray index (for non-truecolor terminals). */
41
+ function rgbTo256(c) {
42
+ const toCube = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.floor((v - 35) / 40)));
43
+ const steps = [0, 95, 135, 175, 215, 255];
44
+ const cr = toCube(c.r);
45
+ const cg = toCube(c.g);
46
+ const cb = toCube(c.b);
47
+ const cubeIdx = 16 + 36 * cr + 6 * cg + cb;
48
+ const cube = { r: steps[cr], g: steps[cg], b: steps[cb] };
49
+ const gray = Math.max(0, Math.min(23, Math.round((((c.r + c.g + c.b) / 3) - 8) / 10)));
50
+ const grayIdx = 232 + gray;
51
+ const grayRgb = xterm256ToRgb(grayIdx);
52
+ const dist = (a, b) => (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
53
+ return dist(c, cube) <= dist(c, grayRgb) ? cubeIdx : grayIdx;
54
+ }
55
+ /** Parse the rgb out of a theme SGR open code (`38/48;2;r;g;b` or `38/48;5;n`). */
56
+ function parseAnsiRgb(ansi) {
57
+ const truecolor = ansi.match(/\x1b\[[34]8;2;(\d+);(\d+);(\d+)m/);
58
+ if (truecolor)
59
+ return { r: Number(truecolor[1]), g: Number(truecolor[2]), b: Number(truecolor[3]) };
60
+ const indexed = ansi.match(/\x1b\[[34]8;5;(\d+)m/);
61
+ if (indexed)
62
+ return xterm256ToRgb(Number(indexed[1]));
63
+ return undefined;
64
+ }
65
+ function mix(a, b, t) {
66
+ return {
67
+ r: Math.round(a.r * t + b.r * (1 - t)),
68
+ g: Math.round(a.g * t + b.g * (1 - t)),
69
+ b: Math.round(a.b * t + b.b * (1 - t)),
70
+ };
71
+ }
72
+ function bgOpen(c, mode) {
73
+ if (mode === 'truecolor')
74
+ return `\x1b[48;2;${c.r};${c.g};${c.b}m`;
75
+ return `\x1b[48;5;${rgbTo256(c)}m`;
76
+ }
77
+ function themeFgRgb(theme, name, fallback) {
78
+ try {
79
+ return parseAnsiRgb(theme.getFgAnsi(name)) ?? fallback;
80
+ }
81
+ catch {
82
+ return fallback;
83
+ }
84
+ }
85
+ function themeBgRgb(theme, name, fallback) {
86
+ try {
87
+ return parseAnsiRgb(theme.getBgAnsi(name)) ?? fallback;
88
+ }
89
+ catch {
90
+ return fallback;
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Diff body rendering
95
+ // ---------------------------------------------------------------------------
96
+ /** pi's display-diff line format: "+123 content" / "-123 content" / " 123 content". */
97
+ function parseDiffLine(line) {
98
+ const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
99
+ if (!match)
100
+ return null;
101
+ return { prefix: match[1], lineNum: match[2], content: match[3] };
102
+ }
103
+ function replaceTabs(text) {
104
+ return text.replace(/\t/g, ' ');
105
+ }
106
+ /** Common prefix/suffix char-span diff for a single-line 1↔1 modification. */
107
+ function changedSpans(oldS, newS) {
108
+ const maxP = Math.min(oldS.length, newS.length);
109
+ let p = 0;
110
+ while (p < maxP && oldS[p] === newS[p])
111
+ p++;
112
+ let s = 0;
113
+ while (s < maxP - p && oldS[oldS.length - 1 - s] === newS[newS.length - 1 - s])
114
+ s++;
115
+ return { old: [p, oldS.length - s], new: [p, newS.length - s] };
116
+ }
117
+ /** Wrap the changed span in a brighter tint, restoring the line tint after. */
118
+ function emphasize(content, span, emphOpen, lineOpen) {
119
+ const [start, end] = span;
120
+ if (end <= start)
121
+ return content;
122
+ return content.slice(0, start) + emphOpen + content.slice(start, end) + lineOpen + content.slice(end);
123
+ }
124
+ /**
125
+ * Build the Claude-Code-style diff body: one full-width background-tinted
126
+ * Text per added/removed line (continuation lines of a wrapped long line keep
127
+ * the tint, exactly like Claude Code), plain context lines in between.
128
+ *
129
+ * `restoreBg` is the SGR that must follow each tinted line so the host Box's
130
+ * own background resumes on its right padding column (a bare `\x1b[49m` would
131
+ * punch a default-bg notch into the card edge).
132
+ */
133
+ function buildDiffBody(diffText, theme, restoreBg) {
134
+ const mode = theme.getColorMode();
135
+ const cardBg = themeBgRgb(theme, 'toolSuccessBg', FALLBACK_CARD_BG);
136
+ const addedFg = themeFgRgb(theme, 'toolDiffAdded', FALLBACK_ADDED);
137
+ const removedFg = themeFgRgb(theme, 'toolDiffRemoved', FALLBACK_REMOVED);
138
+ const addedLineBg = bgOpen(mix(addedFg, cardBg, 0.24), mode);
139
+ const addedEmphBg = bgOpen(mix(addedFg, cardBg, 0.48), mode);
140
+ const removedLineBg = bgOpen(mix(removedFg, cardBg, 0.24), mode);
141
+ const removedEmphBg = bgOpen(mix(removedFg, cardBg, 0.48), mode);
142
+ const container = new Container();
143
+ const pushLine = (styled, lineBg) => {
144
+ const bgFn = lineBg === undefined ? undefined : (padded) => lineBg + padded + restoreBg;
145
+ container.addChild(new Text(styled, 0, 0, bgFn));
146
+ };
147
+ const pushRemoved = (lineNum, content) => {
148
+ pushLine(`${theme.fg('toolDiffRemoved', `-${lineNum}`)} ${content}`, removedLineBg);
149
+ };
150
+ const pushAdded = (lineNum, content) => {
151
+ pushLine(`${theme.fg('toolDiffAdded', `+${lineNum}`)} ${content}`, addedLineBg);
152
+ };
153
+ const lines = diffText.split('\n');
154
+ let i = 0;
155
+ while (i < lines.length) {
156
+ const parsed = parseDiffLine(lines[i]);
157
+ if (!parsed) {
158
+ pushLine(theme.fg('toolDiffContext', lines[i]));
159
+ i++;
160
+ continue;
161
+ }
162
+ if (parsed.prefix === '-') {
163
+ const removed = [];
164
+ while (i < lines.length) {
165
+ const p = parseDiffLine(lines[i]);
166
+ if (!p || p.prefix !== '-')
167
+ break;
168
+ removed.push({ lineNum: p.lineNum, content: replaceTabs(p.content) });
169
+ i++;
170
+ }
171
+ const added = [];
172
+ while (i < lines.length) {
173
+ const p = parseDiffLine(lines[i]);
174
+ if (!p || p.prefix !== '+')
175
+ break;
176
+ added.push({ lineNum: p.lineNum, content: replaceTabs(p.content) });
177
+ i++;
178
+ }
179
+ // Single-line modification → emphasize the changed span in a brighter tint.
180
+ if (removed.length === 1 && added.length === 1) {
181
+ const spans = changedSpans(removed[0].content, added[0].content);
182
+ pushRemoved(removed[0].lineNum, emphasize(removed[0].content, spans.old, removedEmphBg, removedLineBg));
183
+ pushAdded(added[0].lineNum, emphasize(added[0].content, spans.new, addedEmphBg, addedLineBg));
184
+ }
185
+ else {
186
+ for (const line of removed)
187
+ pushRemoved(line.lineNum, line.content);
188
+ for (const line of added)
189
+ pushAdded(line.lineNum, line.content);
190
+ }
191
+ }
192
+ else if (parsed.prefix === '+') {
193
+ pushAdded(parsed.lineNum, replaceTabs(parsed.content));
194
+ i++;
195
+ }
196
+ else {
197
+ pushLine(theme.fg('toolDiffContext', ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
198
+ i++;
199
+ }
200
+ }
201
+ return container;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Tool definition wrapper
205
+ // ---------------------------------------------------------------------------
206
+ /** Replace the pi-rendered diff body inside the edit call Box (children:
207
+ * [header, Spacer, body]) with the full-background rendering. No-op when
208
+ * there is no successful preview (header-only or error states keep pi's
209
+ * rendering untouched). */
210
+ function restyleEditCallBox(box, theme) {
211
+ const preview = box.preview;
212
+ if (!preview || 'error' in preview || box.children.length < 2)
213
+ return;
214
+ const header = box.children[0];
215
+ box.clear();
216
+ box.addChild(header);
217
+ box.addChild(new Spacer(1));
218
+ // With a successful preview the edit card bg is always toolSuccessBg
219
+ // (mirrors pi's getEditHeaderBg), so tinted lines restore to that color.
220
+ box.addChild(buildDiffBody(preview.diff, theme, theme.getBgAnsi('toolSuccessBg')));
221
+ }
222
+ /**
223
+ * Attach-viewer tool definition for `edit`: pi's builtin definition drives
224
+ * all state (preview computation, header, success/error bg), then the diff
225
+ * body is re-skinned Claude-Code-style after every render pass.
226
+ */
227
+ export function createCrtrEditToolDefinition(cwd) {
228
+ const builtin = createEditToolDefinition(cwd);
229
+ return {
230
+ name: 'edit',
231
+ label: 'edit',
232
+ description: 'Render edit diffs with full-line backgrounds in the attach viewer.',
233
+ parameters: {},
234
+ renderShell: 'self',
235
+ renderCall(args, theme, context) {
236
+ const component = builtin.renderCall(args, theme, context);
237
+ const callBox = context.state.callComponent;
238
+ if (callBox)
239
+ restyleEditCallBox(callBox, theme);
240
+ return component;
241
+ },
242
+ renderResult(result, options, theme, context) {
243
+ const component = builtin.renderResult(result, options, theme, context);
244
+ const callBox = context.state.callComponent;
245
+ if (callBox) {
246
+ // builtin renderResult re-ran buildEditCallComponent (pi styling) —
247
+ // restyle the refreshed body.
248
+ restyleEditCallBox(callBox, theme);
249
+ return component;
250
+ }
251
+ // Fallback path (no call box state, e.g. a bare result replay): render
252
+ // the result diff standalone. No host Box here, so restore to default bg.
253
+ const resultDiff = result?.details?.diff;
254
+ if (!context.isError && typeof resultDiff === 'string') {
255
+ const container = new Container();
256
+ container.addChild(new Spacer(1));
257
+ container.addChild(buildDiffBody(resultDiff, theme, '\x1b[49m'));
258
+ return container;
259
+ }
260
+ return component;
261
+ },
262
+ };
263
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Component, type TUI } from '@earendil-works/pi-tui';
2
+ import { type BindingResolution, type BindingId } from '../../core/keybindings/index.js';
2
3
  import { type CanvasSource } from '../../core/canvas/source.js';
3
4
  import type { AttachPalette } from './config-load.js';
4
5
  export declare class GraphOverlay implements Component {
@@ -6,6 +7,7 @@ export declare class GraphOverlay implements Component {
6
7
  private readonly self;
7
8
  private readonly getAsks;
8
9
  private readonly palette;
10
+ private bindings;
9
11
  private readonly source;
10
12
  private handle;
11
13
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -17,7 +19,7 @@ export declare class GraphOverlay implements Component {
17
19
  private cursorId;
18
20
  /** Viewport scroll offset (row index of the top visible tree row). */
19
21
  private scrollTop;
20
- /** Transient y/n confirm gate (kill). */
22
+ /** Transient close confirmation gate. */
21
23
  private pendingConfirm;
22
24
  private snapshot;
23
25
  private snapshotSeq;
@@ -26,7 +28,15 @@ export declare class GraphOverlay implements Component {
26
28
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
27
29
  * commands against a remote graph row. See the file header. */
28
30
  private readonly remote;
29
- constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, source?: CanvasSource);
31
+ constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source?: CanvasSource);
32
+ setBindings(bindings: BindingResolution<BindingId>): void;
33
+ private matches;
34
+ private hint;
35
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
36
+ * overflow `budget` visible columns rather than letting the border mid-clip
37
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
38
+ * discoverable even in a narrow overlay. */
39
+ private fitHints;
30
40
  isOpen(): boolean;
31
41
  open(): void;
32
42
  close(): void;
@@ -1,4 +1,4 @@
1
- // graph-overlay.ts — the alt+g GRAPH navigator overlay for `crtr surface attach` (Unit Q).
1
+ // graph-overlay.ts — the configurable GRAPH navigator overlay for `crtr surface attach`.
2
2
  //
3
3
  // Native reimplementation of canvas-nav.ts's GRAPH modal as a pi-tui OVERLAY
4
4
  // (the viewer has no pi extension host). `tui.showOverlay(this, …)` mounts this
@@ -6,12 +6,9 @@
6
6
  // CAPTURES keyboard focus (mirrors extension-dialogs.ts);
7
7
  // `OverlayHandle.hide()` tears it down and restores focus to the editor. While
8
8
  // shown, every key routes to handleInput() — a fold-aware NERDTree of the
9
- // active CanvasSource subscription graph with the canvas-nav keymap:
10
- // j/k move · h/l fold/ascend-descend · g/G top/bottom · focus (swap into
11
- // this pane) · m focus manager · x kill (y/n confirm) · esc close.
12
- // `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
13
- // canvas-nav). Model/render uses the CanvasSource snapshot built for this
14
- // overlay; keys are decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
9
+ // active CanvasSource subscription graph. Actions and footer hints resolve from
10
+ // the crouter binding catalog; input is decoded with pi-tui's canonical matcher
11
+ // (kitty/CSI-u aware).
15
12
  //
16
13
  // Enter/m/x SHELL `crtr` out-of-process (node focus / node lifecycle close) — the viewer
17
14
  // itself never spawns pi or opens a session (the §0 one-writer invariant). It
@@ -30,6 +27,7 @@
30
27
  // graph structure + node metadata ONLY.
31
28
  import { execFile } from 'node:child_process';
32
29
  import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
30
+ import { formatBinding, matchesPiTuiInput, } from '../../core/keybindings/index.js';
33
31
  import { fullName } from '../../core/canvas/index.js';
34
32
  import { activeFaultForDisplay } from '../../core/canvas/render.js';
35
33
  import { readFault } from '../../core/runtime/fault.js';
@@ -43,18 +41,34 @@ import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, acti
43
41
  // clipped. margin keeps it off the screen edges.
44
42
  const HEIGHT_PCT = 0.72;
45
43
  const OVERLAY_OPTIONS = { anchor: 'center', width: '72%', minWidth: 48, maxHeight: '72%', margin: 1 };
46
- // Viewer-specific hint: nav-model's shared GRAPH_HINT advertises "e expand",
47
- // but that key is the tmux pane-expand prefix-bind canvas-nav installs in its
48
- // host pane — the attach overlay has no such bind and swallows `e`, so the key
49
- // would do nothing here. Drop it.
50
- const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · x kill · m mgr · esc${RESET}`;
51
- // REMOTE mode drops focus/kill/mgr — Enter/m/x no-op there (see the file header).
52
- const GRAPH_HINT_REMOTE = `${DIM}jk move · hl fold · esc${RESET}`;
44
+ // The footer is width-budgeted (fitHints drops WHOLE trailing entries that
45
+ // won't fit rather than mid-clipping the tail). List the keys a user most needs
46
+ // to discover first dismiss (the only way out) then the remapped local
47
+ // actions so they survive at the narrowest overlay width; routine navigation
48
+ // trails and is the first to be dropped.
49
+ const GRAPH_PRIMARY_LOCAL_HINTS = [
50
+ ['crtr.graph.dismiss', 'dismiss'],
51
+ ['crtr.graph.focus', 'focus'],
52
+ ['crtr.graph.close-subtree', 'close'],
53
+ ['crtr.graph.focus-manager', 'manager'],
54
+ ];
55
+ const GRAPH_PRIMARY_REMOTE_HINTS = [
56
+ ['crtr.graph.dismiss', 'dismiss'],
57
+ ];
58
+ const GRAPH_NAV_HINTS = [
59
+ ['crtr.graph.down', 'down'],
60
+ ['crtr.graph.up', 'up'],
61
+ ['crtr.graph.first', 'first'],
62
+ ['crtr.graph.last', 'last'],
63
+ ['crtr.graph.collapse-or-manager', 'fold/manager'],
64
+ ['crtr.graph.expand-or-child', 'expand/child'],
65
+ ];
53
66
  export class GraphOverlay {
54
67
  tui;
55
68
  self;
56
69
  getAsks;
57
70
  palette;
71
+ bindings;
58
72
  source;
59
73
  handle;
60
74
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -66,7 +80,7 @@ export class GraphOverlay {
66
80
  cursorId;
67
81
  /** Viewport scroll offset (row index of the top visible tree row). */
68
82
  scrollTop = 0;
69
- /** Transient y/n confirm gate (kill). */
83
+ /** Transient close confirmation gate. */
70
84
  pendingConfirm;
71
85
  snapshot;
72
86
  snapshotSeq = 0;
@@ -75,14 +89,43 @@ export class GraphOverlay {
75
89
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
76
90
  * commands against a remote graph row. See the file header. */
77
91
  remote;
78
- constructor(tui, self, getAsks, palette, source = localCanvasSource) {
92
+ constructor(tui, self, getAsks, palette, bindings, source = localCanvasSource) {
79
93
  this.tui = tui;
80
94
  this.self = self;
81
95
  this.getAsks = getAsks;
82
96
  this.palette = palette;
97
+ this.bindings = bindings;
83
98
  this.source = source;
84
99
  this.remote = source instanceof RemoteCanvasSource;
85
100
  }
101
+ setBindings(bindings) {
102
+ this.bindings = bindings;
103
+ if (this.handle !== undefined)
104
+ this.tui.requestRender();
105
+ }
106
+ matches(id, data) {
107
+ return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
108
+ }
109
+ hint(entries) {
110
+ return entries.flatMap(([id, label]) => this.bindings.gestures(id).length === 0 ? [] : [`${formatBinding(this.bindings, id)} ${label}`]);
111
+ }
112
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
113
+ * overflow `budget` visible columns rather than letting the border mid-clip
114
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
115
+ * discoverable even in a narrow overlay. */
116
+ fitHints(entries, budget) {
117
+ const SEP = 3; // visible width of ' · '
118
+ const out = [];
119
+ let used = 0;
120
+ for (const piece of this.hint(entries)) {
121
+ const add = (out.length === 0 ? 0 : SEP) + visibleWidth(piece);
122
+ if (used + add > budget)
123
+ break;
124
+ out.push(piece);
125
+ used += add;
126
+ }
127
+ return out.join(' · ');
128
+ }
86
129
  isOpen() {
87
130
  return this.handle !== undefined;
88
131
  }
@@ -341,8 +384,14 @@ export class GraphOverlay {
341
384
  ? `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`
342
385
  : `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
343
386
  const hint = this.pendingConfirm !== undefined
344
- ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
345
- : this.remote ? GRAPH_HINT_REMOTE : GRAPH_HINT;
387
+ ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}${this.hint([
388
+ ['crtr.graph.confirm.accept', 'accept'],
389
+ ['crtr.graph.confirm.cancel', 'cancel'],
390
+ ]).join(' · ')}${RESET}`
391
+ : `${DIM}${this.fitHints([
392
+ ...(this.remote ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
393
+ ...GRAPH_NAV_HINTS,
394
+ ], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
346
395
  // Frame the panel: title in the top border, hint in the bottom border, tree
347
396
  // rows boxed with themed side bars (truncate/pad to the inner width so the
348
397
  // right edge stays flush and opaque).
@@ -385,18 +434,19 @@ export class GraphOverlay {
385
434
  dispatch(data) {
386
435
  // y/n confirm gate takes precedence over everything.
387
436
  if (this.pendingConfirm !== undefined) {
388
- if (matchesKey(data, 'y')) {
437
+ if (this.matches('crtr.graph.confirm.accept', data)) {
389
438
  const act = this.pendingConfirm.action;
390
439
  this.pendingConfirm = undefined;
391
440
  act();
392
441
  }
393
442
  else {
394
- this.pendingConfirm = undefined; // any other key cancels
443
+ // Preserve GRAPH's modal fallback: any non-accept key cancels close.
444
+ this.pendingConfirm = undefined;
395
445
  }
396
446
  this.tui.requestRender();
397
447
  return;
398
448
  }
399
- if (matchesKey(data, 'escape')) {
449
+ if (this.matches('crtr.graph.dismiss', data)) {
400
450
  this.close();
401
451
  return;
402
452
  }
@@ -427,29 +477,29 @@ export class GraphOverlay {
427
477
  }
428
478
  return idx;
429
479
  };
430
- if (matchesKey(data, 'j')) {
480
+ if (this.matches('crtr.graph.down', data)) {
431
481
  idx = step(idx, 1);
432
482
  this.cursorId = rows[idx]?.id ?? this.cursorId;
433
483
  this.tui.requestRender();
434
484
  return;
435
485
  }
436
- if (matchesKey(data, 'k')) {
486
+ if (this.matches('crtr.graph.up', data)) {
437
487
  idx = step(idx, -1);
438
488
  this.cursorId = rows[idx]?.id ?? this.cursorId;
439
489
  this.tui.requestRender();
440
490
  return;
441
491
  }
442
- if (matchesKey(data, 'g')) {
492
+ if (this.matches('crtr.graph.first', data)) {
443
493
  this.cursorId = rows[edge(1)]?.id ?? this.cursorId;
444
494
  this.tui.requestRender();
445
495
  return;
446
496
  }
447
- if (matchesKey(data, 'shift+g')) {
497
+ if (this.matches('crtr.graph.last', data)) {
448
498
  this.cursorId = rows[edge(-1)]?.id ?? this.cursorId;
449
499
  this.tui.requestRender();
450
500
  return;
451
501
  }
452
- if (matchesKey(data, 'h')) {
502
+ if (this.matches('crtr.graph.collapse-or-manager', data)) {
453
503
  if (cur !== undefined && cur.hasKids && !cur.collapsed) {
454
504
  this.userCollapsed.add(cur.id);
455
505
  this.userExpanded.delete(cur.id);
@@ -463,7 +513,7 @@ export class GraphOverlay {
463
513
  }
464
514
  return;
465
515
  }
466
- if (matchesKey(data, 'l')) {
516
+ if (this.matches('crtr.graph.expand-or-child', data)) {
467
517
  if (cur !== undefined && cur.collapsed && cur.hasKids) {
468
518
  this.userExpanded.add(cur.id);
469
519
  this.userCollapsed.delete(cur.id);
@@ -477,12 +527,12 @@ export class GraphOverlay {
477
527
  }
478
528
  return;
479
529
  }
480
- // Enter/m/x drive local lifecycle/focus (`crtr node focus` / `crtr node
530
+ // Focus/manager/close actions drive local lifecycle/focus (`crtr node focus` / `crtr node
481
531
  // lifecycle close`) against `this.cursorId` — a REMOTE graph row's id has no
482
532
  // relationship to this machine's canvas.db (or, worse, collides with one
483
533
  // that does), so all three no-op in remote mode instead of shelling a local
484
- // mutation command against a remote id. GRAPH_HINT_REMOTE drops their hints.
485
- if (matchesKey(data, 'enter')) {
534
+ // mutation command against a remote id. Their hints are omitted remotely.
535
+ if (this.matches('crtr.graph.focus', data)) {
486
536
  if (this.remote)
487
537
  return;
488
538
  if (this.cursorId !== undefined)
@@ -490,7 +540,7 @@ export class GraphOverlay {
490
540
  this.close();
491
541
  return;
492
542
  }
493
- if (matchesKey(data, 'm')) {
543
+ if (this.matches('crtr.graph.focus-manager', data)) {
494
544
  if (this.remote)
495
545
  return;
496
546
  const mgr = snapshot?.managerOf.get(this.self);
@@ -500,7 +550,7 @@ export class GraphOverlay {
500
550
  }
501
551
  return;
502
552
  }
503
- if (matchesKey(data, 'x')) {
553
+ if (this.matches('crtr.graph.close-subtree', data)) {
504
554
  if (this.remote)
505
555
  return;
506
556
  const target = this.cursorId ?? this.self;
@@ -510,7 +560,7 @@ export class GraphOverlay {
510
560
  this.tui.requestRender();
511
561
  return;
512
562
  }
513
- // Modal: swallow everything else (incl. the `e` tmux-menu bind) so a stray
563
+ // Modal: swallow everything else so a stray
514
564
  // key never reaches the editor underneath.
515
565
  }
516
566
  // -------------------------------------------------------------------------
@@ -30,6 +30,10 @@ export interface InputControllerHooks {
30
30
  /** OPTIONAL: copy the last assistant message to the clipboard — forwarded to
31
31
  * the slash context so `/copy` works. Wired by attach-cmd (it owns ChatView). */
32
32
  onCopy?: () => void;
33
+ /** OPTIONAL: recolor the editor's name chip — forwarded to the slash context
34
+ * so `/color` paints it (`null` clears). Wired by attach-cmd (it owns the
35
+ * TitledEditor). */
36
+ onColor?: (color: string | null) => void;
33
37
  /** OPTIONAL: Ctrl+O / app.tools.expand — flip global tool-output expansion in
34
38
  * the render layer (ChatView owns the components). Absent → the key no-ops. */
35
39
  onToggleToolsExpand?: () => void;
@@ -51,6 +55,13 @@ export interface InputControllerHooks {
51
55
  openLoginPicker?: () => void;
52
56
  /** OPTIONAL: open the native /logout provider picker (viewer-local auth flow). */
53
57
  openLogoutPicker?: () => void;
58
+ /** OPTIONAL: an EXPLICIT model choice was just issued (native /model picker
59
+ * select, or `/model <arg>`) — fired alongside the `set_model` frame, with
60
+ * the exact string requested. attach-cmd arms its persist-as-default write
61
+ * on this and completes it when the broker's `model_changed` confirms the
62
+ * switch (so a rejected spec never gets persisted). Absent → picks stay
63
+ * session-only (e.g. a remote attach, which must not write local config). */
64
+ onExplicitModelSet?: (model: string) => void;
54
65
  }
55
66
  export declare class InputController {
56
67
  private readonly tui;
@@ -59,6 +70,10 @@ export declare class InputController {
59
70
  private readonly hooks;
60
71
  /** The currently-rendered blocking dialog, if any (for supersede/dismiss). */
61
72
  private dialog;
73
+ /** The request id of {@link dialog} — so a broker-driven `extension_ui_dismiss`
74
+ * tears down ONLY the matching overlay (correlated dismissal), never an
75
+ * unrelated one. */
76
+ private dialogId;
62
77
  /** The currently-open native picker overlay, if any (for supersede/dismiss). */
63
78
  private picker;
64
79
  /** Monotonic open counter — a slow read-op reply whose `seq` is no longer the
@@ -75,9 +90,17 @@ export declare class InputController {
75
90
  private emitCommand;
76
91
  /** Render extension UI requests from the broker. Blocking dialogs route their
77
92
  * answer back to the broker; non-blocking notify requests become the viewer's
78
- * normal notice line. Supersedes any dialog already on screen (e.g. a re-route
79
- * on control handoff) by dismissing it first. */
93
+ * normal notice line and NOTHING else a notify must never tear down an
94
+ * unrelated blocking dialog whose broker request is still pending. A dialog is
95
+ * torn down only by (a) a NEW blocking dialog superseding it (control handoff
96
+ * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
97
+ * from the broker (the request was aborted/timed out out-of-band). */
80
98
  attachDialog(req: RpcExtensionUIRequest): void;
99
+ /** Broker-driven correlated dismissal: the broker resolved this request itself
100
+ * (its request was aborted out-of-band — e.g. a local OAuth loopback callback
101
+ * won the race against this manual-paste dialog — or its timeout fired). Tear
102
+ * down ONLY the overlay whose id matches; leave any other dialog untouched. */
103
+ dismissDialog(id: string): void;
81
104
  /** Feed the latest engine state so read-only commands (e.g. `/session`) report
82
105
  * current values. Optional; the controller works without it. */
83
106
  setState(state: BrokerSnapshot['state']): void;
@@ -100,6 +123,9 @@ export declare class InputController {
100
123
  * once we focus an inner child. A throwing builder is caught into a notice (m5). */
101
124
  private showPicker;
102
125
  private openModelPicker;
126
+ /** Unlike the other pickers, the session picker opens IMMEDIATELY — its
127
+ * component owns async loaders (with a Loading header), so the multi-second
128
+ * session-dir scan runs off the open path instead of blocking it. */
103
129
  private openSessionPicker;
104
130
  private openTreePicker;
105
131
  private openForkPicker;