@north-light/crouter 0.3.159 → 0.3.161

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 (74) hide show
  1. package/dist/api/dto/nodes.d.ts +3 -0
  2. package/dist/builtin-memory/internal/INDEX.md +1 -0
  3. package/dist/builtin-memory/internal/workflow-codification.md +82 -0
  4. package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
  6. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
  7. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
  8. package/dist/clients/attach/__tests__/markdown-headings.test.js +8 -0
  9. package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -3
  10. package/dist/clients/attach/chrome/bash-jobs.js +15 -8
  11. package/dist/clients/attach/chrome/canvas-panels.js +10 -3
  12. package/dist/clients/attach/chrome/roster.d.ts +7 -2
  13. package/dist/clients/attach/chrome/roster.js +87 -3
  14. package/dist/clients/attach/input/controller.d.ts +3 -0
  15. package/dist/clients/attach/input/controller.js +1 -0
  16. package/dist/clients/attach/render/assistant-message.d.ts +13 -0
  17. package/dist/clients/attach/render/assistant-message.js +55 -0
  18. package/dist/clients/attach/render/chat-view.d.ts +29 -0
  19. package/dist/clients/attach/render/chat-view.js +233 -12
  20. package/dist/clients/attach/render/context-message.js +1 -1
  21. package/dist/clients/attach/render/edit-diff.js +20 -1
  22. package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
  23. package/dist/clients/attach/render/glyph-codepoints.js +42 -0
  24. package/dist/clients/attach/render/markdown-headings.js +15 -4
  25. package/dist/clients/attach/render/tool-calls.d.ts +32 -0
  26. package/dist/clients/attach/render/tool-calls.js +251 -0
  27. package/dist/clients/attach/session/input-wiring.d.ts +3 -0
  28. package/dist/clients/attach/session/input-wiring.js +3 -0
  29. package/dist/clients/attach/slash/dispatch.d.ts +4 -0
  30. package/dist/clients/attach/slash/dispatch.js +8 -0
  31. package/dist/clients/attach/viewer.js +699 -695
  32. package/dist/commands/cron.js +2 -2
  33. package/dist/commands/node.js +3 -3
  34. package/dist/commands/search.js +1 -1
  35. package/dist/commands/sys/config.js +10 -2
  36. package/dist/commands/sys/doctor.js +22 -1
  37. package/dist/commands/sys/setup-core.d.ts +5 -1
  38. package/dist/commands/sys/setup-core.js +16 -2
  39. package/dist/commands/sys/setup-wizard.d.ts +11 -0
  40. package/dist/commands/sys/setup-wizard.js +111 -6
  41. package/dist/core/__tests__/tmux-surface.test.js +5 -2
  42. package/dist/core/canvas/canvas.js +1 -1
  43. package/dist/core/canvas/labels.d.ts +6 -3
  44. package/dist/core/canvas/labels.js +9 -4
  45. package/dist/core/canvas/nav-render.d.ts +6 -0
  46. package/dist/core/canvas/nav-render.js +8 -0
  47. package/dist/core/canvas/types.d.ts +6 -0
  48. package/dist/core/config.js +3 -2
  49. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  50. package/dist/core/keybindings/catalog.d.ts +2 -2
  51. package/dist/core/keybindings/catalog.js +2 -0
  52. package/dist/core/preview-registry.d.ts +5 -0
  53. package/dist/core/preview-registry.js +10 -0
  54. package/dist/core/runtime/canvas-extensions.d.ts +4 -0
  55. package/dist/core/runtime/canvas-extensions.js +4 -0
  56. package/dist/core/runtime/naming-persist.d.ts +4 -3
  57. package/dist/core/runtime/naming-persist.js +10 -9
  58. package/dist/core/runtime/naming.d.ts +28 -8
  59. package/dist/core/runtime/naming.js +175 -32
  60. package/dist/core/runtime/nerd-font.d.ts +28 -0
  61. package/dist/core/runtime/nerd-font.js +127 -0
  62. package/dist/core/runtime/tmux.js +16 -0
  63. package/dist/daemon/api/map.js +2 -0
  64. package/dist/pi-extensions/naming-tool.d.ts +24 -0
  65. package/dist/pi-extensions/naming-tool.js +67 -0
  66. package/dist/types.d.ts +5 -0
  67. package/dist/types.js +1 -0
  68. package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
  69. package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
  70. package/dist/web-client/index.html +2 -2
  71. package/dist/web-client/sw.js +1 -1
  72. package/package.json +8 -8
  73. package/runtime.lock.json +161 -161
  74. package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
