@north-light/crouter 0.3.182 → 0.3.183
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/build-root.js +10 -3
- package/dist/builtin-memory/internal/memory-loading.md +3 -1
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
- package/dist/clients/attach/input/titled-editor.d.ts +18 -0
- package/dist/clients/attach/input/titled-editor.js +62 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +15 -6
- package/dist/clients/attach/render/context-message.js +2 -2
- package/dist/clients/attach/render/group-activity.d.ts +18 -1
- package/dist/clients/attach/render/group-activity.js +54 -6
- package/dist/clients/attach/render/group-recap.js +13 -2
- package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
- package/dist/clients/attach/render/markdown-source.js +165 -0
- package/dist/clients/attach/session/bindings.js +5 -2
- package/dist/clients/attach/viewer.js +396 -394
- package/dist/clients/inbox/review/keys.js +2 -9
- package/dist/clients/inbox/tui/keys.js +2 -9
- package/dist/commands/cron.js +1 -1
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/find.js +1 -1
- package/dist/commands/memory/lint.js +28 -3
- package/dist/commands/memory/list.js +1 -1
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +4 -4
- package/dist/commands/memory/write.js +1 -1
- package/dist/commands/sys/settings-shell.d.ts +0 -3
- package/dist/commands/sys/settings-shell.js +1 -38
- package/dist/commands/sys/settings.js +2 -3
- package/dist/commands/sys/setup-core.d.ts +2 -17
- package/dist/commands/sys/setup-core.js +9 -33
- package/dist/commands/sys/setup-wizard.d.ts +1 -3
- package/dist/commands/sys/setup-wizard.js +9 -39
- package/dist/commands/sys/setup.js +6 -6
- package/dist/commands/sys/sync-project-guidance.js +62 -12
- package/dist/core/__tests__/fault-classifier.test.js +20 -0
- package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
- package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
- package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
- package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
- package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
- package/dist/core/command.js +5 -3
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/fault-classifier.js +7 -0
- package/dist/core/fs-utils.js +6 -1
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/index.js +1 -1
- package/dist/core/keybindings/match.d.ts +11 -0
- package/dist/core/keybindings/match.js +22 -0
- package/dist/core/memory/doc-link-grammar.js +3 -7
- package/dist/core/memory/inline-ref-guidance.js +1 -1
- package/dist/core/memory-resolver.d.ts +7 -1
- package/dist/core/memory-resolver.js +20 -7
- package/dist/core/nested-stores.d.ts +12 -0
- package/dist/core/nested-stores.js +146 -0
- package/dist/core/preview-registry.js +4 -2
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/broker/fault-retry.js +23 -4
- package/dist/core/runtime/broker-extension-render.d.ts +1 -1
- package/dist/core/runtime/broker-extension-render.js +2 -2
- package/dist/core/runtime/broker-protocol.d.ts +1 -1
- package/dist/core/runtime/deliver-live.js +11 -1
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/tmux-bindings.js +42 -20
- package/dist/core/runtime/tool-group-summary.js +2 -6
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +19 -0
- package/dist/core/substrate/on-read-node.d.ts +4 -2
- package/dist/core/substrate/on-read-node.js +5 -3
- package/dist/core/substrate/on-read.d.ts +4 -2
- package/dist/core/substrate/on-read.js +27 -5
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
- package/dist/pi-extensions/canvas-context-intro.js +14 -3
- package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
- package/dist/shared/generated-context.d.ts +4 -0
- package/dist/shared/generated-context.js +20 -1
- package/dist/shared/tool-groups.d.ts +3 -3
- package/dist/shared/tool-groups.js +1 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +1 -1
- package/dist/clients/attach/render/markdown-headings.js +0 -93
- package/dist/commands/search/answer.d.ts +0 -1
- package/dist/commands/search/answer.js +0 -50
- package/dist/commands/search/contents.d.ts +0 -1
- package/dist/commands/search/contents.js +0 -96
- package/dist/commands/search/exa.d.ts +0 -53
- package/dist/commands/search/exa.js +0 -157
- package/dist/commands/search/puremd.d.ts +0 -11
- package/dist/commands/search/puremd.js +0 -53
- package/dist/commands/search/web.d.ts +0 -1
- package/dist/commands/search/web.js +0 -65
- package/dist/commands/search.d.ts +0 -2
- package/dist/commands/search.js +0 -24
- package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
- package/dist/commands/sys/panels/keys-panel.js +0 -60
package/dist/build-root.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { defineRoot } from './core/command.js';
|
|
2
2
|
const TAGLINE = 'crtr: agentic runtime.';
|
|
3
|
+
/** Flags handled before dispatch, so they belong to no leaf schema and are
|
|
4
|
+
* stated once at root (help.ts renders them as the Globals footer). */
|
|
5
|
+
const GLOBALS = [
|
|
6
|
+
{
|
|
7
|
+
name: '--json',
|
|
8
|
+
desc: "stdout as the leaf's declared outputs in raw JSON instead of prose — for scripts (a cron's bash, external tooling) that must branch on canvas state; prose stays the contract for what you read yourself.",
|
|
9
|
+
},
|
|
10
|
+
];
|
|
3
11
|
/** Lazy registry: subtree name → dynamic importer that builds its BranchDef.
|
|
4
12
|
* The whole point is that each `import()` only compiles that one subtree's
|
|
5
13
|
* module graph. The hot path (`crtr surface node focus …`, `crtr push …`, …) dispatches
|
|
@@ -10,7 +18,6 @@ const TAGLINE = 'crtr: agentic runtime.';
|
|
|
10
18
|
const SUBTREE_LOADERS = {
|
|
11
19
|
memory: async () => (await import('./commands/memory.js')).registerMemory(),
|
|
12
20
|
profile: async () => (await import('./commands/profile.js')).registerProfile(),
|
|
13
|
-
search: async () => (await import('./commands/search.js')).registerSearch(),
|
|
14
21
|
pkg: async () => (await import('./commands/pkg.js')).registerPkg(),
|
|
15
22
|
human: async () => (await import('./commands/human.js')).registerHuman(),
|
|
16
23
|
sys: async () => (await import('./commands/sys.js')).registerSys(),
|
|
@@ -53,7 +60,7 @@ export async function coreCommandPaths() {
|
|
|
53
60
|
export async function resolveRoot(first) {
|
|
54
61
|
const loader = first !== undefined ? SUBTREE_LOADERS[first] : undefined;
|
|
55
62
|
if (loader !== undefined) {
|
|
56
|
-
return defineRoot({ tagline: TAGLINE, globals:
|
|
63
|
+
return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [await loader()] });
|
|
57
64
|
}
|
|
58
65
|
return buildRoot();
|
|
59
66
|
}
|
|
@@ -88,5 +95,5 @@ export async function buildRoot() {
|
|
|
88
95
|
// sys doctor). Reads only stored manifests — zero network.
|
|
89
96
|
const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES));
|
|
90
97
|
const external = composeExternalSubtrees(snapshot);
|
|
91
|
-
return defineRoot({ tagline: TAGLINE, globals:
|
|
98
|
+
return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...core, ...external] });
|
|
92
99
|
}
|
|
@@ -13,7 +13,7 @@ Every memory doc declares its own loading policy in frontmatter; the runtime nev
|
|
|
13
13
|
## The two hooks
|
|
14
14
|
|
|
15
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**
|
|
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, and `.`-routed docs whose store's owning dir contains the read file. Nothing positional fires from where the doc happens to sit on disk — only from its declared route.
|
|
17
17
|
|
|
18
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
19
|
|
|
@@ -38,6 +38,8 @@ At boot/first-message assembly the runtime mounts: builtin docs, the user store
|
|
|
38
38
|
|
|
39
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
40
|
|
|
41
|
+
A `.crouter/memory/` store nested BELOW a mounted root (a package or subsystem dir) is delivered by the read path, not by boot: reading any file beneath its owning dir surfaces its `.`-routed docs, deduplicated against everything already in the transcript. Addressability is separate — the `crtr memory` leaves (list/read/find/lint/delete/origin) discover nested stores through a bounded walk (git-aware, depth- and time-capped), while the boot catalog stays ancestor+profile only, which is why a nested doc's `system-prompt-visibility` rung is inert (lint warns; set `none`).
|
|
42
|
+
|
|
41
43
|
## Ordering
|
|
42
44
|
|
|
43
45
|
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.
|
|
@@ -30,7 +30,7 @@ async function summarizedGroup(messages, key) {
|
|
|
30
30
|
stats: {},
|
|
31
31
|
state: { isStreaming: false },
|
|
32
32
|
toolGroupSummaries: {
|
|
33
|
-
[key]: { bullets: ['broker recap'],
|
|
33
|
+
[key]: { bullets: ['broker recap'], filesDeleted: 0 },
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
const group = internals.groups.find((candidate) => candidate.key === key);
|
|
@@ -70,7 +70,7 @@ test('a folded recap carries paths and counts, never diff or write payload bodie
|
|
|
70
70
|
bashMode: tone('bashMode'), bashModeAlt: tone('bashModeAlt'),
|
|
71
71
|
};
|
|
72
72
|
const recap = new GroupRecapComponent();
|
|
73
|
-
recap.setRecap({ bullets: ['finished'],
|
|
73
|
+
recap.setRecap({ bullets: ['finished'], filesDeleted: 2 }, 2, { memories: [{ action: 'read', name: 'alpha', path: '/tmp/memories/alpha.md' }], files: recorder.activity().files, nodes: [] }, true, palette);
|
|
74
74
|
const text = recap.render(120).join('\n');
|
|
75
75
|
assert.match(text, /overwritten\.ts/, 'the recap still names what changed');
|
|
76
76
|
assert.doesNotMatch(text, /sensitive-old-diff|sensitive-new-diff|sensitive-write-payload/, 'payload bodies never leak into a recap');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// titled-editor-paste.test.ts — regression for the unbracketed multi-line paste
|
|
2
|
+
// bug: a multi-line paste into the attach viewer submitted its FIRST line as a
|
|
3
|
+
// prompt, then turned every following line into its own turn, a queued steering
|
|
4
|
+
// message, or a "Agent is already processing" rejection.
|
|
5
|
+
//
|
|
6
|
+
// Root cause: when a terminal delivers a paste WITHOUT bracketed-paste markers,
|
|
7
|
+
// pi-tui's StdinBuffer never enters paste mode and splits the paste into single
|
|
8
|
+
// sequences, so each embedded line ending reaches the editor as a bare Enter and
|
|
9
|
+
// the stock editor submits on it. TitledEditor.handleInput holds a line ending
|
|
10
|
+
// for PASTE_BURST_MS and settles it on what FOLLOWS: more input means a paste's
|
|
11
|
+
// own line break (insert a newline), silence means a human pressed Enter
|
|
12
|
+
// (submit).
|
|
13
|
+
//
|
|
14
|
+
// Deciding on what follows — not on the gap before — is what keeps
|
|
15
|
+
// `tmux send-keys <text> Enter` (one write, so its Enter arrives microseconds
|
|
16
|
+
// after the text) submitting, which the earlier gap-based rescue broke. Both
|
|
17
|
+
// properties are pinned below.
|
|
18
|
+
//
|
|
19
|
+
// The tests drive handleInput directly, bypassing the heavy TUI/stdin
|
|
20
|
+
// constructor via Object.create and stubbing only the members the branch
|
|
21
|
+
// touches; `super.handleInput` is intercepted by replacing
|
|
22
|
+
// CustomEditor.prototype.handleInput (what the ES `super` call resolves to).
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
26
|
+
import { CustomEditor } from '@earendil-works/pi-coding-agent';
|
|
27
|
+
import { TitledEditor } from '../input/titled-editor.js';
|
|
28
|
+
/** Longer than PASTE_BURST_MS (10ms) — the hold window must have expired. */
|
|
29
|
+
const AFTER_HOLD_MS = 40;
|
|
30
|
+
/** A TitledEditor with its constructor bypassed and its collaborators stubbed:
|
|
31
|
+
* - `km.matches` recognizes only the real Enter (`\r`) as `tui.input.submit`
|
|
32
|
+
* and never the newLine chord (so that branch stays out of the way);
|
|
33
|
+
* - `isShowingAutocomplete` is always false;
|
|
34
|
+
* - `insertTextAtCursor` records inserts;
|
|
35
|
+
* - the inherited (super) handleInput records what would have been submitted. */
|
|
36
|
+
function makeHarness() {
|
|
37
|
+
const inserted = [];
|
|
38
|
+
const submitted = [];
|
|
39
|
+
const editor = Object.create(TitledEditor.prototype);
|
|
40
|
+
Object.assign(editor, {
|
|
41
|
+
km: {
|
|
42
|
+
matches: (data, binding) => binding === 'tui.input.submit' ? data === '\r' : false,
|
|
43
|
+
},
|
|
44
|
+
tui: { requestRender: () => { } },
|
|
45
|
+
isShowingAutocomplete: () => false,
|
|
46
|
+
insertTextAtCursor: (s) => inserted.push(s),
|
|
47
|
+
getCursor: () => ({ line: 0, col: 0 }),
|
|
48
|
+
getLines: () => [''],
|
|
49
|
+
maybeTriggerNonLeadingRefAutocomplete: () => { },
|
|
50
|
+
});
|
|
51
|
+
const originalSuper = CustomEditor.prototype.handleInput;
|
|
52
|
+
// `super.handleInput(data)` inside TitledEditor resolves to this prototype
|
|
53
|
+
// method; the stock editor SUBMITS a bare `\r`, so record that as a submit.
|
|
54
|
+
CustomEditor.prototype.handleInput = function (data) {
|
|
55
|
+
if (data === '\r' || data === '\n')
|
|
56
|
+
submitted.push(data);
|
|
57
|
+
};
|
|
58
|
+
return { editor, inserted, submitted, restore: () => { CustomEditor.prototype.handleInput = originalSuper; } };
|
|
59
|
+
}
|
|
60
|
+
test('unbracketed multi-line paste becomes newlines, never a mid-paste submit', async () => {
|
|
61
|
+
const { editor, inserted, submitted, restore } = makeHarness();
|
|
62
|
+
try {
|
|
63
|
+
// A paste floods handleInput synchronously: "line1\rline2\rline3".
|
|
64
|
+
for (const ch of ['l', 'i', 'n', 'e', '1', '\r', 'l', 'i', 'n', 'e', '2', '\r', 'l', 'i', 'n', 'e', '3']) {
|
|
65
|
+
editor.handleInput(ch);
|
|
66
|
+
}
|
|
67
|
+
await delay(AFTER_HOLD_MS);
|
|
68
|
+
assert.equal(inserted.filter((s) => s === '\n').length, 2, 'both pasted returns became newlines');
|
|
69
|
+
assert.equal(submitted.length, 0, 'no submit fired mid-paste — the full paste survives');
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
restore();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
test('an unbracketed paste ending in a newline submits ONCE, carrying the whole block', async () => {
|
|
76
|
+
const { editor, inserted, submitted, restore } = makeHarness();
|
|
77
|
+
try {
|
|
78
|
+
for (const ch of ['a', '\n', 'b', '\n'])
|
|
79
|
+
editor.handleInput(ch);
|
|
80
|
+
await delay(AFTER_HOLD_MS);
|
|
81
|
+
assert.equal(inserted.filter((s) => s === '\n').length, 1, 'the interior line feed became a newline');
|
|
82
|
+
assert.equal(submitted.length, 1, 'only the trailing line feed submitted, with both lines in the editor');
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
restore();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test('a human Enter submits', async () => {
|
|
89
|
+
const { editor, inserted, submitted, restore } = makeHarness();
|
|
90
|
+
try {
|
|
91
|
+
editor.handleInput('\r');
|
|
92
|
+
await delay(AFTER_HOLD_MS);
|
|
93
|
+
assert.equal(submitted.length, 1, 'a pressed Enter submits');
|
|
94
|
+
assert.equal(inserted.length, 0, 'a pressed Enter does not insert a newline');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
restore();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
test('text and Enter delivered in one write (tmux send-keys) still submits', async () => {
|
|
101
|
+
const { editor, inserted, submitted, restore } = makeHarness();
|
|
102
|
+
try {
|
|
103
|
+
// `tmux send-keys -t <pane> '/graph' Enter` flushes both in a single write,
|
|
104
|
+
// so the Enter arrives microseconds after the text — with nothing after it.
|
|
105
|
+
for (const ch of ['/', 'g', 'r', 'a', 'p', 'h', '\r'])
|
|
106
|
+
editor.handleInput(ch);
|
|
107
|
+
await delay(AFTER_HOLD_MS);
|
|
108
|
+
assert.equal(submitted.length, 1, 'the trailing Enter of a send-keys burst submits');
|
|
109
|
+
assert.equal(inserted.length, 0, 'nothing was turned into a newline');
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
restore();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -66,7 +66,7 @@ test('Enter-confirming a non-leading ref completion accepts the token and return
|
|
|
66
66
|
assert.equal(editor.getText(), 'hey check /dev', 'the token was replaced in place, not submitted as typed');
|
|
67
67
|
assert.equal(submitted, undefined, 'Enter-confirming a ref completion must never submit the message');
|
|
68
68
|
});
|
|
69
|
-
test('a real Enter submission preserves raw leading/trailing whitespace end-to-end', () => {
|
|
69
|
+
test('a real Enter submission preserves raw leading/trailing whitespace end-to-end', async () => {
|
|
70
70
|
const editor = makeEditor();
|
|
71
71
|
let submitted;
|
|
72
72
|
editor.onSubmit = (text) => {
|
|
@@ -74,6 +74,9 @@ test('a real Enter submission preserves raw leading/trailing whitespace end-to-e
|
|
|
74
74
|
};
|
|
75
75
|
editor.setText(' hello world ');
|
|
76
76
|
editor.handleInput('\r'); // real Enter keystroke — tui.input.submit
|
|
77
|
+
// The unbracketed-paste rescue holds a line ending for PASTE_BURST_MS to see
|
|
78
|
+
// whether more input follows it (a paste's own line break) before submitting.
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
77
80
|
assert.equal(submitted, ' hello world ', 'onSubmit receives the exact untrimmed text, not the base editor\'s trimmed copy');
|
|
78
81
|
assert.equal(editor.getText(), '', 'submitValue still resets editor state as usual');
|
|
79
82
|
});
|
|
@@ -84,6 +84,17 @@ export declare class TitledEditor extends CustomEditor {
|
|
|
84
84
|
* converted to an accessor below whose getter always returns our substitution
|
|
85
85
|
* wrapper, not this value directly. */
|
|
86
86
|
private realOnSubmit;
|
|
87
|
+
/** A line ending held back to learn whether more input follows it within
|
|
88
|
+
* `PASTE_BURST_MS` — see `handleInput`'s unbracketed-paste rescue. More input
|
|
89
|
+
* means it was a line break inside a paste (insert a newline); silence means
|
|
90
|
+
* a human pressed Enter (submit). */
|
|
91
|
+
private pendingReturn;
|
|
92
|
+
/** How long a line ending is held before it counts as a real Enter. An
|
|
93
|
+
* unbracketed paste floods `handleInput` within one event-loop tick, so its
|
|
94
|
+
* next character always lands far inside this window; a human's next
|
|
95
|
+
* keystroke after Enter is ~30ms+ away at the very fastest. Short enough that
|
|
96
|
+
* the added submit latency is imperceptible. */
|
|
97
|
+
private static readonly PASTE_BURST_MS;
|
|
87
98
|
constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], options?: EditorOptions);
|
|
88
99
|
/** Insert a newline on the newLine chord (Alt+Enter / Shift+Enter) using crtr's
|
|
89
100
|
* OWN keybindings manager, then defer everything else to the stock editor.
|
|
@@ -101,6 +112,13 @@ export declare class TitledEditor extends CustomEditor {
|
|
|
101
112
|
* the terminal delivers as bracketed-paste TEXT, not an image event). Returns
|
|
102
113
|
* true when it consumed the drop, so the raw path is never inserted as text. */
|
|
103
114
|
onPasteImagePath?: (path: string) => boolean;
|
|
115
|
+
/** Settle a line ending held by the unbracketed-paste rescue. `'newline'` (more
|
|
116
|
+
* input arrived, so it was a paste's own line break) inserts it in place;
|
|
117
|
+
* `'submit'` (the hold window expired in silence, so a human pressed Enter)
|
|
118
|
+
* replays it through the stock editor and repaints, because this runs outside
|
|
119
|
+
* the TUI's own post-`handleInput` render request. No-op when nothing is
|
|
120
|
+
* held. */
|
|
121
|
+
private settleHeldReturn;
|
|
104
122
|
/** Width of an `[Image #N]` token sitting immediately before the cursor on the
|
|
105
123
|
* current line, or 0 when the cursor isn't at the end of such a token — used to
|
|
106
124
|
* delete/skip the token atomically. The token's separating space is a normal,
|
|
@@ -441,6 +441,17 @@ export class TitledEditor extends CustomEditor {
|
|
|
441
441
|
* converted to an accessor below whose getter always returns our substitution
|
|
442
442
|
* wrapper, not this value directly. */
|
|
443
443
|
realOnSubmit;
|
|
444
|
+
/** A line ending held back to learn whether more input follows it within
|
|
445
|
+
* `PASTE_BURST_MS` — see `handleInput`'s unbracketed-paste rescue. More input
|
|
446
|
+
* means it was a line break inside a paste (insert a newline); silence means
|
|
447
|
+
* a human pressed Enter (submit). */
|
|
448
|
+
pendingReturn;
|
|
449
|
+
/** How long a line ending is held before it counts as a real Enter. An
|
|
450
|
+
* unbracketed paste floods `handleInput` within one event-loop tick, so its
|
|
451
|
+
* next character always lands far inside this window; a human's next
|
|
452
|
+
* keystroke after Enter is ~30ms+ away at the very fastest. Short enough that
|
|
453
|
+
* the added submit latency is imperceptible. */
|
|
454
|
+
static PASTE_BURST_MS = 10;
|
|
444
455
|
// CustomEditor's ctor signature; captured so we own the newline chord (see
|
|
445
456
|
// handleInput). The keybindings param is typed as pi's app-subclass manager,
|
|
446
457
|
// but the attach viewer feeds it pi-tui's base manager — hence the cast.
|
|
@@ -507,6 +518,25 @@ export class TitledEditor extends CustomEditor {
|
|
|
507
518
|
* the terminal delivers as bracketed-paste TEXT, not an image event). Returns
|
|
508
519
|
* true when it consumed the drop, so the raw path is never inserted as text. */
|
|
509
520
|
onPasteImagePath;
|
|
521
|
+
/** Settle a line ending held by the unbracketed-paste rescue. `'newline'` (more
|
|
522
|
+
* input arrived, so it was a paste's own line break) inserts it in place;
|
|
523
|
+
* `'submit'` (the hold window expired in silence, so a human pressed Enter)
|
|
524
|
+
* replays it through the stock editor and repaints, because this runs outside
|
|
525
|
+
* the TUI's own post-`handleInput` render request. No-op when nothing is
|
|
526
|
+
* held. */
|
|
527
|
+
settleHeldReturn(as) {
|
|
528
|
+
const held = this.pendingReturn;
|
|
529
|
+
if (held === undefined)
|
|
530
|
+
return;
|
|
531
|
+
clearTimeout(held.timer);
|
|
532
|
+
this.pendingReturn = undefined;
|
|
533
|
+
if (as === 'newline') {
|
|
534
|
+
this.insertTextAtCursor('\n');
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
super.handleInput(held.data);
|
|
538
|
+
this.tui.requestRender();
|
|
539
|
+
}
|
|
510
540
|
/** Width of an `[Image #N]` token sitting immediately before the cursor on the
|
|
511
541
|
* current line, or 0 when the cursor isn't at the end of such a token — used to
|
|
512
542
|
* delete/skip the token atomically. The token's separating space is a normal,
|
|
@@ -531,6 +561,9 @@ export class TitledEditor extends CustomEditor {
|
|
|
531
561
|
return match ? match[0].length : 0;
|
|
532
562
|
}
|
|
533
563
|
handleInput(data) {
|
|
564
|
+
// Any input arriving while a line ending is held settles it as a paste's own
|
|
565
|
+
// line break: insert it FIRST so this key lands after it, in typed order.
|
|
566
|
+
this.settleHeldReturn('newline');
|
|
534
567
|
// Keep ↓ on the editor's native focused-input path. Only when its own
|
|
535
568
|
// wrap-aware cursor and prompt-history navigation make no change does the
|
|
536
569
|
// adjacent roster get a chance to take over.
|
|
@@ -590,6 +623,35 @@ export class TitledEditor extends CustomEditor {
|
|
|
590
623
|
const droppedPath = detectDroppedImagePath(data);
|
|
591
624
|
if (droppedPath && this.onPasteImagePath?.(droppedPath))
|
|
592
625
|
return;
|
|
626
|
+
// Unbracketed multi-line paste rescue. When the terminal delivers a paste
|
|
627
|
+
// WITHOUT bracketed-paste markers (\x1b[200~ … \x1b[201~) — the outer
|
|
628
|
+
// terminal may not support them, or tmux attached its client with a terminfo
|
|
629
|
+
// entry lacking the Enbp/Dsbp caps — pi-tui's StdinBuffer never enters paste
|
|
630
|
+
// mode and instead splits the paste into single sequences. Each embedded
|
|
631
|
+
// line ending then reaches us as a bare Enter, and the stock editor SUBMITS
|
|
632
|
+
// on it: line one becomes a prompt and every following line becomes its own
|
|
633
|
+
// turn, is queued as steering, or is lost to pi's "Agent is already
|
|
634
|
+
// processing" rejection when it lands inside prompt()'s async preflight. The
|
|
635
|
+
// bracketed path (when markers DO arrive) is a single `\x1b[200~…` sequence
|
|
636
|
+
// that never trips this branch, so it stays fully intact.
|
|
637
|
+
//
|
|
638
|
+
// A pasted line break is told from a pressed Enter by what FOLLOWS it, not
|
|
639
|
+
// by what precedes it: a paste floods handleInput with its next character
|
|
640
|
+
// microseconds later, while an Enter is followed by human-scale silence.
|
|
641
|
+
// Deciding on the following input (rather than the preceding gap) is what
|
|
642
|
+
// keeps `tmux send-keys <text> Enter` — one write, so its Enter arrives
|
|
643
|
+
// microseconds after the text — submitting as intended, since nothing
|
|
644
|
+
// follows it. A paste that ends in a trailing newline likewise submits, by
|
|
645
|
+
// then carrying its WHOLE text, so no line is ever split off or lost.
|
|
646
|
+
// Skipped while the autocomplete popup is open, where Enter confirms a
|
|
647
|
+
// completion and must stay immediate.
|
|
648
|
+
if ((data === '\r' || data === '\n') && !this.isShowingAutocomplete()) {
|
|
649
|
+
this.pendingReturn = {
|
|
650
|
+
data,
|
|
651
|
+
timer: setTimeout(() => this.settleHeldReturn('submit'), TitledEditor.PASTE_BURST_MS),
|
|
652
|
+
};
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
593
655
|
const { line: beforeLine, col: beforeCol } = this.getCursor();
|
|
594
656
|
const beforeText = this.getLines()[beforeLine] ?? '';
|
|
595
657
|
super.handleInput(data);
|
|
@@ -96,6 +96,9 @@ export declare class ChatView {
|
|
|
96
96
|
private readonly groups;
|
|
97
97
|
private openGroup;
|
|
98
98
|
private readonly groupSummaries;
|
|
99
|
+
/** Node id → display name for every node spawned in this transcript, so a
|
|
100
|
+
* later group's finish notice can name the child rather than its id. */
|
|
101
|
+
private readonly nodeRoster;
|
|
99
102
|
private streamingWasGroupMember;
|
|
100
103
|
private readonly onFooterEvent;
|
|
101
104
|
/** Viewer hook that places activity in its fixed slot, when available. */
|
|
@@ -30,7 +30,7 @@ import { generatedContextExpandedBody, generatedContextPresentation, isRuntimeRe
|
|
|
30
30
|
import { assistantVisibleText, isTrueUserMessage, } from '../../../shared/tool-groups.js';
|
|
31
31
|
import { transformMessageDiagrams } from './diagram.js';
|
|
32
32
|
import { transformMessageHtml } from './html-markdown.js';
|
|
33
|
-
import {
|
|
33
|
+
import { styleAttachMarkdownSource, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-source.js';
|
|
34
34
|
import { createCrtrEditToolDefinition } from './edit-diff.js';
|
|
35
35
|
import { GroupActivityRecorder } from './group-activity.js';
|
|
36
36
|
import { GroupRecapComponent } from './group-recap.js';
|
|
@@ -290,6 +290,9 @@ export class ChatView {
|
|
|
290
290
|
groups = [];
|
|
291
291
|
openGroup;
|
|
292
292
|
groupSummaries = new Map();
|
|
293
|
+
/** Node id → display name for every node spawned in this transcript, so a
|
|
294
|
+
* later group's finish notice can name the child rather than its id. */
|
|
295
|
+
nodeRoster = new Map();
|
|
293
296
|
streamingWasGroupMember = false;
|
|
294
297
|
onFooterEvent;
|
|
295
298
|
/** Viewer hook that places activity in its fixed slot, when available. */
|
|
@@ -1032,7 +1035,11 @@ export class ChatView {
|
|
|
1032
1035
|
this.appendExistingGroupMember(component);
|
|
1033
1036
|
}
|
|
1034
1037
|
else {
|
|
1035
|
-
this.appendGroupMember(component);
|
|
1038
|
+
const group = this.appendGroupMember(component);
|
|
1039
|
+
// A child's final arrives as an inbox digest, not a tool call: without
|
|
1040
|
+
// this the folded recap would hide the one row saying it finished.
|
|
1041
|
+
if (generated.kind === 'inbox')
|
|
1042
|
+
group?.activity.noteInboxDigest(generated.body);
|
|
1036
1043
|
}
|
|
1037
1044
|
return;
|
|
1038
1045
|
}
|
|
@@ -1104,18 +1111,18 @@ export class ChatView {
|
|
|
1104
1111
|
this.appendUserPart(new Spacer(1), !isTrueUserMessage(message));
|
|
1105
1112
|
const skillBlock = parseSkillBlock(textContent);
|
|
1106
1113
|
if (skillBlock) {
|
|
1107
|
-
const component = new SkillInvocationMessageComponent({ ...skillBlock, content:
|
|
1114
|
+
const component = new SkillInvocationMessageComponent({ ...skillBlock, content: styleAttachMarkdownSource(skillBlock.content) }, attachMarkdownTheme());
|
|
1108
1115
|
component.setExpanded(this.toolOutputExpanded);
|
|
1109
1116
|
this.appendUserPart(component, !isTrueUserMessage(message));
|
|
1110
1117
|
this.registerCopyBlock(component, 'user', textContent);
|
|
1111
1118
|
if (skillBlock.userMessage) {
|
|
1112
|
-
const userMessage = new UserMessageComponent(
|
|
1119
|
+
const userMessage = new UserMessageComponent(styleAttachMarkdownSource(skillBlock.userMessage), attachMarkdownTheme());
|
|
1113
1120
|
this.appendUserPart(userMessage, !isTrueUserMessage(message));
|
|
1114
1121
|
this.registerCopyBlock(userMessage, 'user', skillBlock.userMessage);
|
|
1115
1122
|
}
|
|
1116
1123
|
}
|
|
1117
1124
|
else {
|
|
1118
|
-
const component = new UserMessageComponent(
|
|
1125
|
+
const component = new UserMessageComponent(styleAttachMarkdownSource(textContent), attachMarkdownTheme());
|
|
1119
1126
|
this.appendUserPart(component, !isTrueUserMessage(message));
|
|
1120
1127
|
this.registerCopyBlock(component, 'user', textContent);
|
|
1121
1128
|
}
|
|
@@ -1183,7 +1190,7 @@ export class ChatView {
|
|
|
1183
1190
|
const group = {
|
|
1184
1191
|
members: [],
|
|
1185
1192
|
recap,
|
|
1186
|
-
activity: new GroupActivityRecorder(this.cwd),
|
|
1193
|
+
activity: new GroupActivityRecorder(this.cwd, this.nodeRoster),
|
|
1187
1194
|
toolCount: 0,
|
|
1188
1195
|
inFlight: new Set(),
|
|
1189
1196
|
};
|
|
@@ -1203,6 +1210,7 @@ export class ChatView {
|
|
|
1203
1210
|
this.append(component);
|
|
1204
1211
|
if (group)
|
|
1205
1212
|
group.members.push(component);
|
|
1213
|
+
return group;
|
|
1206
1214
|
}
|
|
1207
1215
|
/** Used for context that may join an existing run but must not create one. */
|
|
1208
1216
|
appendExistingGroupMember(component) {
|
|
@@ -1422,6 +1430,7 @@ export class ChatView {
|
|
|
1422
1430
|
this.groups.length = 0;
|
|
1423
1431
|
this.openGroup = undefined;
|
|
1424
1432
|
this.groupSummaries.clear();
|
|
1433
|
+
this.nodeRoster.clear();
|
|
1425
1434
|
this.pendingTools.clear();
|
|
1426
1435
|
this.lastAssistantText = undefined;
|
|
1427
1436
|
this.copyBlockRecords.length = 0;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { UserMessageComponent } from '@earendil-works/pi-coding-agent';
|
|
12
12
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
13
13
|
import { attachMarkdownTheme } from '../config.js';
|
|
14
|
-
import {
|
|
14
|
+
import { styleAttachMarkdownSource } from './markdown-source.js';
|
|
15
15
|
/** Default label/hint for the `crtr-context` bearings block. */
|
|
16
16
|
const LABEL = 'crtr context';
|
|
17
17
|
const COLLAPSED_HINT = ' orienting bearings — ctrl+o to expand';
|
|
@@ -30,7 +30,7 @@ export class ContextMessageComponent {
|
|
|
30
30
|
this.collapsedHint = collapsedHint;
|
|
31
31
|
this.expandsWithTools = expandsWithTools;
|
|
32
32
|
this.expanded = expandsWithTools && expanded;
|
|
33
|
-
this.expandedBody = new UserMessageComponent(
|
|
33
|
+
this.expandedBody = new UserMessageComponent(styleAttachMarkdownSource(body), attachMarkdownTheme());
|
|
34
34
|
}
|
|
35
35
|
/** Ctrl+O global tool-output toggle (ChatView.isExpandable duck-type). */
|
|
36
36
|
setExpanded(expanded) {
|
|
@@ -12,22 +12,39 @@ export type MemoryActivity = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
path?: string;
|
|
14
14
|
};
|
|
15
|
+
/** One node this group started or saw finish. `name` is the spawn's display
|
|
16
|
+
* name, or the node id when the finish has no matching spawn in this session. */
|
|
17
|
+
export type NodeActivity = {
|
|
18
|
+
action: 'spawned' | 'finished';
|
|
19
|
+
name: string;
|
|
20
|
+
id: string;
|
|
21
|
+
};
|
|
15
22
|
export type GroupActivity = {
|
|
16
23
|
files: readonly FileActivity[];
|
|
17
24
|
memories: readonly MemoryActivity[];
|
|
25
|
+
nodes: readonly NodeActivity[];
|
|
18
26
|
};
|
|
19
27
|
/** Per-group recorder. Calls retain only their current transcript references
|
|
20
28
|
* until they settle; the settled activity is plain immutable evidence. */
|
|
21
29
|
export declare class GroupActivityRecorder {
|
|
22
30
|
private readonly cwd;
|
|
31
|
+
private readonly roster;
|
|
23
32
|
private readonly calls;
|
|
24
33
|
private readonly files;
|
|
25
34
|
private readonly memories;
|
|
26
35
|
private readonly memorySet;
|
|
27
|
-
|
|
36
|
+
private readonly nodes;
|
|
37
|
+
private readonly nodeSet;
|
|
38
|
+
/** `roster` is the ChatView-wide node id → display name record: spawns fill
|
|
39
|
+
* it, and a later group's finish reads the name a sibling group recorded. */
|
|
40
|
+
constructor(cwd: string, roster?: Map<string, string>);
|
|
41
|
+
/** Record the nodes an inbox digest reports finished. The digest names senders
|
|
42
|
+
* by node id, so the spawn roster supplies the display name when it has one. */
|
|
43
|
+
noteInboxDigest(body: string): void;
|
|
28
44
|
noteCall(id: string, name: string, args: unknown): void;
|
|
29
45
|
settle(id: string, result: unknown, isError: boolean): void;
|
|
30
46
|
resolveWithoutResult(id: string): void;
|
|
31
47
|
activity(): GroupActivity;
|
|
48
|
+
private addNode;
|
|
32
49
|
private extract;
|
|
33
50
|
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// calls into the exact evidence already present in the session transcript.
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
|
+
import { inboxFinishedSenders } from '../../../shared/generated-context.js';
|
|
6
7
|
import { countDisplayDiffLines } from './edit-diff.js';
|
|
7
8
|
const EMPTY_ACTIVITY = Object.freeze({
|
|
8
9
|
files: Object.freeze([]),
|
|
9
10
|
memories: Object.freeze([]),
|
|
11
|
+
nodes: Object.freeze([]),
|
|
10
12
|
});
|
|
11
13
|
function object(value) {
|
|
12
14
|
return typeof value === 'object' && value !== null ? value : undefined;
|
|
@@ -77,7 +79,7 @@ function shellTokens(segment) {
|
|
|
77
79
|
}
|
|
78
80
|
/** Deliberately match an executable token, not a result-shaped string or a
|
|
79
81
|
* quoted command fragment. */
|
|
80
|
-
function
|
|
82
|
+
function invokesCrtrCommand(args, path) {
|
|
81
83
|
const command = object(args)?.command;
|
|
82
84
|
if (typeof command !== 'string')
|
|
83
85
|
return false;
|
|
@@ -86,7 +88,9 @@ function invokesMemoryCommand(args, verb) {
|
|
|
86
88
|
while (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(tokens[0] ?? ''))
|
|
87
89
|
tokens.shift();
|
|
88
90
|
const executable = tokens[0]?.split('/').at(-1);
|
|
89
|
-
|
|
91
|
+
if (executable !== 'crtr' && executable !== 'crouter')
|
|
92
|
+
return false;
|
|
93
|
+
return path.every((word, index) => tokens[index + 1] === word);
|
|
90
94
|
});
|
|
91
95
|
}
|
|
92
96
|
/** A resolved read/write begins with the identity fields emitted by its leaf.
|
|
@@ -105,6 +109,19 @@ function resolvedMemories(result, action) {
|
|
|
105
109
|
}
|
|
106
110
|
return memories;
|
|
107
111
|
}
|
|
112
|
+
/** The `node new` receipt line (commands/node/create.ts `render`), which names
|
|
113
|
+
* the node only once its broker is actually running. */
|
|
114
|
+
const SPAWNED_NODE_RE = /^Spawned "([^"\n]+)" \(([^)\s]+)\)/gm;
|
|
115
|
+
function spawnedNodes(result) {
|
|
116
|
+
const nodes = [];
|
|
117
|
+
for (const match of resultText(result).matchAll(SPAWNED_NODE_RE)) {
|
|
118
|
+
const name = match[1]?.trim();
|
|
119
|
+
const id = match[2]?.trim();
|
|
120
|
+
if (name && id)
|
|
121
|
+
nodes.push({ action: 'spawned', name, id });
|
|
122
|
+
}
|
|
123
|
+
return nodes;
|
|
124
|
+
}
|
|
108
125
|
/** Local mirror of pi's `resolveToCwd`: file tools expand `~` before resolving
|
|
109
126
|
* against the node cwd, and the recap must key the exact same file identically. */
|
|
110
127
|
function canonicalPath(rawPath, cwd) {
|
|
@@ -132,12 +149,25 @@ function fileChange(name, args, result) {
|
|
|
132
149
|
* until they settle; the settled activity is plain immutable evidence. */
|
|
133
150
|
export class GroupActivityRecorder {
|
|
134
151
|
cwd;
|
|
152
|
+
roster;
|
|
135
153
|
calls = new Map();
|
|
136
154
|
files = new Map();
|
|
137
155
|
memories = [];
|
|
138
156
|
memorySet = new Set();
|
|
139
|
-
|
|
157
|
+
nodes = [];
|
|
158
|
+
nodeSet = new Set();
|
|
159
|
+
/** `roster` is the ChatView-wide node id → display name record: spawns fill
|
|
160
|
+
* it, and a later group's finish reads the name a sibling group recorded. */
|
|
161
|
+
constructor(cwd, roster = new Map()) {
|
|
140
162
|
this.cwd = cwd;
|
|
163
|
+
this.roster = roster;
|
|
164
|
+
}
|
|
165
|
+
/** Record the nodes an inbox digest reports finished. The digest names senders
|
|
166
|
+
* by node id, so the spawn roster supplies the display name when it has one. */
|
|
167
|
+
noteInboxDigest(body) {
|
|
168
|
+
for (const id of inboxFinishedSenders(body)) {
|
|
169
|
+
this.addNode({ action: 'finished', name: this.roster.get(id) ?? id, id });
|
|
170
|
+
}
|
|
141
171
|
}
|
|
142
172
|
noteCall(id, name, args) {
|
|
143
173
|
const existing = this.calls.get(id);
|
|
@@ -163,16 +193,34 @@ export class GroupActivityRecorder {
|
|
|
163
193
|
entry.args = undefined;
|
|
164
194
|
}
|
|
165
195
|
activity() {
|
|
166
|
-
if (this.files.size === 0 && this.memories.length === 0)
|
|
196
|
+
if (this.files.size === 0 && this.memories.length === 0 && this.nodes.length === 0)
|
|
167
197
|
return EMPTY_ACTIVITY;
|
|
168
198
|
const files = [...this.files.values()].map((file) => Object.freeze({ ...file }));
|
|
169
199
|
const memories = this.memories.map((memory) => Object.freeze({ ...memory }));
|
|
170
|
-
|
|
200
|
+
const nodes = this.nodes.map((node) => Object.freeze({ ...node }));
|
|
201
|
+
return Object.freeze({
|
|
202
|
+
files: Object.freeze(files),
|
|
203
|
+
memories: Object.freeze(memories),
|
|
204
|
+
nodes: Object.freeze(nodes),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
addNode(node) {
|
|
208
|
+
const key = `${node.action}:${node.id}`;
|
|
209
|
+
if (this.nodeSet.has(key))
|
|
210
|
+
return;
|
|
211
|
+
this.nodeSet.add(key);
|
|
212
|
+
this.nodes.push(node);
|
|
213
|
+
if (node.action === 'spawned')
|
|
214
|
+
this.roster.set(node.id, node.name);
|
|
171
215
|
}
|
|
172
216
|
extract(entry, result) {
|
|
173
217
|
if (entry.name === 'bash') {
|
|
218
|
+
if (invokesCrtrCommand(entry.args, ['node', 'new'])) {
|
|
219
|
+
for (const node of spawnedNodes(result))
|
|
220
|
+
this.addNode(node);
|
|
221
|
+
}
|
|
174
222
|
for (const action of ['read', 'write']) {
|
|
175
|
-
if (!
|
|
223
|
+
if (!invokesCrtrCommand(entry.args, ['memory', action]))
|
|
176
224
|
continue;
|
|
177
225
|
for (const memory of resolvedMemories(result, action)) {
|
|
178
226
|
const key = `${action}:${memory.path ?? memory.name}`;
|
|
@@ -31,6 +31,9 @@ function countMemories(memories, action) {
|
|
|
31
31
|
function distinctFileCount(files) {
|
|
32
32
|
return new Set(files.map((file) => file.key)).size;
|
|
33
33
|
}
|
|
34
|
+
function countNodes(nodes, action) {
|
|
35
|
+
return nodes.filter((node) => node.action === action).length;
|
|
36
|
+
}
|
|
34
37
|
class RecapDetailLine {
|
|
35
38
|
prefix;
|
|
36
39
|
path;
|
|
@@ -56,6 +59,9 @@ export class GroupRecapComponent extends Container {
|
|
|
56
59
|
return;
|
|
57
60
|
this.addChild(new Spacer(1));
|
|
58
61
|
if (detailed) {
|
|
62
|
+
for (const node of activity.nodes) {
|
|
63
|
+
this.addChild(new RecapDetailLine(`${palette.accent(ICON.graph)} `, palette.muted(node.name), palette.muted(node.action === 'spawned' ? ' spawned' : ' finished')));
|
|
64
|
+
}
|
|
59
65
|
for (const memory of activity.memories) {
|
|
60
66
|
const icon = memory.action === 'write' ? TOOL_ICON.write : MEMORY_ICON;
|
|
61
67
|
this.addChild(new RecapDetailLine(`${palette.info(icon)} `, memoryName(memory, palette), ''));
|
|
@@ -69,8 +75,13 @@ export class GroupRecapComponent extends Container {
|
|
|
69
75
|
}
|
|
70
76
|
else {
|
|
71
77
|
const stats = [toolCallCount];
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
const nodesSpawned = countNodes(activity.nodes, 'spawned');
|
|
79
|
+
if (nodesSpawned >= 1) {
|
|
80
|
+
stats.push(`${palette.accent(ICON.graph)} ${palette.muted(count(nodesSpawned, 'node spawned', 'nodes spawned'))}`);
|
|
81
|
+
}
|
|
82
|
+
const nodesFinished = countNodes(activity.nodes, 'finished');
|
|
83
|
+
if (nodesFinished >= 1) {
|
|
84
|
+
stats.push(`${palette.accent(ICON.graph)} ${palette.muted(count(nodesFinished, 'node finished', 'nodes finished'))}`);
|
|
74
85
|
}
|
|
75
86
|
const changedFiles = distinctFileCount(activity.files);
|
|
76
87
|
if (changedFiles >= 1) {
|