@north-light/crouter 0.3.163 → 0.3.164

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.
@@ -14,6 +14,7 @@ Open this dir whenever a task turns on understanding the runtime itself or chang
14
14
 
15
15
  - **nodes-and-canvas** — the agent-runtime model: nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle (mode + lifecycle axes), and revive (manual + daemon auto-revive).
16
16
  - **storage-tiers** — where every kind of state lives: the two tiers (scope root and canvas home) and their durability/ownership contracts.
17
+ - **memory-loading** — the memory load model: the two hooks (boot catalog, file-read), the four-rung ladder, gates, applies-to/read-when routing, boot-render ordering, and store mounting/precedence — read when diagnosing why a doc did or didn't load.
17
18
  - **agent-shaping** — the when-to-use-which layer over the four dials that shape a node: kinds (the builtin roster, sub-kinds, and custom personas), modes (base vs orchestrator), profiles, and the memory tiers (node/profile/project/user/builtin).
18
19
  - **workflow-codification** — the codification loop for repeatable tasks: do the work hands-on under a capture session, mine the HAR, then codify it as scripts (scope-root `scripts/`) + a slash-invokable memory doc, with the self-heal rule that a failed script is re-derived and rewritten.
19
20
  - **plugins** — authoring a crtr plugin: the plugin.json manifest, directory layout, scopes, install mechanics, versioning, command plugins (contributing top-level CLI commands via commands.json + one executable), and configured CLIs (contributing commands as definition + HTTP from a remote manifest, no executable).
