@bermudi/pi-delegate 0.1.0

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.
@@ -0,0 +1,380 @@
1
+ import { Markdown } from "@earendil-works/pi-tui";
2
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ fmtDuration,
5
+ fmtTokens,
6
+ getActivityAge,
7
+ indent,
8
+ tree,
9
+ trunc,
10
+ truncLine,
11
+ spinnerFrame,
12
+ formatToolCallShort,
13
+ previewOutputLine,
14
+ waitingLabel,
15
+ } from "./format.ts";
16
+ import { stripAnsi, resolveCarriageReturn } from "./utils.ts";
17
+ import { getMaxConcurrent } from "./config.ts";
18
+ import type { TaskProgress, TaskResult } from "./types.ts";
19
+
20
+ /** Renderer state — the live subset of Pi's `ToolRenderContext.state`. */
21
+ export interface RenderState {
22
+ startedAt?: number;
23
+ interval?: ReturnType<typeof setInterval>;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /** Shared inputs for the partial and final render branches. */
28
+ export interface BranchCtx {
29
+ progress: TaskProgress[];
30
+ taskResults: (TaskResult | { error: string })[];
31
+ total: number;
32
+ w: number;
33
+ expanded: boolean;
34
+ state: RenderState;
35
+ theme: Theme;
36
+ /** Mutated by the branch — caller seeds it and applies the line budget. */
37
+ lines: string[];
38
+ /** Async ticket id (only present for background-ticket results). */
39
+ ticketId?: string;
40
+ /** Async ticket status, when known, so the renderer can show cancelling/cancelled. */
41
+ ticketStatus?: "running" | "cancelling" | "done" | "failed" | "cancelled";
42
+ }
43
+
44
+ /** Helpers shared across both branches. `pushWarnings` mutates `lines`.
45
+ * Built by the caller via `makeRenderHelpers` (in render-result.ts) and
46
+ * passed in so both branches share one bound set. */
47
+ export interface RenderHelpers {
48
+ statJoin: (parts: string[]) => string;
49
+ modelLabel: (p: TaskProgress) => string;
50
+ compactActivity: (p: TaskProgress) => string;
51
+ pushWarnings: (p: TaskProgress, ind: string) => void;
52
+ }
53
+
54
+ /** Live (partial) progress tree — animated spinner, in-flight activity, live
55
+ * output previews, and a header with running/done counts. */
56
+ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
57
+ const { progress, total, w, expanded, state, theme, lines } = ctx;
58
+ const { statJoin, modelLabel, compactActivity, pushWarnings } = h;
59
+
60
+ const done = progress.filter(
61
+ (p) => p.status === "done" || p.status === "failed",
62
+ ).length;
63
+ const running = progress.filter((p) => p.status === "running").length;
64
+ const elapsed = state.startedAt
65
+ ? ` · ${fmtDuration(Date.now() - state.startedAt)}`
66
+ : "";
67
+
68
+ // Richer header: agent counts + wall time. The Ctrl+O affordance is
69
+ // tool-scoped in Pi, so a single header hint suffices — one per running
70
+ // task just repeats the same line N times.
71
+ const headerParts: string[] = [];
72
+ if (ctx.ticketStatus === "cancelling") headerParts.push("CANCELLING");
73
+ if (running > 0) headerParts.push(`${running} running`);
74
+ headerParts.push(`${done}/${total} done`);
75
+ if (!expanded && running > 0)
76
+ headerParts.push(theme.fg("accent", "Ctrl+O for detail"));
77
+ lines.push(theme.fg("muted", `${headerParts.join(" · ")}${elapsed}`), "");
78
+
79
+ for (let i = 0; i < total; i++) {
80
+ const p = progress[i]!;
81
+ const ind = indent(i, total);
82
+ const runParts: string[] = [];
83
+ if (p.toolUses > 0)
84
+ runParts.push(`${p.toolUses} tool${p.toolUses > 1 ? "s" : ""}`);
85
+ if (p.tokens > 0) runParts.push(`${fmtTokens(p.tokens)} tokens`);
86
+
87
+ switch (p.status) {
88
+ case "done":
89
+ lines.push(
90
+ truncLine(
91
+ `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
92
+ w,
93
+ ),
94
+ );
95
+ if (expanded) {
96
+ for (const activity of p.activities.slice(-3)) {
97
+ const call = formatToolCallShort(activity.name, activity.args);
98
+ const icon = activity.result?.isError
99
+ ? theme.fg("error", "✗")
100
+ : theme.fg("success", "✓");
101
+ lines.push(
102
+ truncLine(`${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`, w),
103
+ );
104
+ }
105
+ }
106
+ break;
107
+ case "failed":
108
+ lines.push(
109
+ truncLine(
110
+ `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
111
+ w,
112
+ ),
113
+ );
114
+ if (expanded) {
115
+ for (const activity of p.activities.slice(-3)) {
116
+ const call = formatToolCallShort(activity.name, activity.args);
117
+ const icon = activity.result?.isError
118
+ ? theme.fg("error", "✗")
119
+ : theme.fg("success", "✓");
120
+ lines.push(
121
+ truncLine(`${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`, w),
122
+ );
123
+ }
124
+ }
125
+ break;
126
+ case "running":
127
+ {
128
+ const activityAge = getActivityAge(p.lastActivityAt);
129
+ const ageTag = activityAge ? ` · ${activityAge}` : "";
130
+ const stallTag =
131
+ p.failureKind === "stalled"
132
+ ? theme.fg("warning", " · stall detected · cancellation pending")
133
+ : "";
134
+ const glyph = theme.fg("warning", spinnerFrame());
135
+ lines.push(
136
+ truncLine(
137
+ `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin(runParts)}${stallTag}${theme.fg("muted", ageTag)}`,
138
+ w,
139
+ ),
140
+ );
141
+
142
+ if (expanded) {
143
+ // ── Expanded: recent activity history (like done/failed) ──
144
+ if (p.activities.length > 0) {
145
+ for (const activity of p.activities.slice(-5)) {
146
+ const call = formatToolCallShort(activity.name, activity.args);
147
+ if (!activity.result) {
148
+ // In-flight
149
+ const elapsed = ` | ${fmtDuration(Date.now() - activity.startTime)}`;
150
+ lines.push(
151
+ truncLine(
152
+ `${ind}${theme.fg("warning", `> ${call}${elapsed}`)}`,
153
+ w,
154
+ ),
155
+ );
156
+ // Show live stdout/stderr preview for streaming tools
157
+ if (activity.liveOutput) {
158
+ const clean = stripAnsi(
159
+ resolveCarriageReturn(activity.liveOutput),
160
+ );
161
+ const preview = clean
162
+ .split("\n")
163
+ .filter((l) => l.trim())
164
+ .slice(-3);
165
+ for (const outLine of preview) {
166
+ lines.push(
167
+ truncLine(
168
+ `${ind} ${theme.fg("toolOutput", outLine)}`,
169
+ w,
170
+ ),
171
+ );
172
+ }
173
+ }
174
+ } else {
175
+ const icon = activity.result.isError
176
+ ? theme.fg("error", "✗")
177
+ : theme.fg("success", "✓");
178
+ lines.push(
179
+ truncLine(
180
+ `${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`,
181
+ w,
182
+ ),
183
+ );
184
+ }
185
+ }
186
+ } else {
187
+ lines.push(
188
+ truncLine(`${ind}${theme.fg("muted", " thinking…")}`, w),
189
+ );
190
+ }
191
+ } else {
192
+ // ── Collapsed: compact tool line with duration ─────
193
+ // The Ctrl+O affordance lives once in the header now — emitting
194
+ // it per task just repeats the same hint for every running agent.
195
+ lines.push(
196
+ truncLine(
197
+ `${ind}${theme.fg("muted", `⎿ ${compactActivity(p)}`)}`,
198
+ w,
199
+ ),
200
+ );
201
+ }
202
+ }
203
+ break;
204
+ default: {
205
+ // how many slots are occupied so a human sees throttling, not a stall.
206
+ // Pending / waiting. When the concurrency cap is the reason, show
207
+ const queuedTag = theme.fg(
208
+ "muted",
209
+ ` ${waitingLabel(running, getMaxConcurrent())}`,
210
+ );
211
+ lines.push(
212
+ truncLine(
213
+ `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${modelLabel(p)} ${queuedTag}`,
214
+ w,
215
+ ),
216
+ );
217
+ }
218
+ }
219
+ pushWarnings(p, ind);
220
+ }
221
+ }
222
+
223
+ /** Final (or async-ticket poll) view — static status glyphs, output previews,
224
+ * markdown rendering in expanded mode, and the ticket banner for live
225
+ * background tickets. */
226
+ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
227
+ const { progress, taskResults, total, w, expanded, state, theme, lines } =
228
+ ctx;
229
+ const { statJoin, modelLabel, compactActivity, pushWarnings } = h;
230
+
231
+ // Also covers async dispatch + poll of a *running* ticket: those return
232
+ // details.progress with non-terminal (pending/running) statuses. The
233
+ // partial branch isn't used for them (execute has already returned), so
234
+ // this branch must render every status. A ticket banner is shown when
235
+ // details.ticketId is present so the human sees this is background work.
236
+ const succeeded = progress.filter((p) => p.status === "done").length;
237
+ const failed = progress.filter((p) => p.status === "failed").length;
238
+ const finalized = succeeded + failed;
239
+ const running = progress.filter((p) => p.status === "running").length;
240
+ const pending = progress.filter((p) => p.status === "pending").length;
241
+ const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
242
+ const ticketId = ctx.ticketId;
243
+ const ticketStatus = ctx.ticketStatus;
244
+ const isLive = ticketStatus === "running" || ticketStatus === "cancelling";
245
+ const elapsed = state.startedAt
246
+ ? fmtDuration(Date.now() - state.startedAt)
247
+ : fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
248
+
249
+ if (ticketId && isLive) {
250
+ // Background ticket — frame it as in-progress, not a finished result.
251
+ const ticketParts = [
252
+ `ticket ${ticketId}`,
253
+ `${finalized}/${total} finalized`,
254
+ ];
255
+ if (running > 0) ticketParts.push(`${running} active`);
256
+ if (pending > 0) ticketParts.push(`${pending} queued`);
257
+ if (failed > 0) ticketParts.push(`${failed} failed`);
258
+ ticketParts.push(
259
+ ticketStatus === "cancelling" ? "CANCELLING" : "running in background",
260
+ );
261
+ lines.push(theme.fg("warning", `⏳ ${ticketParts.join(" · ")}`), "");
262
+ } else {
263
+ lines.push(
264
+ theme.fg(
265
+ "muted",
266
+ `${succeeded}/${total} completed · ${elapsed} wall · ${fmtTokens(totalTokens)} tokens`,
267
+ ),
268
+ "",
269
+ );
270
+ }
271
+
272
+ for (let i = 0; i < total; i++) {
273
+ const p = progress[i]!;
274
+ const r = taskResults[i];
275
+ const ind = indent(i, total);
276
+ const isCancelledPending =
277
+ ticketStatus === "cancelled" && p.status === "pending";
278
+
279
+ // Unified status glyphs: ✓ done, ✗ failed, ◐ running, ○ pending.
280
+ // Cancelled-but-not-started tasks show as failed so they are not mistaken
281
+ // for still-queued work.
282
+ const icon = isCancelledPending
283
+ ? theme.fg("error", "✗")
284
+ : p.status === "done"
285
+ ? theme.fg("success", "✓")
286
+ : p.status === "failed"
287
+ ? theme.fg("error", "✗")
288
+ : p.status === "running"
289
+ ? theme.fg("warning", "◐")
290
+ : theme.fg("muted", "○");
291
+ const taskPreview = theme.fg("muted", trunc(p.task, w - 30));
292
+ const isLive =
293
+ p.status === "running" || (p.status === "pending" && !isCancelledPending);
294
+ // Live tasks show an activity/waiting hint instead of final stats.
295
+ const liveTail =
296
+ p.status === "running"
297
+ ? theme.fg("muted", ` · ${compactActivity(p)}`)
298
+ : p.status === "pending" && !isCancelledPending
299
+ ? theme.fg("muted", ` ${waitingLabel(running, getMaxConcurrent())}`)
300
+ : "";
301
+ const cancelledTail = isCancelledPending
302
+ ? theme.fg("error", " · CANCELLED")
303
+ : "";
304
+ lines.push(
305
+ truncLine(
306
+ `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
307
+ w,
308
+ ),
309
+ );
310
+
311
+ // Warnings (e.g. unknown tools ignored) — muted line under the task.
312
+ pushWarnings(p, ind);
313
+
314
+ // Tool activities: compact summary only in expanded mode, terminal tasks only.
315
+ if (p.activities.length > 0 && expanded && !isLive) {
316
+ const names = p.activities
317
+ .map((a) => a.name)
318
+ .filter((n, i, arr) => arr.indexOf(n) === i);
319
+ const nameList =
320
+ names.slice(0, 4).join(", ") +
321
+ (names.length > 4 ? ` +${names.length - 4}` : "");
322
+ const okCount = p.activities.filter(
323
+ (a) => a.result && !a.result.isError,
324
+ ).length;
325
+ const errCount = p.activities.filter((a) => a.result?.isError).length;
326
+ const statusParts: string[] = [];
327
+ if (okCount > 0) statusParts.push(`${okCount} ✓`);
328
+ if (errCount > 0) statusParts.push(`${errCount} ✗`);
329
+ const status = statusParts.length ? ` · ${statusParts.join(", ")}` : "";
330
+ lines.push(
331
+ truncLine(
332
+ `${ind}${theme.fg("muted", `${p.activities.length} tool${p.activities.length > 1 ? "s" : ""}: ${nameList}${status}`)}`,
333
+ w,
334
+ ),
335
+ );
336
+ }
337
+
338
+ // Surface errors even when output exists (agent may have emitted text before failing).
339
+ // Live tasks and cancelled-but-not-started tasks already show their status
340
+ // on the row, so don't duplicate it as an error line.
341
+ if (!isLive && !isCancelledPending && r && "error" in r && r.error) {
342
+ lines.push(truncLine(`${ind}${theme.fg("error", r.error)}`, w));
343
+ }
344
+ // Collapsed: one-line output preview so a human scanning the TUI sees
345
+ // the payoff without expanding every task. Expanded mode renders the
346
+ // full markdown below instead.
347
+ if (!expanded && p.status === "done") {
348
+ const preview =
349
+ r && "output" in r
350
+ ? previewOutputLine(r.output ?? "", w - ind.length - 3)
351
+ : "";
352
+ if (preview) {
353
+ lines.push(truncLine(`${ind}${theme.fg("muted", `⎿ ${preview}`)}`, w));
354
+ }
355
+ }
356
+ // Output: render markdown only in expanded mode.
357
+ if (
358
+ r &&
359
+ "output" in r &&
360
+ r.output?.trim() &&
361
+ r.output !== "(no output)" &&
362
+ expanded
363
+ ) {
364
+ const cacheKey = `md_${i}_${expanded ? "exp" : "col"}_${w - ind.length}`;
365
+ let mdLines: string[] | undefined = state[cacheKey] as
366
+ string[] | undefined;
367
+ if (!mdLines || state[`${cacheKey}_src`] !== r.output) {
368
+ const md = new Markdown(r.output.trim(), 0, 0, getMarkdownTheme());
369
+ mdLines = md.render(Math.max(20, w - ind.length));
370
+ state[`${cacheKey}_src`] = r.output;
371
+ state[cacheKey] = mdLines;
372
+ }
373
+ for (const line of mdLines) {
374
+ lines.push(truncLine(ind + line, w));
375
+ }
376
+ }
377
+ // Visual separator between tasks — only in expanded mode.
378
+ if (expanded) lines.push("");
379
+ }
380
+ }
@@ -0,0 +1,182 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ fmtDuration,
5
+ spinnerFrame,
6
+ getTermWidth,
7
+ truncLine,
8
+ applyLineBudget,
9
+ compactActivity,
10
+ } from "./format.ts";
11
+ import {
12
+ renderPartialBranch,
13
+ renderFinalBranch,
14
+ type RenderState,
15
+ type RenderHelpers,
16
+ } from "./render-branches.ts";
17
+ import type { DelegateDetails, TaskProgress } from "./types.ts";
18
+
19
+ /** Build the shared helpers bound to a theme, width, and the lines sink.
20
+ * Both render branches consume one bound set so warning/activity formatting
21
+ * stays consistent across the partial and final views. */
22
+ function makeRenderHelpers(
23
+ theme: Theme,
24
+ w: number,
25
+ lines: string[],
26
+ ): RenderHelpers {
27
+ const statJoin = (parts: string[]) =>
28
+ parts.length ? theme.fg("muted", ` · ${parts.join(" · ")}`) : "";
29
+ const modelLabel = (p: TaskProgress) =>
30
+ p.model ? ` ${theme.fg("accent", p.model)}` : "";
31
+
32
+ // Push muted warning lines for a task under its status row. Rendered in
33
+ // both partial and final views so a human watching the TUI sees that tools
34
+ // were silently dropped — the LLM already gets this in `content`.
35
+ const pushWarnings = (p: TaskProgress, ind: string) => {
36
+ if (!p.warnings?.length) return;
37
+ for (const wn of p.warnings) {
38
+ lines.push(truncLine(`${ind}${theme.fg("warning", `⚠ ${wn}`)}`, w));
39
+ }
40
+ };
41
+
42
+ return { statJoin, modelLabel, compactActivity, pushWarnings };
43
+ }
44
+
45
+ /** Minimal structural view of Pi's `ToolRenderContext` used by the delegate
46
+ * renderers. The real context is wider; we only depend on these fields. */
47
+ interface RenderCtx {
48
+ state: RenderState;
49
+ lastComponent: unknown;
50
+ invalidate: () => void;
51
+ executionStarted: boolean;
52
+ isPartial: boolean;
53
+ }
54
+
55
+ interface RenderResultOptions {
56
+ isPartial: boolean;
57
+ expanded: boolean;
58
+ }
59
+
60
+ /** Minimal structural view of `AgentToolResult<DelegateDetails>` for rendering.
61
+ * `content` items are loosely typed — Pi may include image parts that lack
62
+ * `text`, which we simply filter out. */
63
+ interface RenderResult {
64
+ content?: Array<{ type: string; text?: string }>;
65
+ details?: DelegateDetails;
66
+ }
67
+
68
+ /** Custom rendering for the tool *call* display — minimal by design; the result
69
+ * renderer shows all detail. Only animates a spinner while still running. */
70
+ export function renderDelegateCall(
71
+ args: { tasks?: unknown },
72
+ theme: Theme,
73
+ ctx: RenderCtx,
74
+ ): Text {
75
+ const state = ctx.state;
76
+ const rawTasks = args.tasks;
77
+ const tasks = Array.isArray(rawTasks) ? rawTasks : [];
78
+ const text = (ctx.lastComponent as Text | undefined) ?? new Text("", 0, 0);
79
+ if (typeof rawTasks === "string") {
80
+ text.setText(theme.fg("toolTitle", theme.bold("delegate invalid tasks")));
81
+ return text;
82
+ }
83
+ if (!tasks.length) {
84
+ text.setText(theme.fg("toolTitle", theme.bold("delegate")));
85
+ return text;
86
+ }
87
+ // Minimal call rendering — renderResult handles all detail.
88
+ // ToolExecutionComponent stacks call + result, so duplication
89
+ // happens if both show task trees.
90
+ // Only show spinner while still running (ctx.isPartial).
91
+ if (ctx.executionStarted && ctx.isPartial) {
92
+ if (state.startedAt === undefined) state.startedAt = Date.now();
93
+ const elapsed = fmtDuration(Date.now() - state.startedAt);
94
+ text.setText(
95
+ theme.fg(
96
+ "toolTitle",
97
+ theme.bold(
98
+ `${spinnerFrame()} delegate ${tasks.length} task${tasks.length > 1 ? "s" : ""} · ${elapsed}`,
99
+ ),
100
+ ),
101
+ );
102
+ return text;
103
+ }
104
+ text.setText(
105
+ theme.fg(
106
+ "toolTitle",
107
+ theme.bold(`delegate ${tasks.length} task${tasks.length > 1 ? "s" : ""}`),
108
+ ),
109
+ );
110
+ return text;
111
+ }
112
+
113
+ /** Custom rendering for the tool *result* display — delegates the heavy
114
+ * lifting (progress trees, activity lines, output previews, markdown) to the
115
+ * partial/final branch renderers. Owns only the spinner-interval lifecycle,
116
+ * the no-progress fallback, and the line-budget pass. */
117
+ export function renderDelegateResult(
118
+ result: RenderResult,
119
+ options: RenderResultOptions,
120
+ theme: Theme,
121
+ ctx: RenderCtx,
122
+ ): Text {
123
+ const state = ctx.state;
124
+ // Use a faster animation cadence for spinner (80ms) vs the old 1s
125
+ const tickMs = 80;
126
+ if (options.isPartial && !state.interval)
127
+ state.interval = setInterval(() => ctx.invalidate(), tickMs);
128
+ if (!options.isPartial && state.interval) {
129
+ clearInterval(state.interval);
130
+ state.interval = undefined;
131
+ }
132
+ const text = (ctx.lastComponent as Text | undefined) ?? new Text("", 0, 0);
133
+
134
+ const details = result.details;
135
+ if (!details?.progress?.length) {
136
+ const content =
137
+ result.content
138
+ ?.filter((c) => c.type === "text")
139
+ .map((c) => c.text)
140
+ .join("\n") ?? "";
141
+ text.setText(content ? `\n${content}` : "");
142
+ return text;
143
+ }
144
+
145
+ const {
146
+ progress,
147
+ results: taskResults,
148
+ ticketId,
149
+ status: ticketStatus,
150
+ } = details;
151
+ const total = progress.length;
152
+ const w = getTermWidth() - 4;
153
+ const lines: string[] = [""];
154
+ const helpers = makeRenderHelpers(theme, w, lines);
155
+
156
+ const branchCtx = {
157
+ progress,
158
+ taskResults,
159
+ total,
160
+ w,
161
+ expanded: options.expanded,
162
+ state,
163
+ theme,
164
+ lines,
165
+ ticketId,
166
+ ticketStatus,
167
+ };
168
+
169
+ if (options.isPartial) {
170
+ renderPartialBranch(branchCtx, helpers);
171
+ } else {
172
+ renderFinalBranch(branchCtx, helpers);
173
+ }
174
+
175
+ // The partial branch historically filtered empty lines before budgeting so
176
+ // blank separators didn't count against the row budget; the final branch
177
+ // preserves blanks for visual spacing. Keep that asymmetry intact.
178
+ const toBudget = options.isPartial ? lines.filter(Boolean) : lines;
179
+ const budgeted = applyLineBudget(toBudget, options.expanded ?? false);
180
+ text.setText(budgeted.join("\n"));
181
+ return text;
182
+ }