@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.
@@ -19,14 +19,22 @@ import { TOOL_ICON } from '../../../core/preview-registry.js';
19
19
  function str(value) {
20
20
  return typeof value === 'string' ? value : '';
21
21
  }
22
- /** Key in a tool's shared render-state bag (`context.state`) carrying the
23
- * viewer's fold. `MinimizableToolComponent` sets it; the fold-aware renderCalls
24
- * below read it and emit ONLY their one-line header while it is true, which is
25
- * what makes a folded card exactly `<icon> <tool> <path>` with no body — the
26
- * renderer owns its folded form, no post-hoc slicing of rendered rows. */
27
- export const FOLDED_STATE_KEY = 'crtrFolded';
28
- export function isFoldedContext(context) {
29
- return context.state?.[FOLDED_STATE_KEY] === true;
22
+ /** Per-tool fold state owned by the attach viewer. Tool definitions capture the
23
+ * call component they render while folded, so the viewer never reaches into
24
+ * `ToolExecutionComponent`'s private renderer fields to recover it. */
25
+ export class FoldedToolCallController {
26
+ folded = false;
27
+ component;
28
+ setFolded(folded) {
29
+ this.folded = folded;
30
+ }
31
+ capture(component) {
32
+ this.component = component;
33
+ return component;
34
+ }
35
+ callComponent() {
36
+ return this.component;
37
+ }
30
38
  }
31
39
  /** Count file lines the way a diff does: a trailing newline terminates the last
32
40
  * line instead of creating an extra empty one. */
@@ -36,7 +44,7 @@ function countFileLines(content) {
36
44
  const newlines = content.match(/\n/g)?.length ?? 0;
37
45
  return newlines + (content.endsWith('\n') ? 0 : 1);
38
46
  }
39
- /** The green/red line-count suffix shown after a folded write/edit path. */
47
+ /** The green/red changed-line suffix shown after a folded edit path. */
40
48
  function lineChangesDisplay(changes, theme) {
41
49
  if (!changes)
42
50
  return '';
@@ -47,6 +55,12 @@ function lineChangesDisplay(changes, theme) {
47
55
  counts.push(theme.fg('toolDiffRemoved', `-${changes.removed}`));
48
56
  return counts.length > 0 ? ` ${counts.join(' ')}` : '';
49
57
  }
58
+ /** A write has no before/after diff, so report its output size neutrally. */
59
+ function lineCountDisplay(lines, theme) {
60
+ if (lines === undefined)
61
+ return '';
62
+ return ` ${theme.fg('muted', `${lines} ${lines === 1 ? 'line' : 'lines'}`)}`;
63
+ }
50
64
  /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
51
65
  * optionally followed by its green/red changed-line counts. */
52
66
  export function foldedFileCallLine(tool, rawPath, theme, cwd, changes) {
@@ -72,18 +86,29 @@ function pathDisplay(rawPath, theme, cwd) {
72
86
  function header(icon, label, theme) {
73
87
  return `${theme.fg('accent', icon)} ${theme.fg('toolTitle', theme.bold(label))}`;
74
88
  }
75
- /** Prefix a pi-built `Text` in place (its `text` field is TS-private but a plain
76
- * runtime property). Used where the header line is baked into the same string as
77
- * a body we want to keep verbatim — write's syntax-highlighted content preview,
78
- * edit's header inside its own card. Silently no-ops if pi's Text ever stops
79
- * looking like this, so the worst case is a missing glyph, never a crash. */
80
- export function prefixToolText(component, prefix) {
81
- const text = component;
82
- if (typeof text.text !== 'string' || typeof text.setText !== 'function')
83
- return;
84
- if (text.text.startsWith(prefix))
85
- return;
86
- text.setText(`${prefix}${text.text}`);
89
+ /** Add an icon to a tool call without mutating the imported renderer's private
90
+ * text storage. The imported component remains responsible for its own body. */
91
+ class IconPrefixedToolCall {
92
+ child;
93
+ prefix;
94
+ constructor(child, prefix) {
95
+ this.child = child;
96
+ this.prefix = prefix;
97
+ }
98
+ update(child, prefix) {
99
+ this.child = child;
100
+ this.prefix = prefix;
101
+ }
102
+ invalidate() {
103
+ this.child.invalidate();
104
+ }
105
+ render(width) {
106
+ const prefixWidth = visibleWidth(this.prefix);
107
+ const lines = this.child.render(Math.max(1, width - prefixWidth));
108
+ if (lines.length === 0)
109
+ return [truncateToWidth(this.prefix.trimEnd(), width, '')];
110
+ return lines.map((line, index) => index === 0 ? truncateToWidth(`${this.prefix}${line}`, width, '') : line);
111
+ }
87
112
  }
88
113
  // ---------------------------------------------------------------------------
89
114
  // read
@@ -91,7 +116,7 @@ export function prefixToolText(component, prefix) {
91
116
  /** Attach-viewer `read`: an icon + the path (plus any `:offset-limit` range).
92
117
  * The call is a single line by construction, so a folded read shows exactly
93
118
  * what it read and nothing else. */
94
- export function createCrtrReadToolDefinition(cwd) {
119
+ export function createCrtrReadToolDefinition(cwd, fold = new FoldedToolCallController()) {
95
120
  const builtin = createReadToolDefinition(cwd);
96
121
  return {
97
122
  name: 'read',
@@ -110,7 +135,7 @@ export function createCrtrReadToolDefinition(cwd) {
110
135
  const line = `${header(TOOL_ICON.read, 'read', theme)} ${pathDisplay(raw, theme, context.cwd ?? cwd)}${range}`;
111
136
  const component = context.lastComponent ?? new Text('', 0, 0);
112
137
  component.setText(line);
113
- return component;
138
+ return fold.capture(component);
114
139
  },
115
140
  renderResult(result, options, theme, context) {
116
141
  return builtin.renderResult(result, options, theme, context);
@@ -123,30 +148,30 @@ export function createCrtrReadToolDefinition(cwd) {
123
148
  /** Attach-viewer `write`: pi's builtin call renderer (it owns the
124
149
  * syntax-highlighted content preview and its expand hint) with the icon
125
150
  * prefixed onto its header line. Folded, only that header survives. */
126
- export function createCrtrWriteToolDefinition(cwd) {
151
+ export function createCrtrWriteToolDefinition(cwd, fold = new FoldedToolCallController()) {
127
152
  const builtin = createWriteToolDefinition(cwd);
153
+ let builtinCall;
154
+ let decoratedCall;
128
155
  return {
129
156
  name: 'write',
130
157
  label: 'write',
131
158
  description: 'Render write calls with a crtr icon header in the attach viewer.',
132
159
  parameters: {},
133
160
  renderCall(args, theme, context) {
134
- if (isFoldedContext(context)) {
161
+ if (fold.folded) {
135
162
  const a = args;
136
- const changes = context.isError ? undefined : { added: countFileLines(str(a?.content)), removed: 0 };
137
- const line = foldedFileCallLine('write', str(a?.file_path ?? a?.path), theme, context.cwd ?? cwd, changes);
138
- // Reuse the previous component when it is Text-like (the builtin's is a
139
- // Text subclass), so fold toggles don't churn component identity.
140
- const last = context.lastComponent;
141
- if (last && typeof last.setText === 'function') {
142
- last.setText(line);
143
- return last;
144
- }
145
- return new Text(line, 0, 0);
163
+ const lines = context.isError ? undefined : countFileLines(str(a?.content));
164
+ const line = foldedFileCallLine('write', str(a?.file_path ?? a?.path), theme, context.cwd ?? cwd) +
165
+ lineCountDisplay(lines, theme);
166
+ return fold.capture(new Text(line, 0, 0));
146
167
  }
147
- const component = builtin.renderCall(args, theme, context);
148
- prefixToolText(component, `${theme.fg('accent', TOOL_ICON.write)} `);
149
- return component;
168
+ builtinCall = builtin.renderCall(args, theme, { ...context, lastComponent: builtinCall });
169
+ const prefix = `${theme.fg('accent', TOOL_ICON.write)} `;
170
+ if (decoratedCall)
171
+ decoratedCall.update(builtinCall, prefix);
172
+ else
173
+ decoratedCall = new IconPrefixedToolCall(builtinCall, prefix);
174
+ return fold.capture(decoratedCall);
150
175
  },
151
176
  renderResult(result, options, theme, context) {
152
177
  return builtin.renderResult(result, options, theme, context);
@@ -242,7 +267,7 @@ class BashCallComponent {
242
267
  }
243
268
  }
244
269
  /** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
245
- export function createCrtrPlainBashToolDefinition(cwd) {
270
+ export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController()) {
246
271
  const builtin = createBashToolDefinition(cwd);
247
272
  return {
248
273
  name: 'bash',
@@ -262,8 +287,8 @@ export function createCrtrPlainBashToolDefinition(cwd) {
262
287
  const component = context.lastComponent instanceof BashCallComponent ? context.lastComponent : new BashCallComponent();
263
288
  component.update(theme, str(a?.command),
264
289
  // Folded wins over ctrl+o expansion: a folded bash is always its one-line form.
265
- Boolean(context.expanded) && !isFoldedContext(context), typeof a?.timeout === 'number' ? a.timeout : undefined);
266
- return component;
290
+ Boolean(context.expanded) && !fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined);
291
+ return fold.capture(component);
267
292
  },
268
293
  renderResult(result, options, theme, context) {
269
294
  return builtin.renderResult(result, options, theme, context);