@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.
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/memory-loading.md +45 -0
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +1 -1
- package/dist/clients/attach/__tests__/edit-diff.test.js +21 -20
- package/dist/clients/attach/render/chat-view.js +46 -35
- package/dist/clients/attach/render/crtr-output.d.ts +2 -1
- package/dist/clients/attach/render/crtr-output.js +4 -4
- package/dist/clients/attach/render/edit-diff.d.ts +21 -6
- package/dist/clients/attach/render/edit-diff.js +90 -91
- package/dist/clients/attach/render/tool-calls.d.ts +14 -18
- package/dist/clients/attach/render/tool-calls.js +66 -41
- package/dist/clients/attach/viewer.js +376 -374
- package/dist/commands/memory/lint.js +1 -1
- package/dist/commands/memory/write.js +1 -1
- package/dist/pi-extensions/canvas-tool-guide.js +4 -1
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
|
@@ -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
|
-
|
|
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('
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const context = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
};
|
|
27
|
-
definition.renderCall(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const folded = definition.renderCall(
|
|
32
|
-
assert.match(folded, /\+
|
|
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 {
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
194
|
+
if (!this.foldController || this.minimized === minimized)
|
|
190
195
|
return;
|
|
191
196
|
this.minimized = minimized;
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
//
|
|
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
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
? ` ${
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 —
|
|
1
|
+
// render/edit-diff.ts — crouter-owned edit presentation for the attach viewer.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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,
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 {};
|