pi-famulus 0.1.7 → 0.1.8
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 +14 -7
- package/package.json +8 -8
- package/src/bash-override.ts +34 -26
- package/src/bounded-output.ts +167 -0
- package/src/comms/reply-command.ts +1 -1
- package/src/format.ts +33 -7
- package/src/index.ts +3 -2
- package/src/manager-client.ts +18 -1
- package/src/monitor-batching.ts +84 -36
- package/src/monitor-output.ts +159 -0
- package/src/monitor.ts +148 -37
- package/src/notify.ts +100 -17
- package/src/prompts.generated.ts +14 -11
- package/src/shell-exec.ts +28 -2
- package/src/shell-start.ts +25 -0
- package/src/subagent/child-bash.ts +2 -1
- package/src/subagent/conversation.ts +57 -12
- package/src/subagent/fleet-widget.ts +8 -8
- package/src/subagent/runner.ts +2 -2
- package/src/subagent/tool.ts +2 -1
- package/src/task-tools.ts +32 -13
- package/src/tui/conversation-view.ts +402 -0
- package/src/tui/message-renderers.ts +16 -11
- package/src/tui/scroll-detail-view.ts +218 -99
- package/src/tui/task-detail-view.ts +417 -0
- package/src/tui/tasks-command.ts +117 -84
- package/src/tui/tool-component.ts +6 -2
- package/src/wake.ts +27 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent conversation as a thread (same visual language as `pi-famulus top`).
|
|
3
|
+
*
|
|
4
|
+
* ┃ main → scout message from the parent
|
|
5
|
+
* ┃ inspect the loader and report on an accent rail
|
|
6
|
+
*
|
|
7
|
+
* ● scout the child starts speaking
|
|
8
|
+
* ▸ ∴ Thinking folded reasoning
|
|
9
|
+
* ▸ ✓ read src/loader.ts one line per tool call
|
|
10
|
+
* ▾ ✓ bash rg loader unfolded: output hangs off a rule
|
|
11
|
+
* │ src/loader.ts:12
|
|
12
|
+
*
|
|
13
|
+
* The loader skips `p5`. assistant prose, light markdown
|
|
14
|
+
*
|
|
15
|
+
* Consecutive tool / thinking rows stack tight; any other change of block
|
|
16
|
+
* gets one blank line. Colors come from the pi theme, so light and dark
|
|
17
|
+
* themes both work.
|
|
18
|
+
*/
|
|
19
|
+
import type { ConversationTurn } from "../subagent/types";
|
|
20
|
+
import { fitLines, truncateToWidth, visibleWidth } from "./pi-tui-load";
|
|
21
|
+
|
|
22
|
+
export interface ChatTheme {
|
|
23
|
+
fg(color: string, text: string): string;
|
|
24
|
+
bold(text: string): string;
|
|
25
|
+
italic(text: string): string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChatOptions {
|
|
29
|
+
/** Unfold every tool output and thinking block. */
|
|
30
|
+
expanded: boolean;
|
|
31
|
+
/** Child display name for the speaker headers (default "subagent"). */
|
|
32
|
+
agent?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MARGIN = " ";
|
|
36
|
+
|
|
37
|
+
type Group = "user" | "text" | "activity";
|
|
38
|
+
|
|
39
|
+
interface ToolCall {
|
|
40
|
+
name: string;
|
|
41
|
+
summary: string;
|
|
42
|
+
/** Raw JSON arguments, possibly cut short; read leniently. */
|
|
43
|
+
args?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `tool read {"path":"src"}` → name + one-line summary of the arguments. */
|
|
47
|
+
export function parseToolCall(text: string): ToolCall | undefined {
|
|
48
|
+
const match = /^tool (\S+?)(?:\s*(\{[\s\S]*))?$/.exec(text.trim());
|
|
49
|
+
if (!match) return undefined;
|
|
50
|
+
const args = stripCapSuffix(match[2] ?? "");
|
|
51
|
+
return { name: match[1], summary: argsSummary(args), args };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop a transcript cap marker such as `… (3666 chars)`. */
|
|
55
|
+
function stripCapSuffix(raw: string): string {
|
|
56
|
+
return raw.replace(/… \(\d+ chars\)\s*$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every string value of `key`, in order, from JSON that may be cut short. */
|
|
60
|
+
export function jsonStrings(json: string, key: string): Array<{ value: string; closed: boolean }> {
|
|
61
|
+
const out: Array<{ value: string; closed: boolean }> = [];
|
|
62
|
+
const pat = new RegExp(`"${key}"\\s*:\\s*"`, "g");
|
|
63
|
+
let m: RegExpExecArray | null;
|
|
64
|
+
while ((m = pat.exec(json)) !== null) {
|
|
65
|
+
let value = "";
|
|
66
|
+
let closed = false;
|
|
67
|
+
let i = m.index + m[0].length;
|
|
68
|
+
for (; i < json.length; i++) {
|
|
69
|
+
const c = json[i];
|
|
70
|
+
if (c === '"') {
|
|
71
|
+
closed = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (c !== "\\") {
|
|
75
|
+
value += c;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const e = json[++i];
|
|
79
|
+
if (e === undefined) break;
|
|
80
|
+
if (e === "n") value += "\n";
|
|
81
|
+
else if (e === "t") value += "\t";
|
|
82
|
+
else if (e === "r") continue;
|
|
83
|
+
else if (e === "u") {
|
|
84
|
+
const code = Number.parseInt(json.slice(i + 1, i + 5), 16);
|
|
85
|
+
if (!Number.isNaN(code)) value += String.fromCharCode(code);
|
|
86
|
+
i += 4;
|
|
87
|
+
} else value += e;
|
|
88
|
+
}
|
|
89
|
+
out.push({ value, closed });
|
|
90
|
+
pat.lastIndex = Math.max(pat.lastIndex, i);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What a write / edit call changes: a stat for its row, rows for unfolding. */
|
|
96
|
+
export interface CallPreview {
|
|
97
|
+
stat: string;
|
|
98
|
+
rows: Array<{ gutter: string; color: string; text: string }>;
|
|
99
|
+
cut: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function callPreview(name: string, args: string | undefined, theme: ChatTheme): CallPreview | undefined {
|
|
103
|
+
if (!args) return undefined;
|
|
104
|
+
if (name === "write") {
|
|
105
|
+
const content = jsonStrings(args, "content")[0];
|
|
106
|
+
if (!content) return undefined;
|
|
107
|
+
const text = content.value.replace(/\n$/, "");
|
|
108
|
+
const lines = content.value === "" ? [] : text.split("\n");
|
|
109
|
+
const digits = String(lines.length).length;
|
|
110
|
+
const count = content.closed ? `+${lines.length} line${lines.length === 1 ? "" : "s"}` : `+${lines.length}+ lines`;
|
|
111
|
+
return {
|
|
112
|
+
stat: theme.fg("success", count),
|
|
113
|
+
rows: lines.map((text, i) => ({ gutter: `${String(i + 1).padStart(digits)} `, color: "text", text })),
|
|
114
|
+
cut: !content.closed,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (name === "edit") {
|
|
118
|
+
const olds = jsonStrings(args, "oldText");
|
|
119
|
+
const news = jsonStrings(args, "newText");
|
|
120
|
+
const pairs = Math.max(olds.length, news.length);
|
|
121
|
+
if (pairs === 0) return undefined;
|
|
122
|
+
const rows: CallPreview["rows"] = [];
|
|
123
|
+
let minus = 0;
|
|
124
|
+
let plus = 0;
|
|
125
|
+
let cut = false;
|
|
126
|
+
for (let k = 0; k < pairs; k++) {
|
|
127
|
+
if (pairs > 1) rows.push({ gutter: "", color: "dim", text: `@@ edit ${k + 1}/${pairs}` });
|
|
128
|
+
const old = olds[k];
|
|
129
|
+
const neu = news[k];
|
|
130
|
+
if (old) {
|
|
131
|
+
cut ||= !old.closed;
|
|
132
|
+
for (const line of old.value.split("\n")) {
|
|
133
|
+
minus++;
|
|
134
|
+
rows.push({ gutter: "- ", color: "toolDiffRemoved", text: line });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (neu) {
|
|
138
|
+
cut ||= !neu.closed;
|
|
139
|
+
for (const line of neu.value.split("\n")) {
|
|
140
|
+
plus++;
|
|
141
|
+
rows.push({ gutter: "+ ", color: "toolDiffAdded", text: line });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const edits = pairs > 1 ? theme.fg("dim", ` · ${pairs} edits`) : "";
|
|
146
|
+
return { stat: `${theme.fg("error", `−${minus}`)} ${theme.fg("success", `+${plus}`)}${edits}`, rows, cut };
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const PREVIEW_ROWS = 60;
|
|
152
|
+
|
|
153
|
+
function argsSummary(raw: string): string {
|
|
154
|
+
if (!raw) return "";
|
|
155
|
+
let args: Record<string, unknown> | undefined;
|
|
156
|
+
try {
|
|
157
|
+
const parsed: unknown = JSON.parse(raw);
|
|
158
|
+
if (parsed && typeof parsed === "object") args = parsed as Record<string, unknown>;
|
|
159
|
+
} catch {
|
|
160
|
+
// Truncated or not JSON: fall back to the well-known keys below.
|
|
161
|
+
}
|
|
162
|
+
for (const key of ["path", "command", "query", "pattern", "file", "description", "name"]) {
|
|
163
|
+
const value = args ? args[key] : new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(raw)?.[1];
|
|
164
|
+
if (typeof value === "string" && value.trim()) return firstLine(value);
|
|
165
|
+
}
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function firstLine(text: string): string {
|
|
170
|
+
return text.trim().split("\n")[0] ?? "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `tool read` / `tool read error` → `{ name, error }`. */
|
|
174
|
+
function toolResult(turn: ConversationTurn): { name: string; error: boolean } | undefined {
|
|
175
|
+
const match = /^tool (\S+)( error)?$/.exec(turn.role);
|
|
176
|
+
return match ? { name: match[1], error: Boolean(match[2]) } : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Strip CSI/OSC and collapse carriage-return progress, as a terminal would paint it. */
|
|
180
|
+
export function scrubTty(text: string): string {
|
|
181
|
+
return text
|
|
182
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
183
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
184
|
+
.replace(/\x1b[@-Z\\-_]/g, "")
|
|
185
|
+
.split("\n")
|
|
186
|
+
.map((line) => {
|
|
187
|
+
const parts = line.split("\r").filter((part) => part.length > 0);
|
|
188
|
+
return (parts.at(-1) ?? "").replace(/\t/g, " ");
|
|
189
|
+
})
|
|
190
|
+
.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function padBetween(left: string, right: string, width: number): string {
|
|
194
|
+
if (!right) return truncateToWidth(left, width, "…");
|
|
195
|
+
const rw = visibleWidth(right);
|
|
196
|
+
const room = width - rw - 2;
|
|
197
|
+
if (room < 8) return truncateToWidth(left, width, "…");
|
|
198
|
+
const l = truncateToWidth(left, room, "…");
|
|
199
|
+
return l + " ".repeat(Math.max(2, width - visibleWidth(l) - rw)) + right;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Inline `code` and **bold**. State does not carry across source lines. */
|
|
203
|
+
function inline(line: string, theme: ChatTheme): string {
|
|
204
|
+
return line
|
|
205
|
+
.replace(/`([^`]+)`/g, (_m, code: string) => theme.fg("mdCode", code))
|
|
206
|
+
.replace(/\*\*([^*]+)\*\*/g, (_m, strong: string) => theme.bold(strong));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function hang(text: string, color: string, width: number, theme: ChatTheme, italic = false): string[] {
|
|
210
|
+
const rule = theme.fg("borderMuted", "│ ");
|
|
211
|
+
const inner = Math.max(1, width - MARGIN.length - 2);
|
|
212
|
+
const out: string[] = [];
|
|
213
|
+
for (const raw of text.split("\n")) {
|
|
214
|
+
for (const line of fitLines(raw, inner)) {
|
|
215
|
+
const body = theme.fg(color, line);
|
|
216
|
+
out.push(MARGIN + rule + (italic ? theme.italic(body) : body));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function markdown(text: string, width: number, theme: ChatTheme): string[] {
|
|
223
|
+
const out: string[] = [];
|
|
224
|
+
const inner = Math.max(1, width - MARGIN.length);
|
|
225
|
+
let fence = false;
|
|
226
|
+
for (const raw of text.split("\n")) {
|
|
227
|
+
const trimmed = raw.trimStart();
|
|
228
|
+
if (trimmed.startsWith("```")) {
|
|
229
|
+
fence = !fence;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (fence) {
|
|
233
|
+
for (const line of fitLines(scrubTty(raw), Math.max(1, inner - 2))) {
|
|
234
|
+
out.push(MARGIN + theme.fg("mdCodeBlockBorder", "│ ") + theme.fg("mdCodeBlock", line));
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!raw.trim()) {
|
|
239
|
+
out.push("");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const heading = /^#{1,6} (.*)$/.exec(trimmed);
|
|
243
|
+
if (heading) {
|
|
244
|
+
for (const line of fitLines(heading[1], inner)) out.push(MARGIN + theme.bold(theme.fg("mdHeading", line)));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const indent = " ".repeat(Math.min(8, raw.length - trimmed.length));
|
|
248
|
+
const bullet = /^[-*] (.*)$/.exec(trimmed);
|
|
249
|
+
const ordered = /^(\d{1,3}[.)] )(.*)$/.exec(trimmed);
|
|
250
|
+
const quote = /^> ?(.*)$/.exec(trimmed);
|
|
251
|
+
let lead = indent;
|
|
252
|
+
let body = trimmed;
|
|
253
|
+
let leadStyled = indent;
|
|
254
|
+
if (bullet) {
|
|
255
|
+
lead = `${indent}• `;
|
|
256
|
+
leadStyled = indent + theme.fg("mdListBullet", "• ");
|
|
257
|
+
body = bullet[1];
|
|
258
|
+
} else if (ordered) {
|
|
259
|
+
lead = indent + ordered[1];
|
|
260
|
+
leadStyled = indent + theme.fg("mdListBullet", ordered[1]);
|
|
261
|
+
body = ordered[2];
|
|
262
|
+
} else if (quote) {
|
|
263
|
+
lead = "│ ";
|
|
264
|
+
leadStyled = theme.fg("mdQuoteBorder", "│ ");
|
|
265
|
+
body = quote[1];
|
|
266
|
+
}
|
|
267
|
+
const styled = quote ? theme.fg("mdQuote", inline(body, theme)) : inline(body, theme);
|
|
268
|
+
const lines = fitLines(styled, Math.max(1, inner - lead.length));
|
|
269
|
+
lines.forEach((line, i) => {
|
|
270
|
+
out.push(MARGIN + (i === 0 || quote ? leadStyled : " ".repeat(lead.length)) + line);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function renderConversation(
|
|
277
|
+
turns: readonly ConversationTurn[],
|
|
278
|
+
width: number,
|
|
279
|
+
theme: ChatTheme,
|
|
280
|
+
options: ChatOptions,
|
|
281
|
+
): string[] {
|
|
282
|
+
const w = Math.max(1, width);
|
|
283
|
+
if (turns.length === 0) return [MARGIN + theme.fg("dim", "(no conversation yet)")];
|
|
284
|
+
const out: string[] = [];
|
|
285
|
+
let last: Group | undefined;
|
|
286
|
+
const agent = options.agent?.trim() || "subagent";
|
|
287
|
+
// Blank line between blocks, and a `● agent` header whenever the child
|
|
288
|
+
// starts speaking after the parent (or at the top of the thread).
|
|
289
|
+
const begin = (group: Group) => {
|
|
290
|
+
if (last !== undefined && !(group === "activity" && last === "activity")) out.push("");
|
|
291
|
+
if (group !== "user" && (last === undefined || last === "user")) {
|
|
292
|
+
out.push(theme.fg("success", "● ") + theme.bold(theme.fg("success", agent)));
|
|
293
|
+
}
|
|
294
|
+
last = group;
|
|
295
|
+
};
|
|
296
|
+
const chevron = (open: boolean, foldable: boolean) =>
|
|
297
|
+
theme.fg("dim", foldable ? (open ? "▾ " : "▸ ") : " ");
|
|
298
|
+
|
|
299
|
+
const toolRow = (call: ToolCall, result: { text: string; error: boolean } | undefined) => {
|
|
300
|
+
begin("activity");
|
|
301
|
+
const pending = result === undefined;
|
|
302
|
+
const body = pending ? "" : scrubTty(result.text).trimEnd();
|
|
303
|
+
const preview = callPreview(call.name, call.args, theme);
|
|
304
|
+
const foldable = preview !== undefined || body.trim().length > 0;
|
|
305
|
+
const glyph = pending
|
|
306
|
+
? theme.fg("accent", "● ")
|
|
307
|
+
: result.error ? theme.fg("error", "✗ ") : theme.fg("success", "✓ ");
|
|
308
|
+
const summary = call.summary ? theme.fg("muted", ` ${scrubTty(call.summary)}`) : "";
|
|
309
|
+
// The stat sits on the right so a long path truncates, not the stat.
|
|
310
|
+
const tail = [preview?.stat, pending ? theme.fg("dim", "running") : ""].filter(Boolean).join(" ");
|
|
311
|
+
out.push(padBetween(MARGIN + chevron(options.expanded, foldable) + glyph + theme.bold(call.name) + summary, tail, w));
|
|
312
|
+
if (!foldable || !options.expanded) return;
|
|
313
|
+
if (preview) {
|
|
314
|
+
const rule = theme.fg("borderMuted", "│ ");
|
|
315
|
+
const gw = Math.max(0, ...preview.rows.map((r) => r.gutter.length));
|
|
316
|
+
const inner = Math.max(1, w - MARGIN.length - 2 - gw);
|
|
317
|
+
let shown = 0;
|
|
318
|
+
let rows = 0;
|
|
319
|
+
for (const row of preview.rows) {
|
|
320
|
+
const gutterColor = /^\s*\d+ $/.test(row.gutter) ? "dim" : row.color;
|
|
321
|
+
const segs = fitLines(scrubTty(row.text), inner);
|
|
322
|
+
const room = PREVIEW_ROWS - rows;
|
|
323
|
+
segs.slice(0, room).forEach((seg, j) => {
|
|
324
|
+
// Headers (no gutter) sit flush; gutters align under each other.
|
|
325
|
+
const g = row.gutter === "" ? "" : j === 0 ? row.gutter.padStart(gw) : " ".repeat(gw);
|
|
326
|
+
out.push(MARGIN + rule + theme.fg(gutterColor, g) + theme.fg(row.color, seg));
|
|
327
|
+
});
|
|
328
|
+
rows += Math.min(room, segs.length);
|
|
329
|
+
// A line the cap cut part-way through counts as not shown.
|
|
330
|
+
if (segs.length > room) break;
|
|
331
|
+
shown++;
|
|
332
|
+
}
|
|
333
|
+
const more = preview.rows.length - shown;
|
|
334
|
+
if (more > 0) out.push(MARGIN + rule + theme.fg("dim", `… ${more} more line${more === 1 ? "" : "s"}`));
|
|
335
|
+
if (preview.cut) out.push(MARGIN + rule + theme.fg("dim", "… cut short in the transcript"));
|
|
336
|
+
if (body.trim()) out.push(...hang(body, result?.error ? "error" : "dim", w, theme));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
out.push(...hang(body, result?.error ? "error" : "toolOutput", w, theme));
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
for (let i = 0; i < turns.length; i++) {
|
|
343
|
+
const turn = turns[i];
|
|
344
|
+
if (turn.role === "user") {
|
|
345
|
+
begin("user");
|
|
346
|
+
const rail = theme.fg("accent", "┃ ");
|
|
347
|
+
out.push(rail + theme.bold(theme.fg("accent", "main")) + theme.fg("dim", ` → ${agent}`));
|
|
348
|
+
for (const line of fitLines(scrubTty(turn.text).trim() || " ", w - 2)) out.push(rail + line);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (turn.role === "thinking") {
|
|
352
|
+
begin("activity");
|
|
353
|
+
const foldable = turn.text.trim().length > 0;
|
|
354
|
+
out.push(MARGIN + chevron(options.expanded, foldable) + theme.fg("muted", "∴ ") + theme.italic(theme.fg("dim", "Thinking")));
|
|
355
|
+
if (foldable && options.expanded) out.push(...hang(scrubTty(turn.text.trim()), "thinkingText", w, theme, true));
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const result = toolResult(turn);
|
|
359
|
+
if (result) {
|
|
360
|
+
// A result without its call (the call scrolled out of the snapshot).
|
|
361
|
+
toolRow({ name: result.name, summary: "" }, { text: turn.text, error: result.error });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (turn.role === "assistant error") {
|
|
365
|
+
begin("text");
|
|
366
|
+
fitLines(scrubTty(turn.text).trim(), w - MARGIN.length - 2).forEach((line, j) => {
|
|
367
|
+
out.push(MARGIN + (j === 0 ? theme.fg("error", "✗ ") : " ") + theme.fg("error", line));
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const call = parseToolCall(turn.text);
|
|
372
|
+
if (call) {
|
|
373
|
+
// Parallel calls arrive as a run of calls, then a run of results in
|
|
374
|
+
// call order: pair them by name, first unused result first.
|
|
375
|
+
const calls = [call];
|
|
376
|
+
let j = i + 1;
|
|
377
|
+
for (let c: ToolCall | undefined; j < turns.length && !toolResult(turns[j]) && (c = parseToolCall(turns[j].text)); j++) calls.push(c);
|
|
378
|
+
const results: Array<{ name: string; error: boolean; text: string; used: boolean }> = [];
|
|
379
|
+
for (let r; j < turns.length && (r = toolResult(turns[j])); j++) results.push({ ...r, text: turns[j].text, used: false });
|
|
380
|
+
for (const c of calls) {
|
|
381
|
+
const hit = results.find((r) => !r.used && r.name === c.name);
|
|
382
|
+
if (hit) hit.used = true;
|
|
383
|
+
toolRow(c, hit && { text: hit.text, error: hit.error });
|
|
384
|
+
}
|
|
385
|
+
// A result with no matching call (its call scrolled out of the snapshot).
|
|
386
|
+
for (const r of results) if (!r.used) toolRow({ name: r.name, summary: "" }, r);
|
|
387
|
+
i = j - 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
begin("text");
|
|
391
|
+
// Child transcript text is untrusted: no escape sequence reaches the terminal.
|
|
392
|
+
out.push(...markdown(scrubTty(turn.text).trim(), w, theme));
|
|
393
|
+
}
|
|
394
|
+
return out.map((line) => (visibleWidth(line) > w ? truncateToWidth(line, w, "…") : line));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Plain-text thread for notify fallbacks and logs: same shape, no color. */
|
|
398
|
+
export const plainTheme: ChatTheme = {
|
|
399
|
+
fg: (_color, text) => text,
|
|
400
|
+
bold: (text) => text,
|
|
401
|
+
italic: (text) => text,
|
|
402
|
+
};
|
|
@@ -9,6 +9,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
import { FAMULUS_WAKE_CUSTOM_TYPE, FAMULUS_WAKE_LEAD_IN, type FamulusWake, type TaskWake } from "../wake";
|
|
10
10
|
import { fitLines, loadPiTui } from "./pi-tui-load";
|
|
11
11
|
import { statusGlyph } from "./tool-component";
|
|
12
|
+
import { formatDuration } from "../format";
|
|
12
13
|
|
|
13
14
|
type Theme = {
|
|
14
15
|
fg(color: string, text: string): string;
|
|
@@ -42,15 +43,20 @@ function countStatuses(statuses: string[]): string {
|
|
|
42
43
|
return parts.join(" · ");
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
/** A non-zero exit fails a task even when it "completed". */
|
|
47
|
+
function effectiveStatus(status: string | undefined, exitCode?: number | null): string {
|
|
48
|
+
if (exitCode !== undefined && exitCode !== null && exitCode !== 0) return "failed";
|
|
49
|
+
return status ?? "completed";
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
const FAILED = new Set(["failed", "killed", "orphaned", "interrupted"]);
|
|
53
|
+
const STOPPED = new Set(["stopped", "timeout", "partial"]);
|
|
54
|
+
|
|
55
|
+
/** Worst of a batch: any failure, else any stop, else done. */
|
|
56
|
+
function worstStatus(statuses: string[]): string {
|
|
57
|
+
if (statuses.some((s) => FAILED.has(s))) return "failed";
|
|
58
|
+
const stopped = statuses.find((s) => STOPPED.has(s));
|
|
59
|
+
return stopped ?? "completed";
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
function taskExit(task: TaskWake): string {
|
|
@@ -70,8 +76,7 @@ function taskHead(details: Extract<FamulusWake, { kind: "task" }>): string {
|
|
|
70
76
|
function collapsedText(details: FamulusWake, theme: Theme): string {
|
|
71
77
|
switch (details.kind) {
|
|
72
78
|
case "task": {
|
|
73
|
-
const
|
|
74
|
-
const { color, glyph } = statusGlyph(bad ? "failed" : "completed");
|
|
79
|
+
const { color, glyph } = statusGlyph(worstStatus(details.tasks.map((task) => effectiveStatus(task.status, task.exitCode))));
|
|
75
80
|
return `${theme.fg(color, glyph)} ${theme.fg("muted", "task")} ${taskHead(details)}`;
|
|
76
81
|
}
|
|
77
82
|
case "monitor": {
|
|
@@ -94,8 +99,8 @@ function collapsedText(details: FamulusWake, theme: Theme): string {
|
|
|
94
99
|
return `${theme.fg(color, glyph)} ${theme.fg("muted", "handover")} ${details.name} ${details.status}${snippet ? ` · ${snippet}` : ""}`;
|
|
95
100
|
}
|
|
96
101
|
case "subagent-done": {
|
|
97
|
-
const
|
|
98
|
-
const { color, glyph } = statusGlyph(
|
|
102
|
+
const worst = worstStatus(details.children.map((child) => effectiveStatus(child.status)));
|
|
103
|
+
const { color, glyph } = statusGlyph(worst === "completed" ? details.status : worst);
|
|
99
104
|
return `${theme.fg(color, glyph)} ${theme.fg("muted", "subagent")} ${countStatuses(details.children.map((child) => child.status))}`;
|
|
100
105
|
}
|
|
101
106
|
case "subagent-overrun": {
|