@@ -30,10 +30,12 @@ import { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../../core/runtime/situation
30
30
  import { transformMessageDiagrams } from './diagram.js';
31
31
  import { styleAttachMarkdownHeadings, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-headings.js';
32
32
  import { createCrtrEditToolDefinition } from './edit-diff.js';
33
+ import { FOLDED_STATE_KEY, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
33
34
  import { ringCompletionBell } from '../chrome/completion-bell.js';
34
35
  import { extractFilePaths } from '../visible-paths.js';
35
36
  import { FrozenHistoryComponent, keepWhenCondensed, liveRegionStart } from './frozen-history.js';
36
- import { readConfig } from '../../../core/config.js';
37
+ import { ViewerAssistantMessageComponent } from './assistant-message.js';
38
+ import { readConfig, updateRawConfigAtomically } from '../../../core/config.js';
37
39
  import { DEFAULT_CONDENSED_HISTORY, DEFAULT_LIVE_CYCLES } from '../../../types.js';
38
40
  /** customType stamped on the injected `<crtr-context>` bearings block (mirrors
39
41
  * CONTEXT_INTRO_CUSTOM_TYPE in canvas-context-intro.ts — kept as a local literal
@@ -76,6 +78,27 @@ function readCondensedHistory() {
76
78
  return DEFAULT_CONDENSED_HISTORY;
77
79
  }
78
80
  }
81
+ /** The user's `fold_finished_tools` setting — the persisted starting state of the
82
+ * fold view setting. Toggling it in a viewer writes it back (`persistFoldFinishedTools`),
83
+ * so the choice carries into later chats and later viewers. */
84
+ function readFoldFinishedTools() {
85
+ try {
86
+ return readConfig('user').fold_finished_tools;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /** Persist the fold view setting to user config. Best-effort: a viewer must never
93
+ * die because the config file is unwritable — the in-viewer toggle still applies. */
94
+ function persistFoldFinishedTools(value) {
95
+ try {
96
+ updateRawConfigAtomically('user', (latest) => ({ ...latest, fold_finished_tools: value }));
97
+ }
98
+ catch {
99
+ // ignored — the fold still applies to this viewer.
100
+ }
101
+ }
79
102
  /** A component supports the Ctrl+O global tool-output toggle. Mirrors pi's
80
103
  * `isExpandable` duck-type (interactive-mode.js): any rendered child carrying a
81
104
  * `setExpanded(boolean)` method — tool executions, custom/compaction/branch/skill
@@ -87,6 +110,98 @@ function isExpandable(obj) {
87
110
  'setExpanded' in obj &&
88
111
  typeof obj.setExpanded === 'function');
89
112
  }
113
+ /** Every SGR escape, for measuring a line's visible content. */
114
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
115
+ /** Same match, with the SGR parameter list captured for rewriting. */
116
+ const SGR_PARAMS_RE = /\x1b\[([0-9;]*)m/g;
117
+ /** Drop every background-color SGR parameter from a line, keeping foreground and
118
+ * text styles. The minimized fold is a dense index of what ran, so the tool's
119
+ * pending/success/error background band (pi paints it across the full pane width)
120
+ * reads as noise there — one call per line, unhighlighted. `0` (full reset) is
121
+ * kept: it clears the background too, and nothing follows it to re-set one. */
122
+ function stripBackground(line) {
123
+ return line.replace(SGR_PARAMS_RE, (_whole, params) => {
124
+ const parts = params.split(';');
125
+ const kept = [];
126
+ for (let i = 0; i < parts.length; i++) {
127
+ const code = Number(parts[i] === '' ? 0 : parts[i]);
128
+ // Extended background (`48;5;n` / `48;2;r;g;b`) — skip the code AND its args.
129
+ if (code === 48) {
130
+ const mode = Number(parts[i + 1]);
131
+ i += mode === 5 ? 2 : mode === 2 ? 4 : 1;
132
+ continue;
133
+ }
134
+ // Standard (40-47) and bright (100-107) backgrounds. `49` (reset to the
135
+ // default background) is KEPT — it is the thing we want, and dropping it
136
+ // would let a neighbouring row's background bleed into this line.
137
+ if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107))
138
+ continue;
139
+ kept.push(String(code));
140
+ }
141
+ return kept.length > 0 ? `\x1b[${kept.join(';')}m` : '';
142
+ });
143
+ }
144
+ /** A tool component that can fold itself down to its CALL. pi's `ToolExecutionComponent`
145
+ * only knows expanded/collapsed, so the minimized state is crtr's — and the tool's own
146
+ * renderCall owns its folded form: every crtr-owned definition (read/write/edit/bash)
147
+ * reads the fold flag off its shared render-state bag and emits ONLY its one-line
148
+ * `<icon> <tool> <path>` header while folded. Folded render then paints just the call
149
+ * component (result and Box chrome dropped), so a wrapped path keeps its wrap rows
150
+ * naturally — no measuring or slicing of rendered ANSI rows. Tools without a crtr
151
+ * definition are not foldable and always render in full. */
152
+ /** A one-line separator that appears only while its adjacent tool group is
153
+ * folded. It stays in the transcript so toggling folded tools reflows the same
154
+ * message/tool boundary without rebuilding the chat. */
155
+ class FoldedToolSeparator extends Spacer {
156
+ folded = false;
157
+ separatesToolGroup = false;
158
+ setFolded(folded) {
159
+ this.folded = folded;
160
+ this.refresh();
161
+ }
162
+ setSeparatesToolGroup(separatesToolGroup) {
163
+ this.separatesToolGroup = separatesToolGroup;
164
+ this.refresh();
165
+ }
166
+ refresh() {
167
+ this.setLines(this.folded && this.separatesToolGroup ? 1 : 0);
168
+ }
169
+ }
170
+ class MinimizableToolComponent extends ToolExecutionComponent {
171
+ minimized = false;
172
+ foldable = false;
173
+ /** Only tools whose renderCall is fold-aware (the crtr-owned definitions) may
174
+ * fold; chat-view marks them at construction. */
175
+ setFoldable(foldable) {
176
+ this.foldable = foldable;
177
+ }
178
+ /** Flip the fold: expose it on the definitions' shared render-state bag, then
179
+ * re-run `updateDisplay()` (the same path pi's own `setExpanded` uses) so the
180
+ * fold-aware renderCall rebuilds the call component in its new form. NOT
181
+ * `invalidate()` — pi's `ToolExecutionComponent.invalidate` recurses through
182
+ * its renderer children and killed the viewer process. */
183
+ setMinimized(minimized) {
184
+ if (!this.foldable || this.minimized === minimized)
185
+ return;
186
+ this.minimized = minimized;
187
+ const internals = this;
188
+ internals.rendererState[FOLDED_STATE_KEY] = minimized;
189
+ internals.updateDisplay();
190
+ }
191
+ render(width) {
192
+ if (!this.minimized)
193
+ return super.render(width);
194
+ const call = this
195
+ .callRendererComponent;
196
+ if (!call)
197
+ return super.render(width);
198
+ // trimEnd drops right-hand padding, which only existed to carry the
199
+ // background band these lines no longer paint.
200
+ return call.render(width)
201
+ .filter((line) => line.replace(ANSI_RE, '').trim().length > 0)
202
+ .map((line) => stripBackground(line).trimEnd());
203
+ }
204
+ }
90
205
  const defaultErrorStyle = (s) => `\x1b[31m${s}\x1b[39m`;
91
206
  /** Keep settled conversation history out of picker/editor render hot paths. The
92
207
  * root TUI still compares the cached lines, but it no longer re-renders every
@@ -146,7 +261,12 @@ export class ChatView {
146
261
  imageWidthCells;
147
262
  hideThinking;
148
263
  hiddenThinkingLabel;
264
+ /** Ctrl+O: pi's two-state tool-output expansion. */
149
265
  toolOutputExpanded;
266
+ /** The view setting (Alt+C → w → f): fold every SETTLED tool call to its single
267
+ * call line. Independent of `toolOutputExpanded` — it survives an expand/collapse
268
+ * and only ever applies to tool calls that are done. */
269
+ foldSettledTools = readFoldFinishedTools();
150
270
  onFooterEvent;
151
271
  /** Theme-derived colorizers for the activity/status area. */
152
272
  spinnerStyle;
@@ -157,6 +277,12 @@ export class ChatView {
157
277
  /** The assistant message component currently being streamed (between
158
278
  * message_start and message_end for an assistant turn). */
159
279
  streamingComponent;
280
+ /** The separator immediately after the live assistant component. It becomes
281
+ * visible when that response has visible prose or thinking and folded tools. */
282
+ streamingToolSeparator;
283
+ /** Content classes for each separator, so Ctrl+T can add/remove thinking gaps
284
+ * without rebuilding the transcript. */
285
+ foldedToolSeparatorContents = new Map();
160
286
  /** Tool-call id → its execution component, for the duration of the call. */
161
287
  pendingTools = new Map();
162
288
  /** The `!` bash component currently streaming (between bash_start and bash_end). */
@@ -286,15 +412,20 @@ export class ChatView {
286
412
  snapshot.state.isStreaming &&
287
413
  message.stopReason !== 'aborted' &&
288
414
  message.stopReason !== 'error';
415
+ let separator;
289
416
  if (isStreamingTail) {
290
- this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
417
+ this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
291
418
  this.append(this.streamingComponent);
292
419
  this.streamingComponent.updateContent(styleAttachMessageMarkdown(message));
420
+ separator = this.streamingToolSeparator = this.appendFoldedToolSeparator();
293
421
  }
294
422
  else {
295
- this.addMessageToChat(message);
423
+ separator = this.appendAssistantMessage(message);
296
424
  }
297
- for (const content of this.assistantToolCalls(message)) {
425
+ const toolCalls = this.assistantToolCalls(message);
426
+ if (separator)
427
+ this.setAssistantSeparatorContent(separator, message);
428
+ for (const content of toolCalls) {
298
429
  this.pushTranscriptValue(content.arguments);
299
430
  const component = this.makeToolComponent(content.name, content.id, content.arguments);
300
431
  this.append(component);
@@ -327,6 +458,10 @@ export class ChatView {
327
458
  for (const [id, component] of renderedPendingTools) {
328
459
  this.pendingTools.set(id, component);
329
460
  }
461
+ // A rebuild constructs fresh components, which are born unfolded; re-apply the
462
+ // setting so a reconnect/welcome does not silently unfold the transcript.
463
+ if (this.foldSettledTools)
464
+ this.applyToolDisplay();
330
465
  // If we attached mid-turn, the `agent_start` that spins up the working loader
331
466
  // already fired before we connected — restore it from the snapshot's streaming
332
467
  // state so an actively-working node shows the spinner right away.
@@ -403,6 +538,7 @@ export class ChatView {
403
538
  this.historyContainer.removeChild(this.streamingComponent);
404
539
  this.streamingComponent = undefined;
405
540
  }
541
+ this.streamingToolSeparator = undefined;
406
542
  this.pendingTools.clear();
407
543
  this.setActivity(undefined);
408
544
  this.settleBashComponent();
@@ -424,13 +560,32 @@ export class ChatView {
424
560
  * are frozen as text, so they are unaffected by design. */
425
561
  toggleToolsExpanded() {
426
562
  this.toolOutputExpanded = !this.toolOutputExpanded;
563
+ this.applyToolDisplay();
564
+ return this.toolOutputExpanded;
565
+ }
566
+ /** The `fold finished tool calls` view setting (`/fold-tools`, Alt+C → w → f).
567
+ * Returns the new state so the caller can surface a notice. */
568
+ toggleFoldSettledTools() {
569
+ this.foldSettledTools = !this.foldSettledTools;
570
+ persistFoldFinishedTools(this.foldSettledTools);
571
+ this.applyToolDisplay();
572
+ return this.foldSettledTools;
573
+ }
574
+ /** Push both display settings onto every rendered component. A tool call still in
575
+ * `pendingTools` is in flight, so it is never folded — you keep watching live
576
+ * output, and it folds on its own once `tool_execution_end` settles it. */
577
+ applyToolDisplay() {
578
+ const inFlight = new Set(this.pendingTools.values());
579
+ this.updateFoldedToolSeparators(inFlight);
427
580
  for (const child of this.historyContainer.children) {
581
+ if (child instanceof MinimizableToolComponent) {
582
+ child.setMinimized(this.foldSettledTools && !inFlight.has(child));
583
+ }
428
584
  if (isExpandable(child))
429
585
  child.setExpanded(this.toolOutputExpanded);
430
586
  }
431
587
  this.historyContainer.markDirty();
432
588
  this.tui.requestRender();
433
- return this.toolOutputExpanded;
434
589
  }
435
590
  /** Ctrl+T / app.thinking.toggle — flip thinking-block visibility and apply it to
436
591
  * every assistant message (the in-flight streaming bubble is itself a child of
@@ -441,6 +596,9 @@ export class ChatView {
441
596
  if (child instanceof AssistantMessageComponent)
442
597
  child.setHideThinkingBlock(this.hideThinking);
443
598
  }
599
+ for (const [separator, content] of this.foldedToolSeparatorContents) {
600
+ separator.setSeparatesToolGroup(content.hasText || (!this.hideThinking && content.hasThinking));
601
+ }
444
602
  this.historyContainer.markDirty();
445
603
  this.tui.requestRender();
446
604
  return this.hideThinking;
@@ -485,9 +643,11 @@ export class ChatView {
485
643
  this.addMessageToChat(event.message);
486
644
  }
487
645
  else if (event.message.role === 'assistant') {
488
- this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
646
+ this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
489
647
  this.append(this.streamingComponent);
490
648
  this.streamingComponent.updateContent(styleAttachMessageMarkdown(event.message));
649
+ this.streamingToolSeparator = this.appendFoldedToolSeparator();
650
+ this.setAssistantSeparatorContent(this.streamingToolSeparator, event.message);
491
651
  }
492
652
  break;
493
653
  }
@@ -501,7 +661,10 @@ export class ChatView {
501
661
  const updated = styleAttachMessageMarkdown(transformMessageDiagrams(event.message, this.renderWidth()));
502
662
  this.streamingComponent.updateContent(updated);
503
663
  this.historyContainer.markDirty(this.streamingComponent);
504
- for (const content of this.assistantToolCalls(event.message)) {
664
+ const toolCalls = this.assistantToolCalls(event.message);
665
+ if (this.streamingToolSeparator)
666
+ this.setAssistantSeparatorContent(this.streamingToolSeparator, event.message);
667
+ for (const content of toolCalls) {
505
668
  this.pushTranscriptValue(content.arguments);
506
669
  const existing = this.pendingTools.get(content.id);
507
670
  if (existing) {
@@ -564,6 +727,7 @@ export class ChatView {
564
727
  }
565
728
  this.historyContainer.markDirty(this.streamingComponent);
566
729
  this.streamingComponent = undefined;
730
+ this.streamingToolSeparator = undefined;
567
731
  }
568
732
  break;
569
733
  }
@@ -594,6 +758,10 @@ export class ChatView {
594
758
  if (component) {
595
759
  component.updateResult({ ...event.result, isError: event.isError });
596
760
  this.pendingTools.delete(event.toolCallId);
761
+ // Settled → it now obeys the fold setting it was exempt from while live.
762
+ if (this.foldSettledTools)
763
+ component.setMinimized(true);
764
+ this.updateFoldedToolSeparators();
597
765
  this.historyContainer.markDirty(component);
598
766
  }
599
767
  break;
@@ -787,8 +955,7 @@ export class ChatView {
787
955
  break;
788
956
  }
789
957
  case 'assistant': {
790
- // Settled history message — render its supported diagram fences inline too.
791
- this.append(new AssistantMessageComponent(styleAttachMessageMarkdown(transformMessageDiagrams(message, this.renderWidth())), this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel));
958
+ this.appendAssistantMessage(message);
792
959
  break;
793
960
  }
794
961
  // toolResult is rendered inline with its tool call (handled in applySnapshot).
@@ -799,15 +966,67 @@ export class ChatView {
799
966
  // -------------------------------------------------------------------------
800
967
  // Helpers
801
968
  // -------------------------------------------------------------------------
969
+ /** Render an assistant reply plus its initially-hidden boundary separator. */
970
+ appendAssistantMessage(message) {
971
+ this.append(new ViewerAssistantMessageComponent(styleAttachMessageMarkdown(transformMessageDiagrams(message, this.renderWidth())), this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel));
972
+ const separator = this.appendFoldedToolSeparator();
973
+ this.setAssistantSeparatorContent(separator, message);
974
+ return separator;
975
+ }
976
+ /** Add a separator in transcript order now; it renders only after a following
977
+ * tool settles in the folded view. */
978
+ appendFoldedToolSeparator() {
979
+ const separator = new FoldedToolSeparator();
980
+ this.append(separator);
981
+ return separator;
982
+ }
983
+ /** Make each separator visible only if its immediately-following tool call is
984
+ * actually folded; in-flight calls remain flush with their live output. */
985
+ updateFoldedToolSeparators(inFlight = new Set(this.pendingTools.values())) {
986
+ const children = this.historyContainer.children;
987
+ for (let i = 0; i < children.length; i++) {
988
+ const separator = children[i];
989
+ if (!(separator instanceof FoldedToolSeparator))
990
+ continue;
991
+ const followingTool = children[i + 1];
992
+ separator.setFolded(this.foldSettledTools &&
993
+ followingTool instanceof MinimizableToolComponent &&
994
+ !inFlight.has(followingTool));
995
+ this.historyContainer.markDirty(separator);
996
+ }
997
+ }
998
+ /** Track whether an assistant response contains prose or thinking, then make
999
+ * its separator eligible only for content the current Ctrl+T setting shows. */
1000
+ setAssistantSeparatorContent(separator, message) {
1001
+ const content = message.content;
1002
+ const parts = Array.isArray(content) ? content : [];
1003
+ const hasText = parts.some((part) => typeof part === 'object' &&
1004
+ part !== null &&
1005
+ part.type === 'text' &&
1006
+ typeof part.text === 'string' &&
1007
+ part.text.trim() !== '');
1008
+ const hasThinking = parts.some((part) => typeof part === 'object' &&
1009
+ part !== null &&
1010
+ part.type === 'thinking' &&
1011
+ typeof part.thinking === 'string' &&
1012
+ part.thinking.trim() !== '');
1013
+ this.foldedToolSeparatorContents.set(separator, { hasText, hasThinking });
1014
+ separator.setSeparatesToolGroup(hasText || (!this.hideThinking && hasThinking));
1015
+ }
802
1016
  /** Tool component with native viewer renderers for crouter-owned tool output. */
803
1017
  makeToolComponent(name, id, args) {
804
1018
  const toolDefinition = name === 'bash'
805
- ? createCrtrBashToolDefinition(args)
1019
+ ? (createCrtrBashToolDefinition(args) ?? createCrtrPlainBashToolDefinition(this.cwd))
806
1020
  : name === 'edit'
807
1021
  ? createCrtrEditToolDefinition(this.cwd)
808
- : undefined;
1022
+ : name === 'read'
1023
+ ? createCrtrReadToolDefinition(this.cwd)
1024
+ : name === 'write'
1025
+ ? createCrtrWriteToolDefinition(this.cwd)
1026
+ : undefined;
809
1027
  let component;
810
- component = new ToolExecutionComponent(name, id, args, { showImages: this.showImages, imageWidthCells: this.imageWidthCells }, toolDefinition, this.componentTui(() => component), this.cwd);
1028
+ component = new MinimizableToolComponent(name, id, args, { showImages: this.showImages, imageWidthCells: this.imageWidthCells }, toolDefinition, this.componentTui(() => component), this.cwd);
1029
+ component.setFoldable(toolDefinition !== undefined);
811
1030
  component.setExpanded(this.toolOutputExpanded);
812
1031
  return component;
813
1032
  }
@@ -832,6 +1051,8 @@ export class ChatView {
832
1051
  this.setActivity(undefined);
833
1052
  this.settleBashComponent();
834
1053
  this.streamingComponent = undefined;
1054
+ this.streamingToolSeparator = undefined;
1055
+ this.foldedToolSeparatorContents.clear();
835
1056
  this.pendingTools.clear();
836
1057
  this.lastAssistantText = undefined;
837
1058
  this.transcriptTexts.length = 0;
@@ -8,7 +8,7 @@
8
8
  // `renderContextMessage`) and reuses the same collapsed/expand shape for
9
9
  // situational context so both render consistently: collapsed → one dim line;
10
10
  // expanded (Ctrl+O) → label + full wrapped body. It implements `setExpanded`,
11
- // so the Ctrl+O tool-output toggle (ChatView.toggleToolsExpanded →
11
+ // so the Ctrl+O tool-display cycle (ChatView.cycleToolDisplay →
12
12
  // isExpandable) folds it alongside tool output.
13
13
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
14
14
  /** Default label/hint for the `crtr-context` bearings block. Mirrors the
@@ -14,6 +14,8 @@
14
14
  // ANSI re-parsing of pi's output is ever needed.
15
15
  import { createEditToolDefinition } from '@earendil-works/pi-coding-agent';
16
16
  import { Container, Spacer, Text } from '@earendil-works/pi-tui';
17
+ import { TOOL_ICON } from '../../../core/preview-registry.js';
18
+ import { foldedFileCallLine, isFoldedContext, prefixToolText } from './tool-calls.js';
17
19
  const FALLBACK_ADDED = { r: 181, g: 189, b: 104 }; // pi dark green
18
20
  const FALLBACK_REMOVED = { r: 204, g: 102, b: 102 }; // pi dark red
19
21
  const FALLBACK_CARD_BG = { r: 40, g: 50, b: 40 }; // pi dark toolSuccessBg
@@ -219,6 +221,12 @@ function restyleEditCallBox(box, theme) {
219
221
  // (mirrors pi's getEditHeaderBg), so tinted lines restore to that color.
220
222
  box.addChild(buildDiffBody(preview.diff, theme, theme.getBgAnsi('toolSuccessBg')));
221
223
  }
224
+ /** Put the edit glyph in front of pi's `edit <path>` header line. The header is
225
+ * the first child of the card and the ONLY line a folded edit keeps, so this is
226
+ * what the human sees when the diff is collapsed away. */
227
+ function prefixEditHeaderIcon(box, theme) {
228
+ prefixToolText(box.children[0], `${theme.fg('accent', TOOL_ICON.edit)} `);
229
+ }
222
230
  /**
223
231
  * Attach-viewer tool definition for `edit`: pi's builtin definition drives
224
232
  * all state (preview computation, header, success/error bg), then the diff
@@ -233,10 +241,20 @@ export function createCrtrEditToolDefinition(cwd) {
233
241
  parameters: {},
234
242
  renderShell: 'self',
235
243
  renderCall(args, theme, context) {
244
+ // Folded: the renderer owns its folded form — one line, `<icon> edit <path>`,
245
+ // no diff. The real edit card persists in context.state.callComponent (the
246
+ // builtin prefers it over a non-Box lastComponent), so unfolding restores it.
247
+ if (isFoldedContext(context)) {
248
+ const a = args;
249
+ const raw = typeof a?.file_path === 'string' ? a.file_path : typeof a?.path === 'string' ? a.path : '';
250
+ return new Text(foldedFileCallLine('edit', raw, theme, context.cwd ?? cwd), 0, 0);
251
+ }
236
252
  const component = builtin.renderCall(args, theme, context);
237
253
  const callBox = context.state.callComponent;
238
- if (callBox)
254
+ if (callBox) {
239
255
  restyleEditCallBox(callBox, theme);
256
+ prefixEditHeaderIcon(callBox, theme);
257
+ }
240
258
  return component;
241
259
  },
242
260
  renderResult(result, options, theme, context) {
@@ -246,6 +264,7 @@ export function createCrtrEditToolDefinition(cwd) {
246
264
  // builtin renderResult re-ran buildEditCallComponent (pi styling) —
247
265
  // restyle the refreshed body.
248
266
  restyleEditCallBox(callBox, theme);
267
+ prefixEditHeaderIcon(callBox, theme);
249
268
  return component;
250
269
  }
251
270
  // Fallback path (no call box state, e.g. a bare result replay): render
@@ -0,0 +1,3 @@
1
+ /** Replace every private-use `U+XXXX` reference in `text` with the character it
2
+ * names. Non-PUA references and malformed digits pass through untouched. */
3
+ export declare function expandGlyphCodepoints(text: string): string;
@@ -0,0 +1,42 @@
1
+ // glyph-codepoints.ts — render `U+XXXX` as the glyph it names, for the private-
2
+ // use areas only.
3
+ //
4
+ // Nerd Font icons live in the Unicode private-use areas, and PUA codepoints are
5
+ // effectively absent from model tokenizer vocabularies: an agent cannot type one
6
+ // into its reply (the character never becomes tokens, so it is simply missing
7
+ // from the message) and cannot even perceive one in a prompt. Agents CAN name a
8
+ // codepoint accurately, so the viewer closes the gap at the other end — an agent
9
+ // writes `U+F188` and the reader sees the bug glyph.
10
+ //
11
+ // Scoped to the private-use areas on purpose. Those are exactly the codepoints
12
+ // an agent cannot write literally, so expanding them adds an ability rather than
13
+ // taking one away; every other codepoint reference (`U+0041`, `U+241C`) is left
14
+ // as text, because a message discussing Unicode wants the hex on screen and
15
+ // could have contained the character itself if it meant the character.
16
+ /** `U+` followed by 4-6 hex digits, not glued to a surrounding word. Case
17
+ * insensitive on both the prefix and the digits. */
18
+ const CODEPOINT = /\bU\+([0-9A-Fa-f]{4,6})\b/g;
19
+ /** Private-use areas: the BMP block plus planes 15 and 16. Nerd Fonts use the
20
+ * BMP block and, since v3, the supplementary block above U+F0000. */
21
+ function isPrivateUse(cp) {
22
+ return ((cp >= 0xe000 && cp <= 0xf8ff) ||
23
+ (cp >= 0xf0000 && cp <= 0xffffd) ||
24
+ (cp >= 0x100000 && cp <= 0x10fffd));
25
+ }
26
+ /** Replace every private-use `U+XXXX` reference in `text` with the character it
27
+ * names. Non-PUA references and malformed digits pass through untouched. */
28
+ export function expandGlyphCodepoints(text) {
29
+ if (!text.includes('U+') && !text.includes('u+'))
30
+ return text;
31
+ return text.replace(CODEPOINT, (match, hex) => {
32
+ const cp = Number.parseInt(hex, 16);
33
+ if (!isPrivateUse(cp))
34
+ return match;
35
+ try {
36
+ return String.fromCodePoint(cp);
37
+ }
38
+ catch {
39
+ return match;
40
+ }
41
+ });
42
+ }
@@ -7,6 +7,12 @@
7
7
  // owns parsing, wrapping, inline formatting, and H1/H2's established styling.
8
8
  // This stays attach-local — persisted messages and copied text remain ordinary
9
9
  // Markdown without terminal escape sequences.
10
+ //
11
+ // The same per-block copy carries the viewer's other source-text transform:
12
+ // private-use `U+XXXX` references become their glyphs (see glyph-codepoints.ts),
13
+ // so an agent that cannot type a Nerd Font character can still put one on
14
+ // screen.
15
+ import { expandGlyphCodepoints } from './glyph-codepoints.js';
10
16
  const ATX_HEADING = /^(\s{0,3})(#{3,6})(?:[ \t]+|$)(.*?)([ \t]+#+[ \t]*)?$/;
11
17
  const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/;
12
18
  // A constant-lightness ramp from warm yellow to cool blue, with saturation
@@ -49,6 +55,11 @@ export function styleAttachMarkdownHeadings(markdown) {
49
55
  return `${indent}${headingColor(hashes.length)(body)}`;
50
56
  }).join('\n');
51
57
  }
58
+ /** Every source-text transform the viewer applies to a Markdown-bearing block,
59
+ * in one place: heading hierarchy, then glyph expansion. */
60
+ function renderableText(markdown) {
61
+ return expandGlyphCodepoints(styleAttachMarkdownHeadings(markdown));
62
+ }
52
63
  /** Copy a pi message for terminal rendering and decorate only Markdown-bearing
53
64
  * text/thinking blocks. The broker's source event is never mutated. */
54
65
  export function styleAttachMessageMarkdown(message) {
@@ -56,7 +67,7 @@ export function styleAttachMessageMarkdown(message) {
56
67
  return message;
57
68
  const candidate = message;
58
69
  if (typeof candidate.content === 'string') {
59
- return { ...candidate, content: styleAttachMarkdownHeadings(candidate.content) };
70
+ return { ...candidate, content: renderableText(candidate.content) };
60
71
  }
61
72
  if (!Array.isArray(candidate.content))
62
73
  return message;
@@ -67,10 +78,10 @@ export function styleAttachMessageMarkdown(message) {
67
78
  return part;
68
79
  const block = part;
69
80
  if (block.type === 'text' && typeof block.text === 'string') {
70
- return { ...block, text: styleAttachMarkdownHeadings(block.text) };
81
+ return { ...block, text: renderableText(block.text) };
71
82
  }
72
83
  if (block.type === 'thinking' && typeof block.thinking === 'string') {
73
- return { ...block, thinking: styleAttachMarkdownHeadings(block.thinking) };
84
+ return { ...block, thinking: renderableText(block.thinking) };
74
85
  }
75
86
  return part;
76
87
  }),
@@ -78,5 +89,5 @@ export function styleAttachMessageMarkdown(message) {
78
89
  }
79
90
  /** Copy an expanded pi summary without changing its persisted source. */
80
91
  export function styleAttachSummaryMarkdown(message) {
81
- return { ...message, summary: styleAttachMarkdownHeadings(message.summary) };
92
+ return { ...message, summary: renderableText(message.summary) };
82
93
  }
@@ -0,0 +1,32 @@
1
+ type Theme = {
2
+ fg: (name: string, text: string) => string;
3
+ bold: (text: string) => string;
4
+ };
5
+ /** Key in a tool's shared render-state bag (`context.state`) carrying the
6
+ * viewer's fold. `MinimizableToolComponent` sets it; the fold-aware renderCalls
7
+ * below read it and emit ONLY their one-line header while it is true, which is
8
+ * what makes a folded card exactly `<icon> <tool> <path>` with no body — the
9
+ * renderer owns its folded form, no post-hoc slicing of rendered rows. */
10
+ export declare const FOLDED_STATE_KEY = "crtrFolded";
11
+ export declare function isFoldedContext(context: {
12
+ state?: object;
13
+ }): boolean;
14
+ /** The one-line folded form of a file tool's call: `<icon> <label> <path>`. */
15
+ export declare function foldedFileCallLine(tool: 'read' | 'write' | 'edit', rawPath: string, theme: Theme, cwd: string): string;
16
+ /** Prefix a pi-built `Text` in place (its `text` field is TS-private but a plain
17
+ * runtime property). Used where the header line is baked into the same string as
18
+ * a body we want to keep verbatim — write's syntax-highlighted content preview,
19
+ * edit's header inside its own card. Silently no-ops if pi's Text ever stops
20
+ * looking like this, so the worst case is a missing glyph, never a crash. */
21
+ export declare function prefixToolText(component: unknown, prefix: string): void;
22
+ /** Attach-viewer `read`: an icon + the path (plus any `:offset-limit` range).
23
+ * The call is a single line by construction, so a folded read shows exactly
24
+ * what it read and nothing else. */
25
+ export declare function createCrtrReadToolDefinition(cwd: string): unknown;
26
+ /** Attach-viewer `write`: pi's builtin call renderer (it owns the
27
+ * syntax-highlighted content preview and its expand hint) with the icon
28
+ * prefixed onto its header line. Folded, only that header survives. */
29
+ export declare function createCrtrWriteToolDefinition(cwd: string): unknown;
30
+ /** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
31
+ export declare function createCrtrPlainBashToolDefinition(cwd: string): unknown;
32
+ export {};