@north-light/crouter 0.3.162 → 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.
Files changed (100) hide show
  1. package/dist/builtin-memory/00-runtime-base.md +1 -0
  2. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  3. package/dist/builtin-memory/init.md +38 -0
  4. package/dist/builtin-memory/internal/INDEX.md +1 -0
  5. package/dist/builtin-memory/internal/memory-loading.md +45 -0
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
  10. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +1 -1
  11. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  12. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  13. package/dist/clients/attach/__tests__/edit-diff.test.js +35 -0
  14. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  15. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  16. package/dist/clients/attach/render/chat-view.js +54 -38
  17. package/dist/clients/attach/render/crtr-output.d.ts +2 -1
  18. package/dist/clients/attach/render/crtr-output.js +33 -4
  19. package/dist/clients/attach/render/edit-diff.d.ts +21 -6
  20. package/dist/clients/attach/render/edit-diff.js +104 -81
  21. package/dist/clients/attach/render/tool-calls.d.ts +21 -20
  22. package/dist/clients/attach/render/tool-calls.js +89 -43
  23. package/dist/clients/attach/session/connection.d.ts +1 -1
  24. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  25. package/dist/clients/attach/session/editor-frame.js +21 -15
  26. package/dist/clients/attach/session/identity.d.ts +1 -1
  27. package/dist/clients/attach/viewer.js +628 -624
  28. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  29. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  30. package/dist/commands/__tests__/human.test.js +2 -0
  31. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  32. package/dist/commands/__tests__/search-contents.test.js +52 -0
  33. package/dist/commands/api-client.js +1 -0
  34. package/dist/commands/human/prompts.js +2 -2
  35. package/dist/commands/human/shared.d.ts +1 -0
  36. package/dist/commands/human/shared.js +7 -4
  37. package/dist/commands/memory/lint.d.ts +6 -0
  38. package/dist/commands/memory/lint.js +177 -35
  39. package/dist/commands/memory/read.js +22 -1
  40. package/dist/commands/memory/write.js +18 -4
  41. package/dist/commands/memory.js +1 -1
  42. package/dist/commands/search/contents.js +2 -2
  43. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  44. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  45. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  46. package/dist/commands/sys/setup-wizard.js +209 -58
  47. package/dist/commands/sys/sync-deps.js +14 -18
  48. package/dist/commands/sys/sync-project-guidance.js +16 -19
  49. package/dist/core/__tests__/migration.test.js +8 -3
  50. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  51. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  52. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  53. package/dist/core/__tests__/revive.test.js +2 -2
  54. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  55. package/dist/core/__tests__/worktree.test.js +3 -3
  56. package/dist/core/canvas/extensions.d.ts +1 -1
  57. package/dist/core/canvas/extensions.js +18 -11
  58. package/dist/core/canvas/labels.d.ts +4 -5
  59. package/dist/core/canvas/labels.js +9 -9
  60. package/dist/core/command-manifests/schema.js +18 -5
  61. package/dist/core/command.js +17 -7
  62. package/dist/core/configured-clis/invoker.js +2 -0
  63. package/dist/core/help.d.ts +3 -1
  64. package/dist/core/help.js +5 -2
  65. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  66. package/dist/core/keybindings/catalog.d.ts +3 -2
  67. package/dist/core/keybindings/catalog.js +34 -25
  68. package/dist/core/keybindings/index.d.ts +1 -1
  69. package/dist/core/keybindings/resolve.js +8 -0
  70. package/dist/core/keybindings/types.d.ts +6 -1
  71. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  72. package/dist/core/memory/doc-link-grammar.js +110 -0
  73. package/dist/core/memory-resolver.d.ts +5 -0
  74. package/dist/core/memory-resolver.js +12 -1
  75. package/dist/core/runtime/bearings.d.ts +8 -8
  76. package/dist/core/runtime/bearings.js +20 -16
  77. package/dist/core/runtime/broker.js +3 -13
  78. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  79. package/dist/core/runtime/canvas-extensions.js +2 -0
  80. package/dist/core/runtime/launch.d.ts +1 -1
  81. package/dist/core/runtime/launch.js +1 -1
  82. package/dist/core/runtime/tmux.js +136 -100
  83. package/dist/core/scope.js +4 -5
  84. package/dist/core/substrate/ceiling.d.ts +3 -6
  85. package/dist/core/substrate/ceiling.js +13 -15
  86. package/dist/core/substrate/index.d.ts +2 -3
  87. package/dist/core/substrate/index.js +2 -2
  88. package/dist/core/substrate/on-read.d.ts +4 -13
  89. package/dist/core/substrate/on-read.js +142 -262
  90. package/dist/core/substrate/render.js +4 -4
  91. package/dist/core/substrate/schema.d.ts +3 -2
  92. package/dist/core/substrate/schema.js +5 -5
  93. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  94. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  95. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  96. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  97. package/dist/pi-extensions/canvas-tool-guide.js +77 -0
  98. package/package.json +2 -2
  99. package/runtime.lock.json +6 -6
  100. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -1,6 +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 { FoldedToolCallController } from './tool-calls.js';
