pi-jar 0.1.2 → 0.1.3
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/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +141 -36
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/composer.ts +30 -9
- package/src/delegate.ts +150 -15
- package/src/footer.ts +105 -18
- package/src/icons.ts +57 -0
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +4 -2
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +106 -41
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +6 -83
- package/src/status.ts +2 -0
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { DelegateRegistry, DelegateRun, SubagentRecord } from "./delegate.ts";
|
|
4
|
+
import { safeLine } from "./diff-view.ts";
|
|
5
|
+
import { icon, withIcon } from "./icons.ts";
|
|
6
|
+
import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
|
|
7
|
+
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
8
|
+
import { ACTIVE_STATES, cleanText } from "./status.ts";
|
|
9
|
+
|
|
10
|
+
/** What to select when the view opens. */
|
|
11
|
+
export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
|
|
12
|
+
/** A teammate another extension publishes through the role-status contract; listed read-only. */
|
|
13
|
+
export interface ActivityRole { id: string; name: string; state: string; task?: string }
|
|
14
|
+
export interface ActivitySources {
|
|
15
|
+
subagents: DelegateRegistry;
|
|
16
|
+
shells?: ShellManager;
|
|
17
|
+
roles?: () => ActivityRole[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Doubles as the icon key for the row glyph. */
|
|
21
|
+
type Status = "running" | "pending" | "success" | "error" | "stopped";
|
|
22
|
+
type Fg = (color: string, text: string) => string;
|
|
23
|
+
type Entry =
|
|
24
|
+
| { kind: "subagent"; id: string; status: Status; label: string; record: SubagentRecord }
|
|
25
|
+
| { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
|
|
26
|
+
| { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
|
|
27
|
+
type Item = Entry | { kind: "header"; label: string };
|
|
28
|
+
|
|
29
|
+
const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
|
|
30
|
+
const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
|
|
31
|
+
const SHELL_TAIL = 400;
|
|
32
|
+
const MAX_TASK_CHARS = 8000;
|
|
33
|
+
|
|
34
|
+
/** Left-pane rows: a header per non-empty section, then its entries. */
|
|
35
|
+
function collect(sources: ActivitySources): Item[] {
|
|
36
|
+
const items: Item[] = [];
|
|
37
|
+
const records = sources.subagents.records();
|
|
38
|
+
if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
|
|
39
|
+
for (const record of records) {
|
|
40
|
+
const { run } = record;
|
|
41
|
+
const status: Status = run.state === "working" ? "running" : run.state === "queued" ? "pending" : run.state === "done" ? "success" : run.error === "stopped" ? "stopped" : "error";
|
|
42
|
+
items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
|
|
43
|
+
}
|
|
44
|
+
const jobs = sources.shells?.summaries() ?? [];
|
|
45
|
+
if (jobs.length) items.push({ kind: "header", label: withIcon("shell", "SHELLS") });
|
|
46
|
+
for (const job of jobs) {
|
|
47
|
+
const status: Status = job.status === "running" ? "running" : job.status === "exited" && job.exitCode === 0 ? "success" : job.status === "killed" ? "stopped" : "error";
|
|
48
|
+
items.push({ kind: "shell", id: job.id, status, label: `${job.id} ${job.name}`, job });
|
|
49
|
+
}
|
|
50
|
+
let roles: ActivityRole[] = [];
|
|
51
|
+
try { roles = sources.roles?.() ?? []; } catch { /* teammates are decoration */ }
|
|
52
|
+
// jar_delegate publishes each run as a role status under its record key: list those once, as subagents.
|
|
53
|
+
roles = roles.filter((role) => !sources.subagents.get(role.id));
|
|
54
|
+
if (roles.length) items.push({ kind: "header", label: withIcon("roles", "ROLES") });
|
|
55
|
+
for (const role of roles) {
|
|
56
|
+
const status: Status = (ACTIVE_STATES as ReadonlySet<string>).has(role.state) ? "running" : role.state === "done" ? "success" : role.state === "failed" ? "error" : "pending";
|
|
57
|
+
items.push({ kind: "role", id: role.id, status, label: cleanText(role.name, 32) || role.id, role });
|
|
58
|
+
}
|
|
59
|
+
return items;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
|
|
63
|
+
interface Details { head: string[]; body: string[] }
|
|
64
|
+
|
|
65
|
+
function subagentDetails(run: DelegateRun, status: Status, width: number, fg: Fg): Details {
|
|
66
|
+
const facts = [
|
|
67
|
+
run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
|
|
68
|
+
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`
|
|
69
|
+
].filter(Boolean);
|
|
70
|
+
const head = [
|
|
71
|
+
fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
|
|
72
|
+
fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
|
|
73
|
+
];
|
|
74
|
+
if (run.activity) head.push(fg("muted", run.activity));
|
|
75
|
+
if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
|
|
76
|
+
const body = ["", fg("dim", "TASK")];
|
|
77
|
+
for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
78
|
+
body.push("", fg("dim", "── transcript ──"));
|
|
79
|
+
if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
|
|
80
|
+
for (const entry of run.log) {
|
|
81
|
+
const tone = entry.startsWith("▸ ") ? "accent" : "muted";
|
|
82
|
+
for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
|
|
83
|
+
}
|
|
84
|
+
if (run.output) {
|
|
85
|
+
body.push("", fg("dim", "── report ──"));
|
|
86
|
+
for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
87
|
+
}
|
|
88
|
+
return { head, body };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
|
|
92
|
+
const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
|
|
93
|
+
const body = ["", ...output.map((line) => fg("muted", truncateToWidth(safeLine(line), width)))];
|
|
94
|
+
if (!output.length) body.push(fg("dim", "(no output yet)"));
|
|
95
|
+
return { head, body };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
|
|
99
|
+
const head = [fg("accent", cleanText(role.name, 60) || role.id), fg(COLOR[status], withIcon(status, cleanText(role.state, 24))) + fg("dim", " · read-only")];
|
|
100
|
+
const body = role.task ? ["", fg("dim", "TASK"), ...wrapTextWithAnsi(cleanText(role.task, 400), width).map((line) => fg("text", line))] : [];
|
|
101
|
+
return { head, body };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
|
|
106
|
+
* details on the right, following new output until scrolled up. x stops a subagent or kills a shell.
|
|
107
|
+
*/
|
|
108
|
+
export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
|
|
109
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
110
|
+
const opening = collect(sources);
|
|
111
|
+
if (!opening.some((item) => item.kind !== "header")) {
|
|
112
|
+
ctx.ui.notify("pi-jar: nothing running (subagents come from jar_delegate, shells from jar_shell)", "info");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
116
|
+
let items = opening;
|
|
117
|
+
let at = initial ? items.findIndex((item) => item.kind !== "header" && item.kind === initial.kind && item.id === initial.id) : -1;
|
|
118
|
+
if (at < 0) at = items.findIndex((item) => item.kind !== "header" && item.status === "running");
|
|
119
|
+
if (at < 0) at = items.findIndex((item) => item.kind !== "header");
|
|
120
|
+
let follow = true;
|
|
121
|
+
let scroll = 0;
|
|
122
|
+
let maxScroll = 0;
|
|
123
|
+
let bodyRows = 0;
|
|
124
|
+
let listTop = 0;
|
|
125
|
+
let width = 80;
|
|
126
|
+
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
127
|
+
let timer: NodeJS.Timeout | undefined;
|
|
128
|
+
let closed = false;
|
|
129
|
+
const fg: Fg = (color, text) => theme.fg(color as never, text);
|
|
130
|
+
const shells = sources.shells;
|
|
131
|
+
const previous = shells?.onChange;
|
|
132
|
+
const unsubscribe = sources.subagents.subscribe(() => tui.requestRender());
|
|
133
|
+
if (shells) shells.onChange = () => { previous?.(); tui.requestRender(); };
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
if (closed) return;
|
|
136
|
+
closed = true;
|
|
137
|
+
clearInterval(timer);
|
|
138
|
+
timer = undefined;
|
|
139
|
+
unsubscribe();
|
|
140
|
+
if (shells) shells.onChange = previous;
|
|
141
|
+
};
|
|
142
|
+
const close = () => { cleanup(); done(); };
|
|
143
|
+
// Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
|
|
144
|
+
const refresh = () => {
|
|
145
|
+
const current = items[at];
|
|
146
|
+
items = collect(sources);
|
|
147
|
+
const same = current && current.kind !== "header" ? items.findIndex((item) => item.kind !== "header" && item.kind === current.kind && item.id === current.id) : -1;
|
|
148
|
+
if (same >= 0) { at = same; return; }
|
|
149
|
+
const from = Math.max(0, Math.min(at, items.length - 1));
|
|
150
|
+
at = -1;
|
|
151
|
+
for (let index = from; index < items.length && at < 0; index++) if (items[index]!.kind !== "header") at = index;
|
|
152
|
+
for (let index = from - 1; index >= 0 && at < 0; index--) if (items[index]!.kind !== "header") at = index;
|
|
153
|
+
};
|
|
154
|
+
const move = (delta: number) => {
|
|
155
|
+
for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
|
|
156
|
+
if (items[index]!.kind === "header") continue;
|
|
157
|
+
at = index; follow = true; scroll = 0;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const scrollBy = (delta: number) => {
|
|
162
|
+
if (!maxScroll) return;
|
|
163
|
+
scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
|
|
164
|
+
// Scrolling up pauses follow; scrolling back down to the end resumes it.
|
|
165
|
+
follow = delta > 0 && scroll >= maxScroll;
|
|
166
|
+
};
|
|
167
|
+
const stopSelected = () => {
|
|
168
|
+
const item = items[at];
|
|
169
|
+
if (!item || item.kind === "header" || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
|
|
170
|
+
try {
|
|
171
|
+
if (item.kind === "subagent") sources.subagents.stop(item.id);
|
|
172
|
+
else shells?.kill(item.id);
|
|
173
|
+
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
174
|
+
};
|
|
175
|
+
const component = {
|
|
176
|
+
invalidate() {},
|
|
177
|
+
dispose: cleanup,
|
|
178
|
+
handleInput(data: string) {
|
|
179
|
+
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
180
|
+
if (matchesKey(data, Key.up) || data === "k") move(-1);
|
|
181
|
+
else if (matchesKey(data, Key.down) || data === "j") move(1);
|
|
182
|
+
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
183
|
+
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
184
|
+
else if (data === "f" || data === "G") follow = true;
|
|
185
|
+
else if (matchesKey(data, Key.enter)) follow = !follow;
|
|
186
|
+
else if (data === "x") stopSelected();
|
|
187
|
+
else return;
|
|
188
|
+
tui.requestRender();
|
|
189
|
+
},
|
|
190
|
+
handleMouse(event: TuiMouseEvent) {
|
|
191
|
+
if (event.type === "wheel" && event.wheelDelta) { scrollBy(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
|
|
192
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
193
|
+
if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
|
|
194
|
+
const row = event.y - layout.top;
|
|
195
|
+
if (layout.leftWidth && row >= 0 && row < layout.rows && event.x < layout.leftWidth + 3) {
|
|
196
|
+
const index = listTop + row;
|
|
197
|
+
if (!items[index] || items[index].kind === "header") return;
|
|
198
|
+
if (index !== at) { at = index; follow = true; scroll = 0; }
|
|
199
|
+
tui.requestRender();
|
|
200
|
+
return { handled: true, focus: true };
|
|
201
|
+
}
|
|
202
|
+
const action = ACTIONS[event.y - layout.footerTop];
|
|
203
|
+
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
204
|
+
},
|
|
205
|
+
render(available: number): string[] {
|
|
206
|
+
width = Math.max(24, available);
|
|
207
|
+
const rows = contentRows(6 + ACTIONS.length, 6);
|
|
208
|
+
refresh();
|
|
209
|
+
const picked = items[at];
|
|
210
|
+
const item = picked && picked.kind !== "header" ? picked : undefined;
|
|
211
|
+
const listWidth = sidebarWidth(width, 20, 34);
|
|
212
|
+
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
213
|
+
const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
|
|
214
|
+
const details = !item ? { head: [fg("dim", "Nothing to show.")], body: [] }
|
|
215
|
+
: item.kind === "subagent" ? subagentDetails(item.record.run, item.status, bodyWidth, fg)
|
|
216
|
+
: item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
|
|
217
|
+
: roleDetails(item.role, item.status, bodyWidth, fg);
|
|
218
|
+
// Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
|
|
219
|
+
const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
|
|
220
|
+
...details.head].slice(0, rows - 1);
|
|
221
|
+
bodyRows = rows - pinned.length;
|
|
222
|
+
maxScroll = Math.max(0, details.body.length - bodyRows);
|
|
223
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
224
|
+
if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
|
|
225
|
+
if (at >= listTop + rows) listTop = at - rows + 1;
|
|
226
|
+
listTop = Math.max(0, Math.min(listTop, items.length - rows));
|
|
227
|
+
const list = listWidth ? items.slice(listTop, listTop + rows).map((entry, offset) => {
|
|
228
|
+
if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
|
|
229
|
+
const active = listTop + offset === at;
|
|
230
|
+
const glyph = icon(entry.status);
|
|
231
|
+
return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
232
|
+
+ fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
|
|
233
|
+
}) : [];
|
|
234
|
+
const running = entries.filter((entry) => entry.status === "running").length;
|
|
235
|
+
const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
|
|
236
|
+
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
237
|
+
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
238
|
+
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
239
|
+
const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
|
|
240
|
+
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
|
|
241
|
+
[...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
|
|
242
|
+
layout = split.layout;
|
|
243
|
+
// Subagent events and shell exits repaint through the listeners; elapsed times and shell output
|
|
244
|
+
// advance on a 1s tick that exists only while something runs.
|
|
245
|
+
if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
|
|
246
|
+
else if (!running && timer) { clearInterval(timer); timer = undefined; }
|
|
247
|
+
return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
return component;
|
|
251
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
|
|
252
|
+
}
|
package/src/composer.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type Mascot
|
|
|
4
4
|
import type { SuggestionState } from "./suggest.ts";
|
|
5
5
|
import type { WorkingPhase } from "./working.ts";
|
|
6
6
|
import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
|
|
7
|
+
// Aliased: roundedInput has a parameter named `icon`.
|
|
8
|
+
import { icon as glyph, type IconKey } from "./icons.ts";
|
|
9
|
+
|
|
10
|
+
/** `⏎ send`; ascii spells the key out (`enter send`). */
|
|
11
|
+
const keyHint = (key: IconKey, action: string) => glyph(key) + " " + action;
|
|
7
12
|
|
|
8
13
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
9
14
|
type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
@@ -12,6 +17,8 @@ const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
|
12
17
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
13
18
|
};
|
|
14
19
|
export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
|
|
20
|
+
/** How long composer image chips trust a previous filesystem check for the same draft. */
|
|
21
|
+
const ATTACHMENT_RECHECK_MS = 1000;
|
|
15
22
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
16
23
|
export function composerIcon(phase: WorkingPhase, frame = 0): string {
|
|
17
24
|
const moods = PHASE_MOODS[phase];
|
|
@@ -95,7 +102,9 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
95
102
|
if (index === 0 && options.ghost && row.includes(CURSOR)) {
|
|
96
103
|
const base = row.replace(/ +$/, "");
|
|
97
104
|
const room = inner - visibleWidth(base);
|
|
98
|
-
|
|
105
|
+
// ascii's tab "icon" is already the word.
|
|
106
|
+
const tab = glyph("tab") === "tab" ? "tab" : keyHint("tab", "tab");
|
|
107
|
+
if (room > 2) row = base + dim(truncateToWidth(options.ghost + " " + tab, room, "…"));
|
|
99
108
|
}
|
|
100
109
|
const fitted = truncateToWidth(row, inner);
|
|
101
110
|
return border("│") + fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted))) + border("│");
|
|
@@ -159,7 +168,9 @@ class RoundedEditor extends CustomEditor {
|
|
|
159
168
|
|
|
160
169
|
override render(width: number): string[] {
|
|
161
170
|
const inner = this.renderTall(width < 8 ? width : width - 2);
|
|
162
|
-
const
|
|
171
|
+
const text = this.getText();
|
|
172
|
+
const empty = text === "";
|
|
173
|
+
const below = this.decor.attachments(text);
|
|
163
174
|
const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
|
|
164
175
|
this.ghostShown = !!ghost;
|
|
165
176
|
const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
@@ -167,7 +178,7 @@ class RoundedEditor extends CustomEditor {
|
|
|
167
178
|
...(ghost ? { ghost } : {}), ...(this.decor.hint() ? { hint: this.decor.hint()! } : {}),
|
|
168
179
|
...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}),
|
|
169
180
|
...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
|
|
170
|
-
...(
|
|
181
|
+
...(below ? { below } : {})
|
|
171
182
|
});
|
|
172
183
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
173
184
|
this.tipRows = tip ? 1 : 0;
|
|
@@ -230,10 +241,12 @@ class ThemedEditor implements EditorComponent {
|
|
|
230
241
|
}
|
|
231
242
|
invalidate() { this.base.invalidate(); }
|
|
232
243
|
render(width: number): string[] {
|
|
233
|
-
const
|
|
244
|
+
const text = this.base.getText();
|
|
245
|
+
const ghost = text === "" ? this.decor.ghost() : undefined;
|
|
246
|
+
const below = this.decor.attachments(text);
|
|
234
247
|
const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, this.decor.icon(), this.decor.session(), {
|
|
235
248
|
...(ghost ? { ghost } : {}), ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}), dim: this.decor.dim,
|
|
236
|
-
...(
|
|
249
|
+
...(below ? { below } : {})
|
|
237
250
|
});
|
|
238
251
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
239
252
|
this.tipRows = tip ? 1 : 0;
|
|
@@ -269,6 +282,8 @@ export class ComposerStyle {
|
|
|
269
282
|
private suggestions?: SuggestionState;
|
|
270
283
|
private unsubscribe?: () => void;
|
|
271
284
|
private colors?: { fg(color: string, text: string): string };
|
|
285
|
+
/** Chips for the last draft; image paths hit the filesystem, so reuse them briefly across repaints. */
|
|
286
|
+
private chips?: { text: string; cwd: string; at: number; chips: string[] };
|
|
272
287
|
|
|
273
288
|
private readonly decor: ComposerDecor = {
|
|
274
289
|
icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
|
|
@@ -286,7 +301,9 @@ export class ComposerStyle {
|
|
|
286
301
|
},
|
|
287
302
|
session: () => this.session,
|
|
288
303
|
ghost: () => this.suggestions?.text,
|
|
289
|
-
hint: () => this.suggestions?.text
|
|
304
|
+
hint: () => this.suggestions?.text
|
|
305
|
+
? `${keyHint("tab", "accept")} · ${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")}`
|
|
306
|
+
: `${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")} · /plan · /goal`,
|
|
290
307
|
accept: () => {
|
|
291
308
|
const text = this.suggestions?.text;
|
|
292
309
|
if (text) this.suggestions!.clear();
|
|
@@ -297,9 +314,13 @@ export class ComposerStyle {
|
|
|
297
314
|
dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
|
|
298
315
|
attachments: (text) => {
|
|
299
316
|
if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (this.chips?.text !== text || this.chips.cwd !== this.cwd || now - this.chips.at >= ATTACHMENT_RECHECK_MS) {
|
|
319
|
+
const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
|
|
320
|
+
this.chips = { text, cwd: this.cwd, at: now, chips };
|
|
321
|
+
}
|
|
322
|
+
if (!this.chips.chips.length) return undefined;
|
|
323
|
+
return this.chips.chips.map((chip) => this.colors?.fg("accent", chip) ?? chip).join(this.colors?.fg("dim", " ") ?? " ");
|
|
303
324
|
}
|
|
304
325
|
};
|
|
305
326
|
|
package/src/delegate.ts
CHANGED
|
@@ -18,6 +18,14 @@ const TIMEOUT_MS = 20 * 60_000;
|
|
|
18
18
|
const STATUS_REFRESH_MS = 20_000;
|
|
19
19
|
const LIVE_UPDATE_MS = 1000;
|
|
20
20
|
const MAX_EVENT_TEXT = 16_000;
|
|
21
|
+
const MAX_LOG_LINES = 200;
|
|
22
|
+
const MAX_LOG_CHARS = 240;
|
|
23
|
+
/** Finished subagents the registry keeps for the activity view after their tool call returns. */
|
|
24
|
+
const MAX_FINISHED = 8;
|
|
25
|
+
/** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
|
|
26
|
+
const STOP_REASON = "stopped";
|
|
27
|
+
/** Tool arguments that best describe a call in the transcript, most telling first. */
|
|
28
|
+
const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as const;
|
|
21
29
|
|
|
22
30
|
export type DelegateState = "queued" | "working" | "done" | "failed";
|
|
23
31
|
export interface DelegateRun {
|
|
@@ -33,6 +41,10 @@ export interface DelegateRun {
|
|
|
33
41
|
cost: number;
|
|
34
42
|
output: string;
|
|
35
43
|
error?: string;
|
|
44
|
+
startedAt?: number;
|
|
45
|
+
endedAt?: number;
|
|
46
|
+
/** Live transcript (tool calls, assistant text), last MAX_LOG_LINES; never copied into tool details. */
|
|
47
|
+
log: string[];
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
@@ -73,6 +85,30 @@ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string
|
|
|
73
85
|
return out;
|
|
74
86
|
};
|
|
75
87
|
|
|
88
|
+
/** Append transcript lines, cleaned and clipped, keeping only the newest MAX_LOG_LINES. */
|
|
89
|
+
const appendLog = (run: DelegateRun, lines: readonly string[]) => {
|
|
90
|
+
for (const raw of lines) { const line = cleanText(raw, MAX_LOG_CHARS); if (line) run.log.push(line); }
|
|
91
|
+
if (run.log.length > MAX_LOG_LINES) run.log.splice(0, run.log.length - MAX_LOG_LINES);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The argument that says what a tool call does: the bash command, the file path, the search pattern… */
|
|
95
|
+
const toolHint = (args: unknown): string => {
|
|
96
|
+
if (!args || typeof args !== "object") return "";
|
|
97
|
+
for (const key of HINT_KEYS) {
|
|
98
|
+
const value = (args as Record<string, unknown>)[key];
|
|
99
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** The slice of Pi's JSON event stream a run reads; leaves are unknown because it is another process's output. */
|
|
105
|
+
interface ChildEvent {
|
|
106
|
+
type?: unknown;
|
|
107
|
+
toolName?: unknown;
|
|
108
|
+
args?: unknown;
|
|
109
|
+
message?: { role?: unknown; content?: unknown; usage?: { cost?: { total?: unknown } }; stopReason?: unknown; errorMessage?: unknown };
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
/** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
|
|
77
113
|
export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
|
|
78
114
|
return new Promise((resolve) => {
|
|
@@ -81,9 +117,10 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
|
|
|
81
117
|
try {
|
|
82
118
|
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, [CHILD_ENV]: "1" } });
|
|
83
119
|
} catch (error) {
|
|
84
|
-
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); update(); resolve(); return;
|
|
120
|
+
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update(); resolve(); return;
|
|
85
121
|
}
|
|
86
122
|
run.state = "working";
|
|
123
|
+
run.startedAt = Date.now();
|
|
87
124
|
run.activity = "starting";
|
|
88
125
|
update();
|
|
89
126
|
let buffer = "";
|
|
@@ -97,25 +134,36 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
|
|
|
97
134
|
run.state = state;
|
|
98
135
|
if (error) run.error = error;
|
|
99
136
|
run.activity = undefined;
|
|
137
|
+
run.endedAt = Date.now();
|
|
100
138
|
update();
|
|
101
139
|
resolve();
|
|
102
140
|
};
|
|
103
141
|
const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
|
|
104
|
-
|
|
142
|
+
// Stopping one run from the activity view reads "stopped"; aborting the whole tool call reads "aborted".
|
|
143
|
+
const abort = () => { stop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
105
144
|
const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
|
|
106
145
|
timer.unref?.();
|
|
107
146
|
if (signal?.aborted) { abort(); return; }
|
|
108
147
|
signal?.addEventListener("abort", abort, { once: true });
|
|
109
148
|
const line = (raw: string) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
149
|
+
// Output that arrives after an abort or timeout must not revive a finished run's activity.
|
|
150
|
+
if (finished || !raw.trim()) return;
|
|
151
|
+
let parsed: unknown;
|
|
152
|
+
try { parsed = JSON.parse(raw); } catch { return; }
|
|
153
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
154
|
+
// Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
|
|
155
|
+
const event = parsed as ChildEvent;
|
|
156
|
+
if (event.type === "tool_execution_start") {
|
|
157
|
+
run.tools++;
|
|
158
|
+
run.activity = cleanText(String(event.toolName ?? "tool"), 24);
|
|
159
|
+
appendLog(run, [`▸ ${run.activity} ${toolHint(event.args)}`]);
|
|
160
|
+
update();
|
|
161
|
+
}
|
|
114
162
|
else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
115
163
|
run.turns++;
|
|
116
164
|
run.cost += Number(event.message.usage?.cost?.total) || 0;
|
|
117
165
|
const text = textOf(event.message).trim();
|
|
118
|
-
if (text) run.output = text.slice(0, MAX_OUTPUT_CHARS);
|
|
166
|
+
if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); appendLog(run, text.split("\n").slice(-MAX_LOG_LINES)); }
|
|
119
167
|
if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
|
|
120
168
|
run.activity = "thinking";
|
|
121
169
|
update();
|
|
@@ -137,6 +185,81 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
|
|
|
137
185
|
});
|
|
138
186
|
}
|
|
139
187
|
|
|
188
|
+
const isActive = (state: DelegateState) => state === "queued" || state === "working";
|
|
189
|
+
|
|
190
|
+
export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void }
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Live subagents for the activity view. Tool details are saved in the session, so they stay small;
|
|
194
|
+
* the transcript and the per-run stop handle live only here, for this process.
|
|
195
|
+
*/
|
|
196
|
+
export class DelegateRegistry {
|
|
197
|
+
private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
|
|
198
|
+
private listeners = new Set<() => void>();
|
|
199
|
+
private sequence = 0;
|
|
200
|
+
|
|
201
|
+
subscribe(listener: () => void): () => void {
|
|
202
|
+
this.listeners.add(listener);
|
|
203
|
+
return () => { this.listeners.delete(listener); };
|
|
204
|
+
}
|
|
205
|
+
/** Queued/working first (oldest first), then finished ones newest first. */
|
|
206
|
+
records(): SubagentRecord[] {
|
|
207
|
+
this.sweep();
|
|
208
|
+
const live: SubagentRecord[] = [];
|
|
209
|
+
const finished: Array<{ record: SubagentRecord; finished: number }> = [];
|
|
210
|
+
for (const { record, finished: at } of this.entries.values()) {
|
|
211
|
+
if (at === undefined) live.push(record); else finished.push({ record, finished: at });
|
|
212
|
+
}
|
|
213
|
+
finished.sort((a, b) => b.finished - a.finished);
|
|
214
|
+
return [...live, ...finished.map((entry) => entry.record)];
|
|
215
|
+
}
|
|
216
|
+
get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
|
|
217
|
+
/** Abort one queued/working run, leaving the rest of its batch alone; false when unknown or already finished. */
|
|
218
|
+
stop(key: string): boolean {
|
|
219
|
+
const record = this.entries.get(key)?.record;
|
|
220
|
+
if (!record || !isActive(record.run.state)) return false;
|
|
221
|
+
record.stop();
|
|
222
|
+
this.notify();
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
running(): number {
|
|
226
|
+
let count = 0;
|
|
227
|
+
for (const { record } of this.entries.values()) if (isActive(record.run.state)) count++;
|
|
228
|
+
return count;
|
|
229
|
+
}
|
|
230
|
+
/** Stop outstanding runs on session changes/shutdown and forget their transcript. */
|
|
231
|
+
clear(): void {
|
|
232
|
+
const stopping = [...this.entries.values()].filter(({ record }) => isActive(record.run.state)).map(({ record }) => record.stop);
|
|
233
|
+
this.entries.clear();
|
|
234
|
+
for (const stop of stopping) stop();
|
|
235
|
+
this.notify();
|
|
236
|
+
}
|
|
237
|
+
/** jar_delegate's side: list new runs. */
|
|
238
|
+
add(...records: SubagentRecord[]): void {
|
|
239
|
+
for (const record of records) this.entries.set(record.key, { record });
|
|
240
|
+
this.notify();
|
|
241
|
+
}
|
|
242
|
+
/** jar_delegate's side: a run changed. */
|
|
243
|
+
notify(): void {
|
|
244
|
+
this.sweep();
|
|
245
|
+
for (const listener of this.listeners) {
|
|
246
|
+
try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
|
|
250
|
+
private sweep(): void {
|
|
251
|
+
let finished = 0;
|
|
252
|
+
for (const entry of this.entries.values()) {
|
|
253
|
+
if (isActive(entry.record.run.state)) continue;
|
|
254
|
+
entry.finished ??= ++this.sequence;
|
|
255
|
+
finished++;
|
|
256
|
+
}
|
|
257
|
+
if (finished <= MAX_FINISHED) return;
|
|
258
|
+
const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined).sort((a, b) => a[1].finished! - b[1].finished!);
|
|
259
|
+
for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
140
263
|
const Parameters = Type.Object({
|
|
141
264
|
tasks: Type.Array(Type.Object({
|
|
142
265
|
task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
|
|
@@ -149,7 +272,8 @@ const Parameters = Type.Object({
|
|
|
149
272
|
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
|
|
150
273
|
const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
|
|
151
274
|
|
|
152
|
-
|
|
275
|
+
/** jar_delegate. Every run is listed live in `registry` (with its transcript and a stop handle) for the activity view. */
|
|
276
|
+
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, spawnProcess?: Spawn): void {
|
|
153
277
|
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
154
278
|
let batch = 0;
|
|
155
279
|
pi.registerTool({
|
|
@@ -170,21 +294,29 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
|
|
|
170
294
|
const resolved = roles.resolve(role) ?? roles.resolve("default");
|
|
171
295
|
const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
172
296
|
return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
|
|
173
|
-
state: "queued", tools: 0, turns: 0, cost: 0, output: "" };
|
|
297
|
+
state: "queued", tools: 0, turns: 0, cost: 0, output: "", log: [] };
|
|
174
298
|
});
|
|
299
|
+
// One controller per run: aborting the tool call stops them all, the activity view stops one.
|
|
300
|
+
const controllers = runs.map(() => new AbortController());
|
|
301
|
+
const abortAll = () => { for (const controller of controllers) controller.abort(); };
|
|
302
|
+
if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
|
|
303
|
+
// The key doubles as the role-status id, so views can tell our own teammates from other extensions'.
|
|
304
|
+
const records: SubagentRecord[] = runs.map((run, index) => ({ key: `delegate-${id}-${run.index}`, batch: id, run, stop: () => controllers[index]!.abort(STOP_REASON) }));
|
|
305
|
+
registry.add(...records);
|
|
175
306
|
const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
|
|
176
307
|
// Live teammates: the welcome TEAM row and the footer read this public status contract.
|
|
177
308
|
const publish = () => {
|
|
178
309
|
if (!ctx.hasUI) return;
|
|
179
|
-
for (const run of
|
|
310
|
+
for (const { key, run } of records) {
|
|
180
311
|
try {
|
|
181
|
-
ctx.ui.setStatus(`${ROLE_PREFIX}
|
|
312
|
+
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
|
|
182
313
|
name: run.name, label: run.name.slice(0, 12), state: run.state === "queued" ? "waiting" : "working", task: cleanText(run.task, 60), expiresAt: Date.now() + 25_000
|
|
183
314
|
}));
|
|
184
315
|
} catch { /* status is decoration */ }
|
|
185
316
|
}
|
|
186
317
|
};
|
|
187
|
-
|
|
318
|
+
// Details are persisted with the session: never the transcript, which the registry holds instead.
|
|
319
|
+
const details = (includeOutput = true) => ({ runs: runs.map(({ log: _log, ...run }) => includeOutput ? run : { ...run, output: "" }), write });
|
|
188
320
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
189
321
|
let lastUpdateAt = 0;
|
|
190
322
|
const emitUpdate = () => {
|
|
@@ -194,6 +326,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
|
|
|
194
326
|
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
|
|
195
327
|
};
|
|
196
328
|
const update = () => {
|
|
329
|
+
registry.notify();
|
|
197
330
|
if (!onUpdate) { publish(); return; }
|
|
198
331
|
const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
|
|
199
332
|
if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
|
|
@@ -209,10 +342,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
|
|
|
209
342
|
const refresh = setInterval(publish, STATUS_REFRESH_MS);
|
|
210
343
|
refresh.unref?.();
|
|
211
344
|
try {
|
|
212
|
-
await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
|
|
345
|
+
await Promise.all(runs.map((run, index) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, controllers[index]!.signal, update, spawnProcess)));
|
|
213
346
|
} finally {
|
|
214
347
|
clearInterval(refresh);
|
|
215
|
-
|
|
348
|
+
signal?.removeEventListener("abort", abortAll);
|
|
349
|
+
for (const run of runs) if (run.state !== "done") { run.state = "failed"; run.endedAt ??= Date.now(); }
|
|
350
|
+
registry.notify();
|
|
216
351
|
flushUpdate();
|
|
217
352
|
}
|
|
218
353
|
const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
|
|
@@ -224,7 +359,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
|
|
|
224
359
|
+ theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
|
|
225
360
|
},
|
|
226
361
|
renderResult(result, { expanded }, theme) {
|
|
227
|
-
const runs = (result.details as { runs?: DelegateRun
|
|
362
|
+
const runs = (result.details as { runs?: Array<Omit<DelegateRun, "log">> } | undefined)?.runs ?? [];
|
|
228
363
|
const rows = runs.map((run) => {
|
|
229
364
|
const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
|
|
230
365
|
let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
|