@north-light/crouter 0.3.163 → 0.3.165

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 (167) hide show
  1. package/dist/build-root.d.ts +6 -6
  2. package/dist/build-root.js +12 -13
  3. package/dist/builtin-memory/00-runtime-base.md +3 -4
  4. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +2 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +16 -18
  7. package/dist/builtin-memory/05-kinds/advisor/00-base.md +0 -2
  8. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +3 -9
  9. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -3
  10. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -10
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
  12. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +0 -2
  13. package/dist/builtin-memory/05-kinds/general/00-base.md +3 -5
  14. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -4
  15. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +1 -3
  16. package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +12 -0
  17. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +0 -2
  18. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +0 -2
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +0 -2
  20. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -1
  21. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +0 -2
  22. package/dist/builtin-memory/05-kinds/review/00-base.md +0 -2
  23. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -3
  24. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  25. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +2 -4
  26. package/dist/builtin-memory/05-kinds/spec/requirements.md +1 -1
  27. package/dist/builtin-memory/advisor/council.md +40 -0
  28. package/dist/builtin-memory/design.md +8 -11
  29. package/dist/builtin-memory/development.md +6 -9
  30. package/dist/builtin-memory/internal/INDEX.md +5 -5
  31. package/dist/builtin-memory/internal/agent-shaping.md +5 -7
  32. package/dist/builtin-memory/internal/examples/imessage-assistant.md +3 -3
  33. package/dist/builtin-memory/internal/marketplaces.md +13 -14
  34. package/dist/builtin-memory/internal/memory-loading.md +45 -0
  35. package/dist/builtin-memory/internal/nodes-and-canvas.md +4 -4
  36. package/dist/builtin-memory/internal/plugins.md +61 -45
  37. package/dist/builtin-memory/planning.md +4 -7
  38. package/dist/builtin-memory/spec.md +9 -12
  39. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +8 -22
  40. package/dist/cli.js +1 -1
  41. package/dist/clients/attach/__tests__/attach-keybindings.test.js +17 -4
  42. package/dist/clients/attach/__tests__/context-message.test.js +31 -20
  43. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +1 -1
  44. package/dist/clients/attach/__tests__/crtr-output.test.js +39 -30
  45. package/dist/clients/attach/__tests__/edit-diff.test.js +21 -20
  46. package/dist/clients/attach/render/chat-view.d.ts +20 -20
  47. package/dist/clients/attach/render/chat-view.js +88 -89
  48. package/dist/clients/attach/render/{frozen-history.d.ts → condensed-history.d.ts} +1 -18
  49. package/dist/clients/attach/render/condensed-history.js +60 -0
  50. package/dist/clients/attach/render/context-message.js +9 -14
  51. package/dist/clients/attach/render/crtr-output.d.ts +2 -1
  52. package/dist/clients/attach/render/crtr-output.js +4 -4
  53. package/dist/clients/attach/render/edit-diff.d.ts +21 -6
  54. package/dist/clients/attach/render/edit-diff.js +90 -91
  55. package/dist/clients/attach/render/tool-calls.d.ts +14 -18
  56. package/dist/clients/attach/render/tool-calls.js +66 -41
  57. package/dist/clients/attach/session/bindings.d.ts +5 -3
  58. package/dist/clients/attach/session/bindings.js +7 -13
  59. package/dist/clients/attach/session/keys.d.ts +2 -0
  60. package/dist/clients/attach/session/keys.js +36 -37
  61. package/dist/clients/attach/session/profile-files.d.ts +8 -0
  62. package/dist/clients/attach/session/profile-files.js +157 -0
  63. package/dist/clients/attach/viewer.js +530 -528
  64. package/dist/commands/memory/lint.js +1 -1
  65. package/dist/commands/memory/write.js +1 -1
  66. package/dist/commands/node.js +2 -2
  67. package/dist/commands/pkg/plugin-inspect.js +6 -7
  68. package/dist/commands/pkg/plugin-manage.d.ts +1 -1
  69. package/dist/commands/pkg/plugin-manage.js +131 -19
  70. package/dist/commands/pkg/plugin.js +2 -2
  71. package/dist/commands/pkg.js +6 -11
  72. package/dist/commands/profile/env.js +3 -3
  73. package/dist/commands/sys/config.js +17 -76
  74. package/dist/commands/sys/doctor.js +5 -91
  75. package/dist/commands/sys/setup-wizard.d.ts +9 -11
  76. package/dist/commands/sys/setup-wizard.js +47 -81
  77. package/dist/core/__tests__/base-worker-prompt.test.js +18 -21
  78. package/dist/core/__tests__/command-plugins-surfaces.test.js +39 -5
  79. package/dist/core/__tests__/command-plugins.test.js +36 -16
  80. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  81. package/dist/core/__tests__/tmux-surface.test.js +10 -1
  82. package/dist/core/command-manifests/manifest.d.ts +24 -0
  83. package/dist/core/{configured-clis → command-manifests}/manifest.js +21 -25
  84. package/dist/core/command-manifests/registry.d.ts +1 -2
  85. package/dist/core/command-manifests/registry.js +2 -2
  86. package/dist/core/command-manifests/schema.d.ts +11 -13
  87. package/dist/core/command-manifests/schema.js +53 -192
  88. package/dist/core/command-plugins/compose.d.ts +0 -6
  89. package/dist/core/command-plugins/compose.js +28 -75
  90. package/dist/core/command-plugins/discovery.d.ts +25 -58
  91. package/dist/core/command-plugins/discovery.js +152 -259
  92. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  93. package/dist/core/command-plugins/endpoint.js +48 -0
  94. package/dist/core/command-plugins/store.d.ts +16 -0
  95. package/dist/core/command-plugins/store.js +64 -0
  96. package/dist/core/command-plugins/{adapter.d.ts → transport/exec-invoke.d.ts} +3 -3
  97. package/dist/core/command-plugins/{adapter.js → transport/exec-invoke.js} +4 -4
  98. package/dist/core/{configured-clis/fetch.d.ts → command-plugins/transport/http-fetch.d.ts} +9 -18
  99. package/dist/core/{configured-clis/fetch.js → command-plugins/transport/http-fetch.js} +15 -35
  100. package/dist/core/{configured-clis/invoker.d.ts → command-plugins/transport/http-invoke.d.ts} +7 -7
  101. package/dist/core/{configured-clis/invoker.js → command-plugins/transport/http-invoke.js} +21 -23
  102. package/dist/core/command.d.ts +8 -9
  103. package/dist/core/command.js +6 -9
  104. package/dist/core/config.js +6 -10
  105. package/dist/core/env-name.d.ts +6 -0
  106. package/dist/core/env-name.js +9 -0
  107. package/dist/core/io.d.ts +1 -1
  108. package/dist/core/keybindings/__tests__/resolve.test.js +40 -3
  109. package/dist/core/keybindings/attach-control.d.ts +37 -0
  110. package/dist/core/keybindings/attach-control.js +38 -0
  111. package/dist/core/keybindings/catalog.d.ts +5 -4
  112. package/dist/core/keybindings/catalog.js +16 -8
  113. package/dist/core/keybindings/index.d.ts +1 -0
  114. package/dist/core/keybindings/index.js +1 -0
  115. package/dist/core/keybindings/types.d.ts +1 -1
  116. package/dist/core/preview-registry.js +41 -74
  117. package/dist/core/runtime/bearings.d.ts +2 -5
  118. package/dist/core/runtime/bearings.js +2 -5
  119. package/dist/core/runtime/front-door.d.ts +1 -1
  120. package/dist/core/runtime/front-door.js +2 -2
  121. package/dist/core/runtime/kickoff.d.ts +3 -3
  122. package/dist/core/runtime/kickoff.js +4 -3
  123. package/dist/core/runtime/situational-context.d.ts +1 -1
  124. package/dist/core/runtime/situational-context.js +1 -1
  125. package/dist/core/runtime/spawn.js +9 -9
  126. package/dist/core/runtime/tmux.js +29 -2
  127. package/dist/core/scope.d.ts +0 -5
  128. package/dist/core/scope.js +0 -10
  129. package/dist/core/user-settings.d.ts +193 -0
  130. package/dist/core/user-settings.js +252 -0
  131. package/dist/index.d.ts +4 -0
  132. package/dist/index.js +3 -0
  133. package/dist/pi-extensions/canvas-stophook.js +3 -2
  134. package/dist/pi-extensions/canvas-tool-guide.js +4 -1
  135. package/dist/shared/generated-context.d.ts +34 -0
  136. package/dist/shared/generated-context.js +98 -0
  137. package/dist/types.d.ts +23 -16
  138. package/dist/types.js +3 -14
  139. package/dist/web-client/assets/{index-NIuSCOHM.js → index-BMTOGuOZ.js} +19 -19
  140. package/dist/web-client/assets/index-DvA6Zw-R.css +2 -0
  141. package/dist/web-client/index.html +2 -2
  142. package/dist/web-client/sw.js +1 -1
  143. package/docs/public-api.md +1 -0
  144. package/package.json +2 -2
  145. package/runtime.lock.json +6 -6
  146. package/dist/builtin-memory/05-kinds/product/00-base.md +0 -25
  147. package/dist/builtin-memory/05-kinds/product/01-orchestrator.md +0 -15
  148. package/dist/builtin-memory/05-kinds/product/teardown.md +0 -15
  149. package/dist/builtin-memory/internal/workflow-codification.md +0 -82
  150. package/dist/builtin-memory/product.md +0 -80
  151. package/dist/clients/attach/render/frozen-history.js +0 -100
  152. package/dist/commands/pkg/cli-inspect.d.ts +0 -17
  153. package/dist/commands/pkg/cli-inspect.js +0 -190
  154. package/dist/commands/pkg/cli-manage.d.ts +0 -3
  155. package/dist/commands/pkg/cli-manage.js +0 -206
  156. package/dist/commands/pkg/cli.d.ts +0 -1
  157. package/dist/commands/pkg/cli.js +0 -14
  158. package/dist/core/configured-clis/cache.d.ts +0 -16
  159. package/dist/core/configured-clis/cache.js +0 -57
  160. package/dist/core/configured-clis/compose.d.ts +0 -14
  161. package/dist/core/configured-clis/compose.js +0 -60
  162. package/dist/core/configured-clis/discovery.d.ts +0 -47
  163. package/dist/core/configured-clis/discovery.js +0 -173
  164. package/dist/core/configured-clis/manifest.d.ts +0 -24
  165. package/dist/core/configured-clis/registration.d.ts +0 -40
  166. package/dist/core/configured-clis/registration.js +0 -201
  167. package/dist/web-client/assets/index-CqLKj8Xu.css +0 -2