4
5
  // Hard cap on a collapsed crtr card's total rendered lines (rule 12,
5
6
  // inline-ui-placement): help synthesis or a normal truncated result never
6
7
  // shows more than this many lines total, blank spacer + status + body +
@@ -148,6 +149,32 @@ function commandFromArgs(args) {
148
149
  const command = args.command;
149
150
  return typeof command === 'string' ? command : '';
150
151
  }
152
+ const CRTR_FLAG_TOKEN = /^-{1,2}[A-Za-z0-9]/;
153
+ const CRTR_ATTACHED_FLAG_VALUE = /^(--?[A-Za-z0-9][^=]*)(=)(.*)$/;
154
+ const CRTR_NUMBER_VALUE = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
155
+ /** Syntax-color flags and their values while leaving the command path and
156
+ * positional arguments in the folded row's existing bold title style. */
157
+ function styleFoldedCrtrTokens(tokens, theme) {
158
+ let expectsValue = false;
159
+ return tokens.map((token) => {
160
+ const attached = CRTR_ATTACHED_FLAG_VALUE.exec(token);
161
+ if (attached !== null) {
162
+ expectsValue = false;
163
+ const valueColor = CRTR_NUMBER_VALUE.test(attached[3]) ? 'syntaxNumber' : 'syntaxString';
164
+ return `${theme.fg('syntaxKeyword', attached[1])}${theme.fg('syntaxOperator', attached[2])}${theme.fg(valueColor, attached[3])}`;
165
+ }
166
+ if (expectsValue && !CRTR_FLAG_TOKEN.test(token)) {
167
+ expectsValue = false;
168
+ return theme.fg(CRTR_NUMBER_VALUE.test(token) ? 'syntaxNumber' : 'syntaxString', token);
169
+ }
170
+ if (CRTR_FLAG_TOKEN.test(token)) {
171
+ expectsValue = true;
172
+ return theme.fg('syntaxKeyword', token);
173
+ }
174
+ expectsValue = false;
175
+ return theme.fg('toolTitle', theme.bold(token));
176
+ }).join(' ');
177
+ }
151
178
  function resultText(result) {
152
179
  return (result.content ?? [])
153
180
  .filter((part) => part?.type === 'text' || typeof part?.text === 'string')
@@ -661,7 +688,7 @@ export class CrtrOutputMessageComponent extends Box {
661
688
  this.addChild(this.body);
662
689
  }
663
690
  }