@@ -0,0 +1,45 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When you need to know why a memory doc did or didn't load — or are deciding how a new doc should surface — this reference should be read because it names the hook, rung, gate, and ordering that produced the behavior, so you fix loading by turning the right dial instead of guessing at frontmatter.
4
+ short-form: The complete load model — two hooks (boot catalog, file-read), the four-rung ladder, gates, applies-to/read-when routing, boot-render ordering, and store mounting/precedence.
5
+ system-prompt-visibility: name
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # How memory loads
10
+
11
+ Every memory doc declares its own loading policy in frontmatter; the runtime never guesses. Loading is two hooks, one four-rung dial per hook, an optional gate, and structural ordering. The authoring contract (flags, routing-line craft) is `crtr memory write -h`; which scope to write to is `internal/agent-shaping`; physical paths are `internal/storage-tiers`. This doc is the mechanics between those: what actually fires, when, and in what order.
12
+
13
+ ## The two hooks
14
+
15
+ - **System prompt** (`system-prompt-visibility`) — the boot catalog assembled into every node's system prompt at revive.
16
+ - **File read** (`file-read-visibility`) — context attached to the workspace or to files. Two events: **workspace mount** during first-message assembly (docs routed `applies-to: "."`), and a later **matching file read** (docs routed by glob). Nothing positional fires from where the doc happens to sit on disk — only from its declared route.
17
+
18
+ Each doc sets both rungs explicitly; there is no kind-based default. Usually one axis carries a real rung and the other is `none`.
19
+
20
+ ## The rung ladder
21
+
22
+ `none` → `name` → `preview` → `content`, per hook:
23
+
24
+ - `none` — invisible on that hook; findable only by search/read. For archival docs and for the unused axis.
25
+ - `name` — the bare title. The practical floor: an agent can't reach for a doc it has never seen named.
26
+ - `preview` — name + the `when-and-why-to-read` routing line, rendered verbatim. The heart of progressive disclosure: one sentence that lets an agent decide whether to spend the read.
27
+ - `content` — the full body inlined. Reserved for always-relevant docs that are either a bullet's worth of text or a wholly-important operating guide (the root INDEX shape below).
28
+
29
+ `short-form` is **not** a rung and never enters agent context — it exists for humans browsing `crtr memory list`. Disclosure is name → routing line → whole thing; there is deliberately no "just the summary" level, because agents satisfice on abbreviations and never read the rest.
30
+
31
+ ## Gates and read-when
32
+
33
+ An optional `gate` predicates visibility on the node's own config — kind, mode, orchestration depth, scope, cwd — using the standard matcher vocabulary (`crtr memory write -h`). No gate means always eligible. Persona prose is just gated content-rung docs (`gate: {kind: developer, mode: base}`); guidance that should scale with effort is one predicate (`orchestration.depth: {gte: 2}`), not a mechanism. `read-when` additionally matches the *read file's* frontmatter on the file-read hook; it refines a route but never replaces the required `applies-to` boundary.
34
+
35
+ ## Store mounting and precedence
36
+
37
+ At boot/first-message assembly the runtime mounts: builtin docs, the user store (`~/.crouter/memory/`), the selected profile's store, and every project store — ancestor `.crouter/memory/` dirs walking up from cwd plus each project in the profile's purview. Physical duplicates are deduplicated; name collisions resolve nearest-first (project over profile over user over builtin), which is what lets a project doc shadow a builtin one.
38
+
39
+ A root `INDEX.md` is a workspace's front door: `system-prompt-visibility: none`, `file-read-visibility: content`, `applies-to: "."` — the operating guide loads when that workspace mounts, not in every boot catalog. Multiple mounted roots render broad-to-specific, each under its own envelope name.
40
+
41
+ ## Ordering
42
+
43
+ The boot render is structural, never a per-doc knob: docs group by rung (content bodies as prose, then previews, then names), and within a group order general-to-specific — scope first (builtin → user → profile → outermost project root → nearest), then tree position (higher directories before deeper), then filename. A numeric `NN-` filename prefix (stripped from the doc's name) is the sparing escape hatch when an exact sequence must be pinned.
44
+
45
+ `crtr memory lint` is the validator for all of the above: frontmatter schema, both rungs present, routes on every non-`none` file-read rung, rung-scaled body length, dangling `[[links]]`, and each profile-managed project's front door.
@@ -50,7 +50,7 @@ test('the attach viewer covers crtr bash tool calls and crtr-output messages nat
50
50
  const chatView = read(join(ROOT, 'clients', 'attach', 'render/chat-view.ts'));
51
51
  assert.match(chatView, /CRTR_OUTPUT_CUSTOM_TYPE/);
52
52
  assert.match(chatView, /new CrtrOutputMessageComponent\(/);
53
- assert.match(chatView, /createCrtrBashToolDefinition\(args\)/);
53
+ assert.match(chatView, /createCrtrBashToolDefinition\(args,\s*fold\)/);
54
54
  // MinimizableToolComponent is the viewer's ToolExecutionComponent subclass (it
55
55
  // adds the Ctrl+O minimized state); the crtr tool definition still threads through it.
56
56
  assert.match(chatView, /new MinimizableToolComponent\([\s\S]*toolDefinition as never/);
@@ -1,9 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { initTheme } from '@earendil-works/pi-coding-agent';
4
- import { FOLDED_STATE_KEY } from '../render/tool-calls.js';
5
3
  import { createCrtrEditToolDefinition } from '../render/edit-diff.js';
6
- initTheme(undefined, false);
4
+ import { FoldedToolCallController } from '../render/tool-calls.js';
7
5
  const theme = {
8
6
  fg: (_name, text) => text,
9
7
  bg: (_name, text) => text,
@@ -12,23 +10,26 @@ const theme = {
12
10
  getBgAnsi: (_name) => '',
13
11
  getColorMode: () => 'truecolor',
14
12
  };
15
- test('folded edit counts keep the executed diff when its speculative preview settles later', () => {
16
- const definition = createCrtrEditToolDefinition(process.cwd());
17
- const args = { path: 'example.ts', edits: [{ oldText: 'before', newText: 'after' }] };
18
- const state = {};
19
- const context = {
20
- args,
21
- argsComplete: false,
22
- cwd: process.cwd(),
23
- invalidate() { },
13
+ test('edit cards stream argument diffs, then keep the executed result authoritative', () => {
14
+ const fold = new FoldedToolCallController();
15
+ const view = createCrtrEditToolDefinition(process.cwd(), fold);
16
+ const definition = view.definition;
17
+ const context = { cwd: process.cwd(), isError: false };
18
+ const streamedArgs = { path: 'example.ts', edits: [{ oldText: 'before', newText: 'streaming' }] };
19
+ const streamed = definition.renderCall(streamedArgs, theme, context).render(80).join('\n');
20
+ assert.match(streamed, /before/);
21
+ assert.match(streamed, /streaming/);
22
+ view.observeResult({
23
+ content: [{ type: 'text', text: 'edited' }],
24
+ details: { diff: '-10 before\n+10 executed\n+11 extra' },
24
25
  isError: false,
25
- state,
26
- };
27
- definition.renderCall(args, theme, context);
28
- definition.renderResult({ content: [{ type: 'text', text: 'edited' }], details: { diff: '- 1 before\n+ 1 after' } }, { isPartial: false }, theme, context);
29
- state.callComponent.preview = { error: 'speculative preview completed after execution' };
30
- state[FOLDED_STATE_KEY] = true;
31
- const folded = definition.renderCall(args, theme, context).render(80).join('\n');
32
- assert.match(folded, /\+1/);
26
+ }, false);
27
+ const laterArgs = { path: 'example.ts', edits: [{ oldText: 'before', newText: 'stale preview' }] };
28
+ const settled = definition.renderCall(laterArgs, theme, context).render(80).join('\n');
29
+ assert.match(settled, /executed/);
30
+ assert.doesNotMatch(settled, /stale preview/);
31
+ fold.setFolded(true);
32
+ const folded = definition.renderCall(laterArgs, theme, context).render(80).join('\n');
33
+ assert.match(folded, /\+2/);
33
34
  assert.match(folded, /-1/);
34
35
  });
@@ -30,7 +30,7 @@ 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
+ import { FoldedToolCallController, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
34
34
  import { ringCompletionBell } from '../chrome/completion-bell.js';
35
35
  import { extractFilePaths } from '../visible-paths.js';
36
36
  import { FrozenHistoryComponent, keepWhenCondensed, liveRegionStart } from './frozen-history.js';
@@ -147,13 +147,10 @@ function stripBackground(line) {
147
147
  });
148
148
  }
149
149
  /** A tool component that can fold itself down to its CALL. pi's `ToolExecutionComponent`
150
- * only knows expanded/collapsed, so the minimized state is crtr's — and the tool's own
151
- * renderCall owns its folded form: every crtr-owned definition (read/write/edit/bash)
152
- * reads the fold flag off its shared render-state bag and emits ONLY its one-line
153
- * `<icon> <tool> <path>` header while folded. Folded render then paints just the call
154
- * component (result and Box chrome dropped), so a wrapped path keeps its wrap rows
155
- * naturally — no measuring or slicing of rendered ANSI rows. Tools without a crtr
156
- * definition are not foldable and always render in full. */
150
+ * only knows expanded/collapsed, so each crtr-owned definition receives a local fold
151
+ * controller and captures the compact call component it renders. Folded render paints
152
+ * that captured call directly, dropping result and Box chrome while letting a wrapped
153
+ * path keep its natural rows. Tools without a crtr definition remain in full. */
157
154
  /** A one-line separator that appears only while its adjacent tool group is
158
155
  * folded. It stays in the transcript so toggling folded tools reflows the same
159
156
  * message/tool boundary without rebuilding the chat. */
@@ -174,30 +171,36 @@ class FoldedToolSeparator extends Spacer {
174
171
  }
175
172
  class MinimizableToolComponent extends ToolExecutionComponent {
176
173
  minimized = false;
177
- foldable = false;
178
- /** Only tools whose renderCall is fold-aware (the crtr-owned definitions) may
179
- * fold; chat-view marks them at construction. */
180
- setFoldable(foldable) {
181
- this.foldable = foldable;
174
+ expandedState = false;
175
+ foldController;
176
+ resultObserver;
177
+ /** A foldable definition owns its folded call component; an edit definition
178
+ * may also observe results before the imported shell renders them. */
179
+ setToolView(foldController, resultObserver) {
180
+ this.foldController = foldController;
181
+ this.resultObserver = resultObserver;
182
182
  }
183
- /** Flip the fold: expose it on the definitions' shared render-state bag, then
184
- * re-run `updateDisplay()` (the same path pi's own `setExpanded` uses) so the
185
- * fold-aware renderCall rebuilds the call component in its new form. NOT
186
- * `invalidate()` — pi's `ToolExecutionComponent.invalidate` recurses through
187
- * its renderer children and killed the viewer process. */
183
+ updateResult(...args) {
184
+ this.resultObserver?.(args[0], args[1] ?? false);
185
+ super.updateResult(...args);
186
+ }
187
+ setExpanded(expanded) {
188
+ this.expandedState = expanded;
189
+ super.setExpanded(expanded);
190
+ }
191
+ /** Flip the local fold state, then use the imported component's public
192
+ * expansion setter to rebuild its call renderer at the current setting. */
188
193
  setMinimized(minimized) {
189
- if (!this.foldable || this.minimized === minimized)
194
+ if (!this.foldController || this.minimized === minimized)
190
195
  return;
191
196
  this.minimized = minimized;
192
- const internals = this;
193
- internals.rendererState[FOLDED_STATE_KEY] = minimized;
194
- internals.updateDisplay();
197
+ this.foldController.setFolded(minimized);
198
+ super.setExpanded(this.expandedState);
195
199
  }
196
200
  render(width) {
197
201
  if (!this.minimized)
198
202
  return super.render(width);
199
- const call = this
200
- .callRendererComponent;
203
+ const call = this.foldController?.callComponent();
201
204
  if (!call)
202
205
  return super.render(width);
203
206
  // trimEnd drops right-hand padding, which only existed to carry the
@@ -724,7 +727,7 @@ export class ChatView {
724
727
  this.pendingTools.clear();
725
728
  }
726
729
  else {
727
- // Args complete → trigger diff computation for edit tools.
730
+ // Finalize renderer caches now that every tool argument is complete.
728
731
  for (const component of this.pendingTools.values()) {
729
732
  component.setArgsComplete();
730
733
  this.historyContainer.markDirty(component);
@@ -1020,18 +1023,26 @@ export class ChatView {
1020
1023
  }
1021
1024
  /** Tool component with native viewer renderers for crouter-owned tool output. */
1022
1025
  makeToolComponent(name, id, args) {
1023
- const toolDefinition = name === 'bash'
1024
- ? (createCrtrBashToolDefinition(args) ?? createCrtrPlainBashToolDefinition(this.cwd))
1025
- : name === 'edit'
1026
- ? createCrtrEditToolDefinition(this.cwd)
1027
- : name === 'read'
1028
- ? createCrtrReadToolDefinition(this.cwd)
1029
- : name === 'write'
1030
- ? createCrtrWriteToolDefinition(this.cwd)
1031
- : undefined;
1026
+ const fold = new FoldedToolCallController();
1027
+ let resultObserver;
1028
+ let toolDefinition;
1029
+ if (name === 'bash') {
1030
+ toolDefinition = createCrtrBashToolDefinition(args, fold) ?? createCrtrPlainBashToolDefinition(this.cwd, fold);
1031
+ }
1032
+ else if (name === 'edit') {
1033
+ const edit = createCrtrEditToolDefinition(this.cwd, fold);
1034
+ toolDefinition = edit.definition;
1035
+ resultObserver = edit.observeResult;
1036
+ }
1037
+ else if (name === 'read') {
1038
+ toolDefinition = createCrtrReadToolDefinition(this.cwd, fold);
1039
+ }
1040
+ else if (name === 'write') {
1041
+ toolDefinition = createCrtrWriteToolDefinition(this.cwd, fold);
1042
+ }
1032
1043
  let component;
1033
1044
  component = new MinimizableToolComponent(name, id, args, { showImages: this.showImages, imageWidthCells: this.imageWidthCells }, toolDefinition, this.componentTui(() => component), this.cwd);
1034
- component.setFoldable(toolDefinition !== undefined);
1045
+ component.setToolView(toolDefinition === undefined ? undefined : fold, resultObserver);
1035
1046
  component.setExpanded(this.toolOutputExpanded);
1036
1047
  return component;
1037
1048
  }
@@ -1,4 +1,5 @@
1
1
  import { Box } from '@earendil-works/pi-tui';
2
+ import { FoldedToolCallController } from './tool-calls.js';
2
3
  type Theme = {
3
4
  fg: (name: string, text: string) => string;
4
5
  bg: (name: string, text: string) => string;
@@ -44,5 +45,5 @@ export declare class CrtrOutputMessageComponent extends Box {
44
45
  constructor(content: string, expanded: boolean, theme: Theme);
45
46
  setExpanded(expanded: boolean): void;
46
47
  }
47
- export declare function createCrtrBashToolDefinition(args: unknown): unknown | undefined;
48
+ export declare function createCrtrBashToolDefinition(args: unknown, fold?: FoldedToolCallController): unknown | undefined;
48
49
  export {};
@@ -1,7 +1,7 @@
1
1
  import { truncateToVisualLines } from '@earendil-works/pi-coding-agent';
2
2
  import { Box, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
3
3
  import { HELP_ICON, iconForPath, suppressOutputForPath, summarizePath, } from '../../../core/preview-registry.js';
4
- import { isFoldedContext } from './tool-calls.js';
4
+ import { FoldedToolCallController } from './tool-calls.js';
5
5
  // Hard cap on a collapsed crtr card's total rendered lines (rule 12,
6
6
  // inline-ui-placement): help synthesis or a normal truncated result never
7
7
  // shows more than this many lines total, blank spacer + status + body +
@@ -688,7 +688,7 @@ export class CrtrOutputMessageComponent extends Box {
688
688
  this.addChild(this.body);
689
689
  }
690
690
  }
691
- export function createCrtrBashToolDefinition(args) {
691
+ export function createCrtrBashToolDefinition(args, fold = new FoldedToolCallController()) {
692
692
  const command = commandFromArgs(args);
693
693
  if (!isCrtrBashCommand(command))
694
694
  return undefined;
@@ -712,9 +712,9 @@ export function createCrtrBashToolDefinition(args) {
712
712
  const prefix = isHelp ? `${icon} crtr help` : `${icon} crtr`;
713
713
  const styledPrefix = theme.fg('accent', theme.bold(prefix));
714
714
  const styledPath = path
715
- ? ` ${isFoldedContext(context) ? styleFoldedCrtrTokens(pathTokens, theme) : theme.fg('toolTitle', theme.bold(path))}`
715
+ ? ` ${fold.folded ? styleFoldedCrtrTokens(pathTokens, theme) : theme.fg('toolTitle', theme.bold(path))}`
716
716
  : '';
717
- return new Text(`${styledPrefix}${styledPath}`, 0, 0);
717
+ return fold.capture(new Text(`${styledPrefix}${styledPath}`, 0, 0));
718
718
  },
719
719
  renderResult(result, options, theme, context) {
720
720
  return new CrtrResultComponent(resultText(result), options, context, theme, command);
@@ -1,6 +1,21 @@
1
- /**
2
- * Attach-viewer tool definition for `edit`: pi's builtin definition drives
3
- * all state (preview computation, header, success/error bg), then the diff
4
- * body is re-skinned Claude-Code-style after every render pass.
5
- */
6
- export declare function createCrtrEditToolDefinition(cwd: string): unknown;
1
+ import { FoldedToolCallController } from './tool-calls.js';
2
+ type ToolResultLike = {
3
+ content?: Array<{
4
+ type?: unknown;
5
+ text?: unknown;
6
+ }>;
7
+ details?: {
8
+ diff?: unknown;
9
+ };
10
+ isError?: boolean;
11
+ };
12
+ export type EditResultObserver = (result: ToolResultLike, isPartial: boolean) => void;
13
+ export type CrtrEditToolView = {
14
+ definition: unknown;
15
+ observeResult: EditResultObserver;
16
+ };
17
+ /** Crouter owns edit presentation while the imported tool owns execution. The
18
+ * card has two explicit phases: streamed argument diff, then executed result
19
+ * diff. No asynchronous preview can write after settlement. */
20
+ export declare function createCrtrEditToolDefinition(cwd: string, fold?: FoldedToolCallController): CrtrEditToolView;
21
+ export {};
@@ -1,21 +1,13 @@
1
- // render/edit-diff.ts — Claude-Code-style diff rendering for the attach viewer.
1
+ // render/edit-diff.ts — crouter-owned edit presentation for the attach viewer.
2
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
- import { TOOL_ICON } from '../../../core/preview-registry.js';
18
- import { foldedFileCallLine, isFoldedContext, prefixToolText } from './tool-calls.js';
3
+ // The card renders replacement arguments synchronously while they stream, then
4
+ // switches to the executed tool result's full-file diff when the tool settles.
5
+ // Added and removed rows receive full-width tinted backgrounds, with the changed
6
+ // span inside a 1↔1 line modification emphasized in a brighter tint. The imported
7
+ // pi package remains responsible for edit execution, not viewer lifecycle.
8
+ import { generateDiffString } from '@earendil-works/pi-coding-agent';
9
+ import { Box, Container, Spacer, Text } from '@earendil-works/pi-tui';
10
+ import { FoldedToolCallController, foldedFileCallLine } from './tool-calls.js';
19
11
  const FALLBACK_ADDED = { r: 181, g: 189, b: 104 }; // pi dark green
20
12
  const FALLBACK_REMOVED = { r: 204, g: 102, b: 102 }; // pi dark red
21
13
  const FALLBACK_CARD_BG = { r: 40, g: 50, b: 40 }; // pi dark toolSuccessBg
@@ -116,6 +108,30 @@ function countDiffLines(diff) {
116
108
  }
117
109
  return { added, removed };
118
110
  }
111
+ /** Extract every complete replacement pair currently present in the streamed
112
+ * arguments. A pair becomes previewable as soon as both strings exist. */
113
+ function editPairs(args) {
114
+ const value = args;
115
+ if (Array.isArray(value?.edits)) {
116
+ return value.edits.flatMap((edit) => {
117
+ const pair = edit;
118
+ return typeof pair?.oldText === 'string' && typeof pair?.newText === 'string'
119
+ ? [{ oldText: pair.oldText, newText: pair.newText }]
120
+ : [];
121
+ });
122
+ }
123
+ return typeof value?.oldText === 'string' && typeof value?.newText === 'string'
124
+ ? [{ oldText: value.oldText, newText: value.newText }]
125
+ : [];
126
+ }
127
+ /** The pre-execution preview is deliberately replacement-local: it can update
128
+ * synchronously as arguments stream and never races filesystem mutation. */
129
+ function streamedArgumentDiff(args) {
130
+ const diffs = editPairs(args)
131
+ .map(({ oldText, newText }) => generateDiffString(oldText, newText).diff)
132
+ .filter((diff) => diff.trim().length > 0);
133
+ return diffs.length > 0 ? diffs.join('\n') : undefined;
134
+ }
119
135
  function replaceTabs(text) {
120
136
  return text.replace(/\t/g, ' ');
121
137
  }
@@ -216,91 +232,74 @@ function buildDiffBody(diffText, theme, restoreBg) {
216
232
  }
217
233
  return container;
218
234
  }
219
- // ---------------------------------------------------------------------------
220
- // Tool definition wrapper
221
- // ---------------------------------------------------------------------------
222
- /** Replace the pi-rendered diff body inside the edit call Box (children:
223
- * [header, Spacer, body]) with the full-background rendering. No-op when
224
- * there is no successful preview (header-only or error states keep pi's
225
- * rendering untouched). */
226
- function restyleEditCallBox(box, theme) {
227
- const preview = box.preview;
228
- if (!preview || 'error' in preview || box.children.length < 2)
229
- return;
230
- const header = box.children[0];
231
- box.clear();
232
- box.addChild(header);
233
- box.addChild(new Spacer(1));
234
- // With a successful preview the edit card bg is always toolSuccessBg
235
- // (mirrors pi's getEditHeaderBg), so tinted lines restore to that color.
236
- box.addChild(buildDiffBody(preview.diff, theme, theme.getBgAnsi('toolSuccessBg')));
235
+ class EditCallComponent extends Box {
236
+ constructor() {
237
+ super(1, 1, (text) => text);
238
+ }
239
+ update(header, diff, state, theme) {
240
+ const background = state.settled
241
+ ? state.isError ? 'toolErrorBg' : 'toolSuccessBg'
242
+ : 'toolPendingBg';
243
+ this.setBgFn((text) => theme.bg(background, text));
244
+ this.clear();
245
+ this.addChild(new Text(header, 0, 0));
246
+ if (!diff)
247
+ return;
248
+ this.addChild(new Spacer(1));
249
+ this.addChild(buildDiffBody(diff, theme, theme.getBgAnsi(background)));
250
+ }
237
251
  }
238
- /** Put the edit glyph in front of pi's `edit <path>` header line. The header is
239
- * the first child of the card and the ONLY line a folded edit keeps, so this is
240
- * what the human sees when the diff is collapsed away. */
241
- function prefixEditHeaderIcon(box, theme) {
242
- prefixToolText(box.children[0], `${theme.fg('accent', TOOL_ICON.edit)} `);
252
+ function editPath(args) {
253
+ const value = args;
254
+ return typeof value?.file_path === 'string' ? value.file_path : typeof value?.path === 'string' ? value.path : '';
243
255
  }
244
- /**
245
- * Attach-viewer tool definition for `edit`: pi's builtin definition drives
246
- * all state (preview computation, header, success/error bg), then the diff
247
- * body is re-skinned Claude-Code-style after every render pass.
248
- */
249
- export function createCrtrEditToolDefinition(cwd) {
250
- const builtin = createEditToolDefinition(cwd);
251
- return {
256
+ function errorResultText(result) {
257
+ return (result.content ?? [])
258
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
259
+ .map((part) => part.text)
260
+ .join('\n');
261
+ }
262
+ /** Crouter owns edit presentation while the imported tool owns execution. The
263
+ * card has two explicit phases: streamed argument diff, then executed result
264
+ * diff. No asynchronous preview can write after settlement. */
265
+ export function createCrtrEditToolDefinition(cwd, fold = new FoldedToolCallController()) {
266
+ const state = { settled: false, isError: false };
267
+ const call = new EditCallComponent();
268
+ const observeResult = (result, isPartial) => {
269
+ if (isPartial)
270
+ return;
271
+ state.settled = true;
272
+ state.isError = result.isError === true;
273
+ const resultDiff = result.details?.diff;
274
+ state.diff = !state.isError && typeof resultDiff === 'string' ? resultDiff : undefined;
275
+ };
276
+ const definition = {
252
277
  name: 'edit',
253
278
  label: 'edit',
254
- description: 'Render edit diffs with full-line backgrounds in the attach viewer.',
279
+ description: 'Render streamed edit arguments and the executed full-file diff in the attach viewer.',
255
280
  parameters: {},
256
281
  renderShell: 'self',
257
282
  renderCall(args, theme, context) {
258
- // Folded: the renderer owns its folded form — one line, `<icon> edit <path>`,
259
- // no diff. The real edit card persists in context.state.callComponent (the
260
- // builtin prefers it over a non-Box lastComponent), so unfolding restores it.
261
- if (isFoldedContext(context)) {
262
- const a = args;
263
- const raw = typeof a?.file_path === 'string' ? a.file_path : typeof a?.path === 'string' ? a.path : '';
264
- const preview = context.state.callComponent?.preview;
265
- const changes = context.isError
266
- ? undefined
267
- : context.state.settledChanges ?? (preview && !('error' in preview) ? countDiffLines(preview.diff) : undefined);
268
- return new Text(foldedFileCallLine('edit', raw, theme, context.cwd ?? cwd, changes), 0, 0);
269
- }
270
- const component = builtin.renderCall(args, theme, context);
271
- const callBox = context.state.callComponent;
272
- if (callBox) {
273
- restyleEditCallBox(callBox, theme);
274
- prefixEditHeaderIcon(callBox, theme);
275
- }
276
- return component;
283
+ const diff = state.settled ? state.diff : streamedArgumentDiff(args);
284
+ const changes = state.settled && !state.isError && state.diff ? countDiffLines(state.diff) : undefined;
285
+ const header = foldedFileCallLine('edit', editPath(args), theme, context.cwd ?? cwd, changes);
286
+ if (fold.folded)
287
+ return fold.capture(new Text(header, 0, 0));
288
+ call.update(header, diff, state, theme);
289
+ return fold.capture(call);
277
290
  },
278
- renderResult(result, options, theme, context) {
279
- const resultDiff = result?.details?.diff;
280
- if (!options?.isPartial) {
281
- if (!context.isError && typeof resultDiff === 'string')
282
- context.state.settledChanges = countDiffLines(resultDiff);
283
- else
284
- delete context.state.settledChanges;
285
- }
286
- const component = builtin.renderResult(result, options, theme, context);
287
- const callBox = context.state.callComponent;
288
- if (callBox) {
289
- // builtin renderResult re-ran buildEditCallComponent (pi styling) —
290
- // restyle the refreshed body.
291
- restyleEditCallBox(callBox, theme);
292
- prefixEditHeaderIcon(callBox, theme);
291
+ renderResult(result, _options, theme, context) {
292
+ const component = context.lastComponent instanceof Container ? context.lastComponent : new Container();
293
+ component.clear();
294
+ if (!context.isError)
293
295
  return component;
294
- }
295
- // Fallback path (no call box state, e.g. a bare result replay): render
296
- // the result diff standalone. No host Box here, so restore to default bg.
297
- if (!context.isError && typeof resultDiff === 'string') {
298
- const container = new Container();
299
- container.addChild(new Spacer(1));
300
- container.addChild(buildDiffBody(resultDiff, theme, '\x1b[49m'));
301
- return container;
296
+ const output = errorResultText(result);
297
+ if (output) {
298
+ component.addChild(new Spacer(1));
299
+ component.addChild(new Text(theme.fg('error', output), 1, 0));
302
300
  }
303
301
  return component;
304
302
  },
305
303
  };
304
+ return { definition, observeResult };
306
305
  }
@@ -1,16 +1,18 @@
1
+ import { type Component } from '@earendil-works/pi-tui';
1
2
  type Theme = {
2
3
  fg: (name: string, text: string) => string;
3
4
  bold: (text: string) => string;
4
5
  };
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;
6
+ /** Per-tool fold state owned by the attach viewer. Tool definitions capture the
7
+ * call component they render while folded, so the viewer never reaches into
8
+ * `ToolExecutionComponent`'s private renderer fields to recover it. */
9
+ export declare class FoldedToolCallController {
10
+ folded: boolean;
11
+ private component;
12
+ setFolded(folded: boolean): void;
13
+ capture<T extends Component>(component: T): T;
14
+ callComponent(): Component | undefined;
15
+ }
14
16
  export type FileLineChanges = {
15
17
  added: number;
16
18
  removed: number;
@@ -18,20 +20,14 @@ export type FileLineChanges = {
18
20
  /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
19
21
  * optionally followed by its green/red changed-line counts. */
20
22
  export declare function foldedFileCallLine(tool: 'read' | 'write' | 'edit', rawPath: string, theme: Theme, cwd: string, changes?: FileLineChanges): string;
21
- /** Prefix a pi-built `Text` in place (its `text` field is TS-private but a plain
22
- * runtime property). Used where the header line is baked into the same string as
23
- * a body we want to keep verbatim — write's syntax-highlighted content preview,
24
- * edit's header inside its own card. Silently no-ops if pi's Text ever stops
25
- * looking like this, so the worst case is a missing glyph, never a crash. */
26
- export declare function prefixToolText(component: unknown, prefix: string): void;
27
23
  /** Attach-viewer `read`: an icon + the path (plus any `:offset-limit` range).
28
24
  * The call is a single line by construction, so a folded read shows exactly
29
25
  * what it read and nothing else. */
30
- export declare function createCrtrReadToolDefinition(cwd: string): unknown;
26
+ export declare function createCrtrReadToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
31
27
  /** Attach-viewer `write`: pi's builtin call renderer (it owns the
32
28
  * syntax-highlighted content preview and its expand hint) with the icon
33
29
  * prefixed onto its header line. Folded, only that header survives. */
34
- export declare function createCrtrWriteToolDefinition(cwd: string): unknown;
30
+ export declare function createCrtrWriteToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
35
31
  /** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
36
- export declare function createCrtrPlainBashToolDefinition(cwd: string): unknown;
32
+ export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
37
33
  export {};