@@ -0,0 +1,60 @@
1
+ // condensed-history.ts — lightweight rendering of a node's OLD cycles.
2
+ //
3
+ // A node that has yielded several times carries every prior cycle in its welcome
4
+ // snapshot (cycleAwareMessages, session-cycles.ts). Cycles older than the trailing
5
+ // `live_cycles` (config, default 2) are condensed to cycle dividers plus whichever
6
+ // messages `condensed_history` (config, default `none`) admits. Tool calls and
7
+ // results are excluded; across six measured sessions they accounted for 62.9% of
8
+ // rendered lines and 69% of transcript build time.
9
+ //
10
+ // Retained messages stay as components inside one nested container. Settled lines
11
+ // are cached at a stable pane width, while a width change rerenders the nested
12
+ // content so old prose wraps with the rest of the transcript.
13
+ //
14
+ // The full detail of a condensed cycle remains in the node's session file, and
15
+ // `/tree` navigates the real branches.
16
+ import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../../core/runtime/session-cycles.js';
17
+ /** Roles admitted by each `condensed_history` mode. `toolResult`,
18
+ * `bashExecution`, and every non-divider `custom` message (crtr output,
19
+ * injected context blocks) are never kept, nor are the tool-call components an
20
+ * assistant message would otherwise spawn — only its prose. The cycle dividers
21
+ * survive every mode, so `none` leaves the seams and nothing else. */
22
+ const ROLES_BY_MODE = {
23
+ none: new Set(),
24
+ user: new Set(['user']),
25
+ agent: new Set(['assistant', 'compactionSummary', 'branchSummary']),
26
+ both: new Set(['user', 'assistant', 'compactionSummary', 'branchSummary']),
27
+ };
28
+ export function isCycleDivider(message) {
29
+ return message.role === 'custom' && message.customType === CRTR_CYCLE_DIVIDER_CUSTOM_TYPE;
30
+ }
31
+ /** Does this message survive into the condensed region? */
32
+ export function keepWhenCondensed(message, mode) {
33
+ if (isCycleDivider(message))
34
+ return true;
35
+ return (ROLES_BY_MODE[mode] ?? ROLES_BY_MODE.none).has(message.role ?? '');
36
+ }
37
+ /**
38
+ * Index into `messages` at which the LIVE (fully rendered) region begins.
39
+ *
40
+ * The snapshot is `[cycle 0][divider][cycle 1][divider]…[current cycle]`, so a
41
+ * transcript with D dividers holds D+1 cycles. Keeping the trailing
42
+ * `liveCycles` of them means everything up to and including divider
43
+ * `D - liveCycles` is condensed. Returns 0 — condense nothing — when the node
44
+ * has not yielded often enough to have older cycles, so a fresh or
45
+ * single-yield node renders exactly as it always did.
46
+ */
47
+ export function liveRegionStart(messages, liveCycles) {
48
+ const keep = Math.max(1, Math.floor(liveCycles));
49
+ const dividers = [];
50
+ for (let i = 0; i < messages.length; i++) {
51
+ if (isCycleDivider(messages[i]))
52
+ dividers.push(i);
53
+ }
54
+ const cycles = dividers.length + 1;
55
+ if (cycles <= keep)
56
+ return 0;
57
+ // The last divider that closes a condensed cycle; the live region opens just
58
+ // after it (the divider itself stays in the condensed region as the seam).
59
+ return dividers[cycles - keep - 1] + 1;
60
+ }
@@ -1,18 +1,13 @@
1
- // render/context-message.ts — the viewer's foldable renderer for hidden-context custom
2
- // messages: the `<crtr-context>` bearings block (customType `crtr-context`) and
3
- // the ambient `<situational-context>` update (customType
4
- // `crtr-situational-context`). Both are broker-injected (canvas-context-intro,
5
- // canvas-inbox-watcher), so the VIEWER has no message renderer for either and
6
- // would otherwise show the full block always. This reimplements the bearings
7
- // renderer natively (src/pi-extensions/canvas-context-intro.ts:
8
- // `renderContextMessage`) and reuses the same collapsed/expand shape for
9
- // situational context so both render consistently: collapsed → one dim line;
10
- // expanded (Ctrl+O) → label + full wrapped body. It implements `setExpanded`,
11
- // so the Ctrl+O tool-display cycle (ChatView.cycleToolDisplay →
12
- // isExpandable) folds it alongside tool output.
1
+ // render/context-message.ts — the terminal viewer's foldable renderer for
2
+ // crouter-generated context. Session bearings, situational updates, fresh-revive
3
+ // kickoffs, context-size nudges, and node inbox digests retain their full model-visible body and
4
+ // runtime role, while this display-only component presents them consistently:
5
+ // collapsed → one dim line; expanded (Ctrl+O) → label + full wrapped body. It
6
+ // implements `setExpanded`, so the global tool-display cycle folds these blocks
7
+ // alongside tool output. Classification lives in shared/generated-context.ts;
8
+ // this component only renders the presentation it is handed.
13
9
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
14
- /** Default label/hint for the `crtr-context` bearings block. Mirrors the
15
- * extension's customType stamp. */
10
+ /** Default label/hint for the `crtr-context` bearings block. */
16
11
  const LABEL = 'crtr context';
