@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.
- package/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
|
@@ -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
|
+
}
|
package/render-result.ts
ADDED
|
@@ -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
|
+
}
|