664
- export function createCrtrBashToolDefinition(args) {
691
+ export function createCrtrBashToolDefinition(args, fold = new FoldedToolCallController()) {
665
692
  const command = commandFromArgs(args);
666
693
  if (!isCrtrBashCommand(command))
667
694
  return undefined;
@@ -670,7 +697,7 @@ export function createCrtrBashToolDefinition(args) {
670
697
  label: 'bash',
671
698
  description: 'Render crtr bash output with crtr-specific chrome in the attach viewer.',
672
699
  parameters: {},
673
- renderCall(callArgs, theme) {
700
+ renderCall(callArgs, theme, context) {
674
701
  const currentCommand = commandFromArgs(callArgs) || command;
675
702
  // Semantic card, not a bash echo: the header names what the invocation
676
703
  // IS (help schema vs. a crtr command), and the body previews only the
@@ -684,8 +711,10 @@ export function createCrtrBashToolDefinition(args) {
684
711
  const icon = isHelp ? HELP_ICON : iconForPath(pathTokens);
685
712
  const prefix = isHelp ? `${icon} crtr help` : `${icon} crtr`;
686
713
  const styledPrefix = theme.fg('accent', theme.bold(prefix));
687
- const styledPath = path ? ` ${theme.fg('toolTitle', theme.bold(path))}` : '';
688
- return new Text(`${styledPrefix}${styledPath}`, 0, 0);
714
+ const styledPath = path
715
+ ? ` ${fold.folded ? styleFoldedCrtrTokens(pathTokens, theme) : theme.fg('toolTitle', theme.bold(path))}`
716
+ : '';
717
+ return fold.capture(new Text(`${styledPrefix}${styledPath}`, 0, 0));
689
718
  },
690
719
  renderResult(result, options, theme, context) {
691
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
@@ -102,6 +94,44 @@ function parseDiffLine(line) {
102
94
  return null;
103
95
  return { prefix: match[1], lineNum: match[2], content: match[3] };
104
96
  }
97
+ /** Count only changed rows in pi's display diff; context and ellipsis rows do
98
+ * not contribute to the folded file summary. */
99
+ function countDiffLines(diff) {
100
+ let added = 0;
101
+ let removed = 0;
102
+ for (const line of diff.split('\n')) {
103
+ const parsed = parseDiffLine(line);
104
+ if (parsed?.prefix === '+')
105
+ added++;
106
+ if (parsed?.prefix === '-')
107
+ removed++;
108
+ }
109
+ return { added, removed };
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
+ }
105
135
  function replaceTabs(text) {
106
136
  return text.replace(/\t/g, ' ');
107
137
  }
@@ -202,81 +232,74 @@ function buildDiffBody(diffText, theme, restoreBg) {
202
232
  }
203
233
  return container;
204
234
  }
205
- // ---------------------------------------------------------------------------
206
- // Tool definition wrapper
207
- // ---------------------------------------------------------------------------
208
- /** Replace the pi-rendered diff body inside the edit call Box (children:
209
- * [header, Spacer, body]) with the full-background rendering. No-op when
210
- * there is no successful preview (header-only or error states keep pi's
211
- * rendering untouched). */
212
- function restyleEditCallBox(box, theme) {
213
- const preview = box.preview;
214
- if (!preview || 'error' in preview || box.children.length < 2)
215
- return;
216
- const header = box.children[0];
217
- box.clear();
218
- box.addChild(header);
219
- box.addChild(new Spacer(1));
220
- // With a successful preview the edit card bg is always toolSuccessBg
221
- // (mirrors pi's getEditHeaderBg), so tinted lines restore to that color.
222
- 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
+ }
223
251
  }
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)} `);
252
+ function editPath(args) {
253
+ const value = args;
254
+ return typeof value?.file_path === 'string' ? value.file_path : typeof value?.path === 'string' ? value.path : '';
229
255
  }
230
- /**
231
- * Attach-viewer tool definition for `edit`: pi's builtin definition drives
232
- * all state (preview computation, header, success/error bg), then the diff
233
- * body is re-skinned Claude-Code-style after every render pass.
234
- */
235
- export function createCrtrEditToolDefinition(cwd) {
236
- const builtin = createEditToolDefinition(cwd);
237
- 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 = {
238
277
  name: 'edit',
239
278
  label: 'edit',
240
- 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.',
241
280
  parameters: {},
242
281
  renderShell: 'self',
243
282
  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
- }
252
- const component = builtin.renderCall(args, theme, context);
253
- const callBox = context.state.callComponent;
254
- if (callBox) {
255
- restyleEditCallBox(callBox, theme);
256
- prefixEditHeaderIcon(callBox, theme);
257
- }
258
- 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);
259
290
  },
260
- renderResult(result, options, theme, context) {
261
- const component = builtin.renderResult(result, options, theme, context);
262
- const callBox = context.state.callComponent;
263
- if (callBox) {
264
- // builtin renderResult re-ran buildEditCallComponent (pi styling) —
265
- // restyle the refreshed body.
266
- restyleEditCallBox(callBox, theme);
267
- 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)
268
295
  return component;
269
- }
270
- // Fallback path (no call box state, e.g. a bare result replay): render
271
- // the result diff standalone. No host Box here, so restore to default bg.
272
- const resultDiff = result?.details?.diff;
273
- if (!context.isError && typeof resultDiff === 'string') {
274
- const container = new Container();
275
- container.addChild(new Spacer(1));
276
- container.addChild(buildDiffBody(resultDiff, theme, '\x1b[49m'));
277
- 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));
278
300
  }
279
301
  return component;
280
302
  },
281
303
  };
304
+ return { definition, observeResult };
282
305
  }
@@ -1,32 +1,33 @@
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;
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;
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
+ }
16
+ export type FileLineChanges = {
17
+ added: number;
18
+ removed: number;
19
+ };
20
+ /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
21
+ * optionally followed by its green/red changed-line counts. */
22
+ export declare function foldedFileCallLine(tool: 'read' | 'write' | 'edit', rawPath: string, theme: Theme, cwd: string, changes?: FileLineChanges): string;
22
23
  /** Attach-viewer `read`: an icon + the path (plus any `:offset-limit` range).
23
24
  * The call is a single line by construction, so a folded read shows exactly
24
25
  * what it read and nothing else. */
25
- export declare function createCrtrReadToolDefinition(cwd: string): unknown;
26
+ export declare function createCrtrReadToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
26
27
  /** Attach-viewer `write`: pi's builtin call renderer (it owns the
27
28
  * syntax-highlighted content preview and its expand hint) with the icon
28
29
  * prefixed onto its header line. Folded, only that header survives. */
29
- export declare function createCrtrWriteToolDefinition(cwd: string): unknown;
30
+ export declare function createCrtrWriteToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
30
31
  /** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
31
- export declare function createCrtrPlainBashToolDefinition(cwd: string): unknown;
32
+ export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
32
33
  export {};
@@ -19,18 +19,52 @@ 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;
30
- }
31
- /** The one-line folded form of a file tool's call: `<icon> <label> <path>`. */
32
- export function foldedFileCallLine(tool, rawPath, theme, cwd) {
33
- return `${header(TOOL_ICON[tool], tool, theme)} ${pathDisplay(rawPath, theme, cwd)}`;
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
+ }
38
+ }
39
+ /** Count file lines the way a diff does: a trailing newline terminates the last
40
+ * line instead of creating an extra empty one. */
41
+ function countFileLines(content) {
42
+ if (content.length === 0)
43
+ return 0;
44
+ const newlines = content.match(/\n/g)?.length ?? 0;
45
+ return newlines + (content.endsWith('\n') ? 0 : 1);
46
+ }
47
+ /** The green/red changed-line suffix shown after a folded edit path. */
48
+ function lineChangesDisplay(changes, theme) {
49
+ if (!changes)
50
+ return '';
51
+ const counts = [];
52
+ if (changes.added > 0)
53
+ counts.push(theme.fg('toolDiffAdded', `+${changes.added}`));
54
+ if (changes.removed > 0)
55
+ counts.push(theme.fg('toolDiffRemoved', `-${changes.removed}`));
56
+ return counts.length > 0 ? ` ${counts.join(' ')}` : '';
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
+ }
64
+ /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
65
+ * optionally followed by its green/red changed-line counts. */
66
+ export function foldedFileCallLine(tool, rawPath, theme, cwd, changes) {
67
+ return `${header(TOOL_ICON[tool], tool, theme)} ${pathDisplay(rawPath, theme, cwd)}${lineChangesDisplay(changes, theme)}`;
34
68
  }
35
69
  /** `~`-shorten an absolute path the way pi's renderToolPath does (its
36
70
  * render-utils module is not exported from the package root). */
@@ -52,18 +86,29 @@ function pathDisplay(rawPath, theme, cwd) {
52
86
  function header(icon, label, theme) {
53
87
  return `${theme.fg('accent', icon)} ${theme.fg('toolTitle', theme.bold(label))}`;
54
88
  }
55
- /** Prefix a pi-built `Text` in place (its `text` field is TS-private but a plain
56
- * runtime property). Used where the header line is baked into the same string as
57
- * a body we want to keep verbatim — write's syntax-highlighted content preview,
58
- * edit's header inside its own card. Silently no-ops if pi's Text ever stops
59
- * looking like this, so the worst case is a missing glyph, never a crash. */
60
- export function prefixToolText(component, prefix) {
61
- const text = component;
62
- if (typeof text.text !== 'string' || typeof text.setText !== 'function')
63
- return;
64
- if (text.text.startsWith(prefix))
65
- return;
66
- 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
+ }
67
112
  }
68
113
  // ---------------------------------------------------------------------------
69
114
  // read
@@ -71,7 +116,7 @@ export function prefixToolText(component, prefix) {
71
116
  /** Attach-viewer `read`: an icon + the path (plus any `:offset-limit` range).
72
117
  * The call is a single line by construction, so a folded read shows exactly
73
118
  * what it read and nothing else. */
74
- export function createCrtrReadToolDefinition(cwd) {
119
+ export function createCrtrReadToolDefinition(cwd, fold = new FoldedToolCallController()) {
75
120
  const builtin = createReadToolDefinition(cwd);
76
121
  return {
77
122
  name: 'read',
@@ -90,7 +135,7 @@ export function createCrtrReadToolDefinition(cwd) {
90
135
  const line = `${header(TOOL_ICON.read, 'read', theme)} ${pathDisplay(raw, theme, context.cwd ?? cwd)}${range}`;
91
136
  const component = context.lastComponent ?? new Text('', 0, 0);
92
137
  component.setText(line);
93
- return component;
138
+ return fold.capture(component);
94
139
  },
95
140
  renderResult(result, options, theme, context) {
96
141
  return builtin.renderResult(result, options, theme, context);
@@ -103,29 +148,30 @@ export function createCrtrReadToolDefinition(cwd) {
103
148
  /** Attach-viewer `write`: pi's builtin call renderer (it owns the
104
149
  * syntax-highlighted content preview and its expand hint) with the icon
105
150
  * prefixed onto its header line. Folded, only that header survives. */
106
- export function createCrtrWriteToolDefinition(cwd) {
151
+ export function createCrtrWriteToolDefinition(cwd, fold = new FoldedToolCallController()) {
107
152
  const builtin = createWriteToolDefinition(cwd);
153
+ let builtinCall;
154
+ let decoratedCall;
108
155
  return {
109
156
  name: 'write',
110
157
  label: 'write',
111
158
  description: 'Render write calls with a crtr icon header in the attach viewer.',
112
159
  parameters: {},
113
160
  renderCall(args, theme, context) {
114
- if (isFoldedContext(context)) {
161
+ if (fold.folded) {
115
162
  const a = args;
116
- const line = foldedFileCallLine('write', str(a?.file_path ?? a?.path), theme, context.cwd ?? cwd);
117
- // Reuse the previous component when it is Text-like (the builtin's is a
118
- // Text subclass), so fold toggles don't churn component identity.
119
- const last = context.lastComponent;
120
- if (last && typeof last.setText === 'function') {
121
- last.setText(line);
122
- return last;
123
- }
124
- 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));
125
167
  }
126
- const component = builtin.renderCall(args, theme, context);
127
- prefixToolText(component, `${theme.fg('accent', TOOL_ICON.write)} `);
128
- 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);
129
175
  },
130
176
  renderResult(result, options, theme, context) {
131
177
  return builtin.renderResult(result, options, theme, context);
@@ -221,7 +267,7 @@ class BashCallComponent {
221
267
  }
222
268
  }
223
269
  /** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
224
- export function createCrtrPlainBashToolDefinition(cwd) {
270
+ export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController()) {
225
271
  const builtin = createBashToolDefinition(cwd);
226
272
  return {
227
273
  name: 'bash',
@@ -241,8 +287,8 @@ export function createCrtrPlainBashToolDefinition(cwd) {
241
287
  const component = context.lastComponent instanceof BashCallComponent ? context.lastComponent : new BashCallComponent();
242
288
  component.update(theme, str(a?.command),
243
289
  // Folded wins over ctrl+o expansion: a folded bash is always its one-line form.
244
- Boolean(context.expanded) && !isFoldedContext(context), typeof a?.timeout === 'number' ? a.timeout : undefined);
245
- return component;
290
+ Boolean(context.expanded) && !fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined);
291
+ return fold.capture(component);
246
292
  },
247
293
  renderResult(result, options, theme, context) {
248
294
  return builtin.renderResult(result, options, theme, context);
@@ -30,7 +30,7 @@ export interface AttachConnection {
30
30
  readonly meta: Pick<NodeMeta, 'cwd' | 'profile_id'>;
31
31
  /** The attached node's persona/lifecycle, when this canvas can tell us —
32
32
  * `undefined` for a remote attach until the viewer refetches it. */
33
- readonly nodeIdentity: Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle'> | undefined;
33
+ readonly nodeIdentity: Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle' | 'icon'> | undefined;
34
34
  /** The one socket to the node's headless broker, already connected. */
35
35
  readonly socket: BrokerClient;
36
36
  /** Re-throw a connect-adjacent failure that landed AFTER `connect` resolved.
@@ -14,7 +14,7 @@ export declare function resolveEditorFrameStyles(bashMode: 0 | 1 | 2, thinkingLe
14
14
  export interface EditorChromeHooks {
15
15
  /** The viewer's live node identity — a `let` runAttach owns and refreshes
16
16
  * (remote attach starts without one), so it MUST stay a getter. */
17
- nodeIdentity: () => Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle'> | undefined;
17
+ nodeIdentity: () => Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle' | 'icon'> | undefined;
18
18
  /** The engine's current thinking level, from live session state. */
19
19
  thinkingLevel: () => BrokerSnapshot['state']['thinkingLevel'] | undefined;
20
20
  /** Repaint the status line — the bash-level segment tracks `bashMode`. */
@@ -36,6 +36,10 @@ export interface EditorChrome {
36
36
  /** Kick an async git-status fetch that repaints the top border. */
37
37
  pollGit: () => void;
38
38
  }
39
+ /** Render the generated task icon on every non-empty editor title. Session
40
+ * names are persisted independently by pi, so this derives the icon from the
41
+ * canvas identity again whenever a viewer attaches or reloads. */
42
+ export declare function displayEditorTitle(sessionName: string | undefined, identity: Pick<NodeMeta, 'kind' | 'mode' | 'icon'> | undefined): string;
39
43
  /** The editor-chrome collaborator: owns the shell/bash level (`deriveBashMode`
40
44
  * over `editor.onChange` is its ONE writer) and the `/color` chip override,
41
45
  * and paints every frame-attached adornment — border hue, title chip, context