17
12
  const COLLAPSED_HINT = ' orienting bearings — ctrl+o to expand';
18
13
  /** Char-based wrap that never emits an over-width line (pi-tui hard-crashes on
@@ -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 {};
@@ -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);
@@ -1,9 +1,11 @@
1
- import { type BindingId, type BindingResolution } from '../../../core/keybindings/index.js';
2
- export type AttachBindingId = Extract<BindingId, `crtr.attach.${string}` | 'crtr.mode.cycle'>;
1
+ import { type AttachBindingId, type BindingId, type BindingResolution } from '../../../core/keybindings/index.js';
2
+ export type { AttachBindingId } from '../../../core/keybindings/index.js';
3
3
  export type AttachBindings = BindingResolution<BindingId>;
4
+ export type AttachActions = Record<AttachBindingId, () => void>;
4
5
  export { inboxOpenHint } from '../../../core/keybindings/index.js';
5
6
  export declare function matchesAttachBinding(bindings: AttachBindings, id: AttachBindingId, data: string): boolean;
6
- export declare function dispatchAttachBinding(bindings: AttachBindings, data: string, actions: Record<AttachBindingId, () => void>): boolean;
7
+ export declare function dispatchAttachAction(id: AttachBindingId, actions: AttachActions): void;
8
+ export declare function dispatchAttachBinding(bindings: AttachBindings, data: string, actions: AttachActions): boolean;
7
9
  export declare function detachHint(bindings: AttachBindings): string | undefined;
8
10
  /** Re-resolve the user's binding catalog and republish it to every consumer:
9
11
  * the graph overlay's own snapshot and (in tmux) the crouter-owned tmux