@crouton-kit/crouter 0.3.68 → 0.3.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -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
+ }
@@ -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;
@@ -52,7 +52,7 @@ const MAX_FRAME_BYTES = BROKER_READ_CAPS.maxLineBytes - 4 * 1024 * 1024;
52
52
  * see slash-commands.ts's REMOTE_SAFE_BUILTIN_NAMES gate). */
53
53
  const DRIVE_FRAME_TYPES = new Set([
54
54
  'prompt', 'steer', 'follow_up', 'abort', 'bash',
55
- 'set_model', 'cycle_model', 'cycle_thinking', 'set_thinking_level', 'dequeue',
55
+ 'set_model', 'cycle_model', 'cycle_ladder', 'cycle_thinking', 'set_thinking_level', 'dequeue',
56
56
  'set_auto_retry', 'set_auto_compaction', 'compact', 'new_session',
57
57
  'switch_session', 'fork', 'set_session_name', 'navigate_tree', 'reload',
58
58
  'export', 'clone', 'share', 'request_control', 'release_control', 'reload_auth',
@@ -64,6 +64,10 @@ export class InputController {
64
64
  hooks;
65
65
  /** The currently-rendered blocking dialog, if any (for supersede/dismiss). */
66
66
  dialog;
67
+ /** The request id of {@link dialog} — so a broker-driven `extension_ui_dismiss`
68
+ * tears down ONLY the matching overlay (correlated dismissal), never an
69
+ * unrelated one. */
70
+ dialogId;
67
71
  /** The currently-open native picker overlay, if any (for supersede/dismiss). */
68
72
  picker;
69
73
  /** Monotonic open counter — a slow read-op reply whose `seq` is no longer the
@@ -92,8 +96,11 @@ export class InputController {
92
96
  }
93
97
  /** Render extension UI requests from the broker. Blocking dialogs route their
94
98
  * answer back to the broker; non-blocking notify requests become the viewer's
95
- * normal notice line. Supersedes any dialog already on screen (e.g. a re-route
96
- * on control handoff) by dismissing it first. */
99
+ * normal notice line and NOTHING else a notify must never tear down an
100
+ * unrelated blocking dialog whose broker request is still pending. A dialog is
101
+ * torn down only by (a) a NEW blocking dialog superseding it (control handoff
102
+ * re-route), (b) its own answer, or (c) a correlated {@link dismissDialog}
103
+ * from the broker (the request was aborted/timed out out-of-band). */
97
104
  attachDialog(req) {
98
105
  if (req.method === 'notify') {
99
106
  const prefix = req.notifyType === 'error' ? 'Error: ' : req.notifyType === 'warning' ? 'Warning: ' : '';
@@ -102,12 +109,26 @@ export class InputController {
102
109
  return;
103
110
  }
104
111
  this.dialog?.dismiss();
112
+ this.dialogId = req.id;
105
113
  this.dialog = renderDialog(this.tui, req, (resp) => {
106
114
  this.dialog = undefined;
115
+ this.dialogId = undefined;
107
116
  this.hooks.onDialogResponse(resp);
108
117
  }, this.keybindings);
109
118
  this.tui.requestRender();
110
119
  }
120
+ /** Broker-driven correlated dismissal: the broker resolved this request itself
121
+ * (its request was aborted out-of-band — e.g. a local OAuth loopback callback
122
+ * won the race against this manual-paste dialog — or its timeout fired). Tear
123
+ * down ONLY the overlay whose id matches; leave any other dialog untouched. */
124
+ dismissDialog(id) {
125
+ if (this.dialogId !== id)
126
+ return;
127
+ this.dialog?.dismiss();
128
+ this.dialog = undefined;
129
+ this.dialogId = undefined;
130
+ this.tui.requestRender();
131
+ }
111
132
  /** Feed the latest engine state so read-only commands (e.g. `/session`) report
112
133
  * current values. Optional; the controller works without it. */
113
134
  setState(state) {
@@ -174,6 +195,7 @@ export class InputController {
174
195
  onGraph: this.hooks.onGraph,
175
196
  onQuit: this.hooks.onQuit,
176
197
  onCopy: this.hooks.onCopy,
198
+ onColor: this.hooks.onColor,
177
199
  openModelPicker: () => void this.openModelPicker(),
178
200
  openSessionPicker: () => void this.openSessionPicker(),
179
201
  openForkPicker: () => void this.openForkPicker(),
@@ -182,6 +204,7 @@ export class InputController {
182
204
  openScopedModelsPicker: () => void this.openScopedModelsPicker(),
183
205
  openLoginPicker: this.hooks.openLoginPicker ? () => this.hooks.openLoginPicker() : undefined,
184
206
  openLogoutPicker: this.hooks.openLogoutPicker ? () => this.hooks.openLogoutPicker() : undefined,
207
+ onExplicitModelSet: this.hooks.onExplicitModelSet,
185
208
  };
186
209
  }
187
210
  // -------------------------------------------------------------------------
@@ -299,10 +322,20 @@ export class InputController {
299
322
  this.tui.requestRender();
300
323
  }
301
324
  openModelPicker() {
302
- return this.openPicker({ type: 'list_models' }, 'list_models', 'model', (data, close) => buildModelPicker(this.tui, data, this.send, close));
325
+ return this.openPicker({ type: 'list_models' }, 'list_models', 'model', (data, close) => buildModelPicker(this.tui, data, this.send, close, this.hooks.onExplicitModelSet));
303
326
  }
327
+ /** Unlike the other pickers, the session picker opens IMMEDIATELY — its
328
+ * component owns async loaders (with a Loading header), so the multi-second
329
+ * session-dir scan runs off the open path instead of blocking it. */
304
330
  openSessionPicker() {
305
- return this.openPicker({ type: 'list_sessions', scope: 'cwd' }, 'list_sessions', 'session', (data, close) => buildSessionPicker(this.tui, data, this.hooks.onRequest, this.keybindings, this.send, close));
331
+ const request = this.hooks.onRequest;
332
+ if (request === undefined) {
333
+ this.notify("The session picker isn't available in this viewer");
334
+ return Promise.resolve();
335
+ }
336
+ this.openSeq++; // supersede any in-flight fetch-first picker open
337
+ this.showPicker((controls) => buildSessionPicker(this.tui, this.state?.sessionFile, request, this.keybindings, this.send, controls.close));
338
+ return Promise.resolve();
306
339
  }
307
340
  openTreePicker() {
308
341
  return this.openPicker({ type: 'get_tree' }, 'get_tree', 'tree', (data, close) => buildTreePicker(this.tui, data, this.send, close));
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // Scope (v1): only ```mermaid fences. The wider termrender directive vocabulary
20
20
  // is deliberately out of scope — see the base prompt's Diagrams note.
21
- import { isRendererReady, renderMarkdown } from '@crouton-kit/humanloop';
21
+ import { ensureRenderer, isRendererReady, renderMarkdown } from '@crouton-kit/humanloop';
22
22
  /** A complete mermaid fence: ```mermaid (opening info string) ... closing ```.
23
23
  * Line-anchored (m flag), non-greedy body (captured). An UNCLOSED fence (still
24
24
  * streaming) does not match, so mid-stream text is left raw and only settles to
@@ -71,7 +71,10 @@ function stripBlankEdges(lines) {
71
71
  * diagrams have no width control and overflow rather than truncate), which is
72
72
  * stripped here, so an approximate pane width is fine. */
73
73
  export function transformMermaidFences(text, width) {
74
- if (!textHasMermaid(text) || !isRendererReady())
74
+ if (!textHasMermaid(text))
75
+ return text;
76
+ ensureRenderer();
77
+ if (!isRendererReady())
75
78
  return text;
76
79
  return text.replace(MERMAID_FENCE, (whole, body) => {
77
80
  try {
@@ -1,5 +1,5 @@
1
1
  import { type Component, type KeybindingsManager, type TUI } from '@earendil-works/pi-tui';
2
- import type { ClientToBroker, GetSettingsData, GetTreeData, ListModelsData, ListScopedModelsData, ListSessionsData } from '../../core/runtime/broker-protocol.js';
2
+ import type { ClientToBroker, GetSettingsData, GetTreeData, ListModelsData, ListScopedModelsData } from '../../core/runtime/broker-protocol.js';
3
3
  import type { ReadOpRequest } from './view-socket.js';
4
4
  /** A built picker overlay: the `component` to show, and the `focus` target the
5
5
  * caller must `setFocus` (the component itself, or the inner list that owns
@@ -28,12 +28,13 @@ type Notify = (message: string) => void;
28
28
  /** `/model` (and ctrl+l) — the real model selector with search + scope toggle.
29
29
  * Select → `set_model` with the resolved `provider/id` (the broker's
30
30
  * `findModelSpec` requires that form). */
31
- export declare function buildModelPicker(tui: TUI, data: ListModelsData, send: Send, close: Close): Picker;
32
- /** `/resume` — the real session selector. Reconstructs from async loaders that
33
- * fetch each scope on demand (cwd is pre-fetched so the current-session marker
34
- * shows immediately; the `all` scope loads only on toggle). Select
35
- * `switch_session`. Rename/delete are out of scope (no command frame). */
36
- export declare function buildSessionPicker(tui: TUI, prefetchedCwd: ListSessionsData, request: Request, keybindings: KeybindingsManager, send: Send, close: Close): Picker;
31
+ export declare function buildModelPicker(tui: TUI, data: ListModelsData, send: Send, close: Close, onSelect?: (model: string) => void): Picker;
32
+ /** `/resume` — the real session selector. Opens INSTANTLY and reconstructs from
33
+ * async loaders that fetch each scope on demand the component's own Loading
34
+ * header covers the scan (listing a large session dir takes seconds, so the
35
+ * fetch must stay off the open path). Select → `switch_session`. Rename/delete
36
+ * are out of scope (no command frame). */
37
+ export declare function buildSessionPicker(tui: TUI, currentSessionFile: string | undefined, request: Request, keybindings: KeybindingsManager, send: Send, close: Close): Picker;
37
38
  /** `/tree` — the real tree navigator (pure data). Select → `navigate_tree`.
38
39
  * Label-edit is out of scope (no command frame) → no `onLabelChange`. */
39
40
  export declare function buildTreePicker(tui: TUI, data: GetTreeData, send: Send, close: Close): Picker;
@@ -51,7 +51,7 @@ function modelRegistryAdapter(all, available) {
51
51
  /** `/model` (and ctrl+l) — the real model selector with search + scope toggle.
52
52
  * Select → `set_model` with the resolved `provider/id` (the broker's
53
53
  * `findModelSpec` requires that form). */
54
- export function buildModelPicker(tui, data, send, close) {
54
+ export function buildModelPicker(tui, data, send, close, onSelect) {
55
55
  const all = data.models;
56
56
  const availableSet = new Set(data.availableIds);
57
57
  const available = all.filter((m) => availableSet.has(`${m.provider}/${m.id}`));
@@ -60,29 +60,23 @@ export function buildModelPicker(tui, data, send, close) {
60
60
  : undefined;
61
61
  const component = new ModelSelectorComponent(tui, current, SettingsManager.inMemory(), modelRegistryAdapter(all, available), data.scopedModels, (model) => {
62
62
  send({ type: 'set_model', model: `${model.provider}/${model.id}` });
63
+ onSelect?.(`${model.provider}/${model.id}`);
63
64
  close();
64
65
  }, () => close());
65
66
  return { component, focus: component };
66
67
  }
67
- /** `/resume` — the real session selector. Reconstructs from async loaders that
68
- * fetch each scope on demand (cwd is pre-fetched so the current-session marker
69
- * shows immediately; the `all` scope loads only on toggle). Select
70
- * `switch_session`. Rename/delete are out of scope (no command frame). */
71
- export function buildSessionPicker(tui, prefetchedCwd, request, keybindings, send, close) {
68
+ /** `/resume` — the real session selector. Opens INSTANTLY and reconstructs from
69
+ * async loaders that fetch each scope on demand the component's own Loading
70
+ * header covers the scan (listing a large session dir takes seconds, so the
71
+ * fetch must stay off the open path). Select → `switch_session`. Rename/delete
72
+ * are out of scope (no command frame). */
73
+ export function buildSessionPicker(tui, currentSessionFile, request, keybindings, send, close) {
72
74
  const revive = (d) => d.sessions.map((s) => ({ ...s, created: new Date(s.created), modified: new Date(s.modified) }));
73
- let firstCwd = true;
74
- const currentLoader = async () => {
75
- if (firstCwd) {
76
- firstCwd = false;
77
- return revive(prefetchedCwd);
78
- }
79
- return revive((await request({ type: 'list_sessions', scope: 'cwd' })));
80
- };
81
- const allLoader = async () => revive((await request({ type: 'list_sessions', scope: 'all' })));
82
- const component = new SessionSelectorComponent(currentLoader, allLoader, (path) => {
75
+ const loader = (scope) => async () => revive((await request({ type: 'list_sessions', scope })));
76
+ const component = new SessionSelectorComponent(loader('cwd'), loader('all'), (path) => {
83
77
  send({ type: 'switch_session', path });
84
78
  close();
85
- }, () => close(), () => close(), () => tui.requestRender(), { showRenameHint: false, keybindings: keybindings }, prefetchedCwd.currentSessionFile);
79
+ }, () => close(), () => close(), () => tui.requestRender(), { showRenameHint: false, keybindings: keybindings }, currentSessionFile);
86
80
  return { component, focus: component };
87
81
  }
88
82
  /** `/tree` — the real tree navigator (pure data). Select → `navigate_tree`.
@@ -141,6 +135,8 @@ export function buildSettingsPicker(data, send, close, notify) {
141
135
  onClearOnShrinkChange: out,
142
136
  onShowTerminalProgressChange: out,
143
137
  onWarningsChange: out,
138
+ onShowCacheMissNoticesChange: out,
139
+ onOutputPadChange: out,
144
140
  };
145
141
  const component = new SettingsSelectorComponent(data.settings, callbacks);
146
142
  return { component, focus: component.getSettingsList() };
@@ -46,10 +46,19 @@ export interface SlashContext {
46
46
  openTreePicker?: () => void;
47
47
  openSettingsPicker?: () => void;
48
48
  openScopedModelsPicker?: () => void;
49
+ /** An EXPLICIT model choice was just issued via `/model <arg>` — mirrors
50
+ * `InputControllerHooks.onExplicitModelSet` (the picker path fires it from
51
+ * `buildModelPicker`). attach-cmd persists the choice as the node kind's
52
+ * default once the broker confirms with `model_changed`. */
53
+ onExplicitModelSet?: (model: string) => void;
49
54
  /** Open the native /login provider selector. */
50
55
  openLoginPicker?: () => void;
51
56
  /** Open the native /logout provider selector. */
52
57
  openLogoutPicker?: () => void;
58
+ /** `/color` — recolor the editor's name chip (the "bubble" /rename names).
59
+ * `null` clears back to the default chip. Wired by attach-cmd (it owns the
60
+ * TitledEditor); absent → only the tmux pane outline is painted. */
61
+ onColor?: (color: string | null) => void;
53
62
  }
54
63
  /** Canvas slash-commands — implemented NATIVE in the viewer, the ONLY live host:
55
64
  * the broker binds mode:'print', so broker pi-extensions never run their