@juicesharp/rpiv-pi 0.12.0 → 0.12.2
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/extensions/subagent-widget/renderer-override.test.ts +98 -0
- package/extensions/subagent-widget/renderer-override.ts +25 -10
- package/extensions/subagent-widget/run-tracker.test.ts +50 -0
- package/extensions/subagent-widget/run-tracker.ts +12 -4
- package/extensions/subagent-widget/widget.render.test.ts +46 -0
- package/extensions/subagent-widget/widget.ts +8 -4
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
// Stub the full renderer so the terminal-state branch returns a recognisable sentinel
|
|
6
|
+
// instead of executing nicobailon's real renderer (which needs a full Theme).
|
|
7
|
+
// vi.hoisted is required because vi.mock factories are top-hoisted and can't close
|
|
8
|
+
// over file-level consts.
|
|
9
|
+
const { renderSubagentResultMock } = vi.hoisted(() => ({
|
|
10
|
+
renderSubagentResultMock: vi.fn(() => ({ __sentinel: "full-render" }) as unknown),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("pi-subagents/render", () => ({ renderSubagentResult: renderSubagentResultMock }));
|
|
13
|
+
|
|
14
|
+
import { buildQuietRenderResult } from "./renderer-override.js";
|
|
15
|
+
|
|
16
|
+
function makeTheme(): Theme {
|
|
17
|
+
return {
|
|
18
|
+
fg: (_c: string, t: string) => t,
|
|
19
|
+
bold: (t: string) => t,
|
|
20
|
+
} as unknown as Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("buildQuietRenderResult — layout-stable quiet card", () => {
|
|
24
|
+
it("emits ONE Text line when progress.status === 'running'", () => {
|
|
25
|
+
const render = buildQuietRenderResult();
|
|
26
|
+
const out = render(
|
|
27
|
+
{ details: { results: [{ agent: "x", progress: { status: "running" } }] } },
|
|
28
|
+
{ expanded: false },
|
|
29
|
+
makeTheme(),
|
|
30
|
+
);
|
|
31
|
+
expect(out).toBeInstanceOf(Text);
|
|
32
|
+
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("emits ONE Text line when progress.status === 'pending'", () => {
|
|
36
|
+
const render = buildQuietRenderResult();
|
|
37
|
+
const out = render(
|
|
38
|
+
{ details: { results: [{ agent: "x", progress: { status: "pending" } }] } },
|
|
39
|
+
{ expanded: false },
|
|
40
|
+
makeTheme(),
|
|
41
|
+
);
|
|
42
|
+
expect(out).toBeInstanceOf(Text);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("emits ONE Text line when progress is MISSING (pre-progress partial update)", () => {
|
|
46
|
+
const render = buildQuietRenderResult();
|
|
47
|
+
// First partialResult often arrives before pi-subagents stamps progress.
|
|
48
|
+
// Previously fell through to full renderer → N-line card mid-stream →
|
|
49
|
+
// layout shift → physical-row stacking. Now stays 1-line.
|
|
50
|
+
const out = render({ details: { results: [{ agent: "x" }] } }, { expanded: false }, makeTheme());
|
|
51
|
+
expect(out).toBeInstanceOf(Text);
|
|
52
|
+
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("emits ONE Text line when result.details itself is missing (very first frame)", () => {
|
|
56
|
+
const render = buildQuietRenderResult();
|
|
57
|
+
const out = render({}, { expanded: false }, makeTheme());
|
|
58
|
+
expect(out).toBeInstanceOf(Text);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("delegates to full renderer once exitCode lands (terminal state)", () => {
|
|
62
|
+
renderSubagentResultMock.mockClear();
|
|
63
|
+
const render = buildQuietRenderResult();
|
|
64
|
+
const out = render(
|
|
65
|
+
{ details: { results: [{ agent: "x", exitCode: 0, progress: { status: "complete" } }] } },
|
|
66
|
+
{ expanded: false },
|
|
67
|
+
makeTheme(),
|
|
68
|
+
);
|
|
69
|
+
expect(renderSubagentResultMock).toHaveBeenCalledOnce();
|
|
70
|
+
expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("delegates to full renderer on error stopReason", () => {
|
|
74
|
+
renderSubagentResultMock.mockClear();
|
|
75
|
+
const render = buildQuietRenderResult();
|
|
76
|
+
const out = render(
|
|
77
|
+
{ details: { results: [{ agent: "x", stopReason: "error" }] } },
|
|
78
|
+
{ expanded: false },
|
|
79
|
+
makeTheme(),
|
|
80
|
+
);
|
|
81
|
+
expect(renderSubagentResultMock).toHaveBeenCalledOnce();
|
|
82
|
+
expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("treats exitCode present + status=running as NON-terminal (streaming finalisation window)", () => {
|
|
86
|
+
renderSubagentResultMock.mockClear();
|
|
87
|
+
const render = buildQuietRenderResult();
|
|
88
|
+
// Mid-transition frames can have exitCode set before progress.status
|
|
89
|
+
// leaves "running". Should stay quiet until status clears.
|
|
90
|
+
const out = render(
|
|
91
|
+
{ details: { results: [{ agent: "x", exitCode: 0, progress: { status: "running" } }] } },
|
|
92
|
+
{ expanded: false },
|
|
93
|
+
makeTheme(),
|
|
94
|
+
);
|
|
95
|
+
expect(out).toBeInstanceOf(Text);
|
|
96
|
+
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -34,30 +34,45 @@ interface ProgressLike {
|
|
|
34
34
|
interface ResultLike {
|
|
35
35
|
agent?: string;
|
|
36
36
|
progress?: ProgressLike;
|
|
37
|
+
exitCode?: number;
|
|
38
|
+
stopReason?: string;
|
|
37
39
|
}
|
|
38
40
|
interface DetailsLike {
|
|
39
41
|
results?: ResultLike[];
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
// Layout-stable quiet card height: while the subagent is non-terminal, emit EXACTLY
|
|
45
|
+
// one `Text` line. If we fell through to the full renderer whenever progress.status
|
|
46
|
+
// was missing (pre-progress partial updates), the card would flip 1-line ↔ N-line
|
|
47
|
+
// every few frames mid-stream — same physical-row-stacking pathology that ghosted
|
|
48
|
+
// the overlay rows before the run-tracker newline fix.
|
|
49
|
+
//
|
|
50
|
+
// Terminal state: exitCode or stopReason is present on the last SingleResult AND
|
|
51
|
+
// progress.status is not in the running set. Anything else is treated as running,
|
|
52
|
+
// including "no progress yet" (status === undefined) and the first tool_execution_*
|
|
53
|
+
// frames before pi-subagents has stamped progress.
|
|
54
|
+
function isTerminal(r: ResultLike | undefined): boolean {
|
|
55
|
+
if (!r) return false;
|
|
56
|
+
const status = r.progress?.status;
|
|
57
|
+
if (status === "pending" || status === "running") return false;
|
|
58
|
+
return r.exitCode != null || r.stopReason != null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildQuietRenderResult(): (
|
|
43
62
|
result: { details?: DetailsLike; content?: Array<{ type: string; text?: string }> },
|
|
44
63
|
options: { expanded: boolean },
|
|
45
64
|
theme: Theme,
|
|
46
65
|
) => unknown {
|
|
47
66
|
return (result, options, theme) => {
|
|
48
67
|
const r = result.details?.results?.[0];
|
|
49
|
-
|
|
50
|
-
if (status !== "pending" && status !== "running") {
|
|
68
|
+
if (isTerminal(r)) {
|
|
51
69
|
return renderSubagentResult(result, options, theme);
|
|
52
70
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
// overlays.
|
|
71
|
+
// Non-terminal → single-line status. Renders under the tool-call header,
|
|
72
|
+
// glyph at column 0 to align with "subagent <agent>" above. Glyphs mirror
|
|
73
|
+
// packages/rpiv-todo/todo-overlay.ts:28-35 for consistent status language.
|
|
74
|
+
const status = r?.progress?.status ?? "running";
|
|
58
75
|
const glyph = status === "pending" ? theme.fg("dim", "○") : theme.fg("warning", "◐");
|
|
59
|
-
// Glyph at column 0 — aligned directly under the "s" of "subagent"
|
|
60
|
-
// in the renderCall line above (pi prints that at column 0 too).
|
|
61
76
|
return new Text(`${glyph} ${theme.fg("muted", status)}`, 0, 0);
|
|
62
77
|
};
|
|
63
78
|
}
|
|
@@ -199,3 +199,53 @@ describe("run-tracker __resetState", () => {
|
|
|
199
199
|
expect(hasAnyVisible()).toBe(false);
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("run-tracker newline sanitization", () => {
|
|
204
|
+
it("collapses embedded newlines in single-mode task descriptions", () => {
|
|
205
|
+
__resetState();
|
|
206
|
+
onStart("t1", {
|
|
207
|
+
agent: "peer-comparator",
|
|
208
|
+
task: "Peer-mirror check.\n\nPeerPairs (orchestrator-computed):\n[list of tuples]\n\nFor each pair, Read BOTH files.",
|
|
209
|
+
});
|
|
210
|
+
const [run] = listRuns();
|
|
211
|
+
expect(run.description).not.toMatch(/[\r\n]/);
|
|
212
|
+
expect(run.description.startsWith("Peer-mirror check.")).toBe(true);
|
|
213
|
+
expect(run.description).toContain("PeerPairs (orchestrator-computed):");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("collapses newlines in displayName too (defensive)", () => {
|
|
217
|
+
__resetState();
|
|
218
|
+
onStart("t1", { agent: "weird\nname", task: "x" });
|
|
219
|
+
const [run] = listRuns();
|
|
220
|
+
expect(run.displayName).not.toMatch(/[\r\n]/);
|
|
221
|
+
expect(run.displayName).toBe("weird name");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("sanitizes errorMessage on terminal state", () => {
|
|
225
|
+
__resetState();
|
|
226
|
+
onStart("t1", { agent: "x", task: "t" });
|
|
227
|
+
onEnd(
|
|
228
|
+
"t1",
|
|
229
|
+
{
|
|
230
|
+
details: makeDetails("single", [
|
|
231
|
+
makeResult({
|
|
232
|
+
exitCode: 1,
|
|
233
|
+
stopReason: "error",
|
|
234
|
+
errorMessage: "boom\nat foo\nat bar",
|
|
235
|
+
}),
|
|
236
|
+
]),
|
|
237
|
+
},
|
|
238
|
+
true,
|
|
239
|
+
);
|
|
240
|
+
const [run] = listRuns();
|
|
241
|
+
expect(run.errorMessage).not.toMatch(/[\r\n]/);
|
|
242
|
+
expect(run.errorMessage).toBe("boom at foo at bar");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("preserves single-line descriptions unchanged", () => {
|
|
246
|
+
__resetState();
|
|
247
|
+
onStart("t1", { agent: "scout", task: "probe auth module" });
|
|
248
|
+
const [run] = listRuns();
|
|
249
|
+
expect(run.description).toBe("probe auth module");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -21,6 +21,13 @@ interface SubagentArgs {
|
|
|
21
21
|
chain?: Array<{ agent: string; task: string }>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Multi-line skill prompts (e.g. peer-comparator with `PeerPairs (orchestrator-computed):` inlined)
|
|
25
|
+
// must not ship embedded newlines into the widget's string[] output — pi-tui tracks logical lines
|
|
26
|
+
// but the terminal splits on \n into physical rows it can't clear, leaving stale duplicate blocks.
|
|
27
|
+
function oneLine(s: string): string {
|
|
28
|
+
return s.replace(/\s*[\r\n]+\s*/g, " ").trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
function inferMode(args: SubagentArgs): RunMode {
|
|
25
32
|
if (args.chain && args.chain.length > 0) return "chain";
|
|
26
33
|
if (args.tasks && args.tasks.length > 0) return "parallel";
|
|
@@ -44,17 +51,17 @@ function deriveDisplay(args: SubagentArgs, mode: RunMode): { displayName: string
|
|
|
44
51
|
const steps = args.chain ?? [];
|
|
45
52
|
return {
|
|
46
53
|
displayName: `chain (${steps.length} steps)`,
|
|
47
|
-
description: steps.map((s) => s.agent).join(" → "),
|
|
54
|
+
description: oneLine(steps.map((s) => s.agent).join(" → ")),
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
if (mode === "parallel") {
|
|
51
58
|
const tasks = args.tasks ?? [];
|
|
52
59
|
return {
|
|
53
60
|
displayName: `parallel (${tasks.length} tasks)`,
|
|
54
|
-
description: tasks[0]?.agent ?? "",
|
|
61
|
+
description: oneLine(tasks[0]?.agent ?? ""),
|
|
55
62
|
};
|
|
56
63
|
}
|
|
57
|
-
return { displayName: args.agent ?? "subagent", description: args.task ?? "" };
|
|
64
|
+
return { displayName: oneLine(args.agent ?? "subagent"), description: oneLine(args.task ?? "") };
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
function deriveTerminalStatus(isError: boolean, results: readonly SingleResult[]): RunStatus {
|
|
@@ -107,7 +114,8 @@ export function onEnd(toolCallId: string, result: { details?: SubagentDetails }
|
|
|
107
114
|
run.completedAt = Date.now();
|
|
108
115
|
run.status = deriveTerminalStatus(isError, run.results);
|
|
109
116
|
const last = run.results[run.results.length - 1];
|
|
110
|
-
|
|
117
|
+
const rawErr = last?.errorMessage || (isError ? last?.stderr : undefined) || undefined;
|
|
118
|
+
run.errorMessage = rawErr ? oneLine(rawErr) : undefined;
|
|
111
119
|
finishedAge.set(toolCallId, 0);
|
|
112
120
|
}
|
|
113
121
|
|
|
@@ -220,3 +220,49 @@ describe("SubagentWidget render — invalidate", () => {
|
|
|
220
220
|
expect(captured.length).toBe(2);
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
describe("SubagentWidget render — newline safety", () => {
|
|
225
|
+
it("never emits embedded newlines even with multi-line tasks (running)", () => {
|
|
226
|
+
onStart("t1", {
|
|
227
|
+
agent: "peer-comparator",
|
|
228
|
+
task: "Peer-mirror check.\n\nPeerPairs (orchestrator-computed):\n[list of (new_file, peer_file) tuples]\n\nFor each pair, Read BOTH files in full.",
|
|
229
|
+
});
|
|
230
|
+
onUpdate("t1", makeDetails("single", [makeResult()]));
|
|
231
|
+
const { ctx, captured } = makeUICtx();
|
|
232
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
expect(line).not.toMatch(/[\r\n]/);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("never emits embedded newlines after the run completes (finished line)", () => {
|
|
239
|
+
onStart("t1", {
|
|
240
|
+
agent: "peer-comparator",
|
|
241
|
+
task: "Peer-mirror check.\n\nPeerPairs (orchestrator-computed):\n[list]",
|
|
242
|
+
});
|
|
243
|
+
onEnd("t1", { details: makeDetails("single", [makeResult()]) }, false);
|
|
244
|
+
const { ctx, captured } = makeUICtx();
|
|
245
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
expect(line).not.toMatch(/[\r\n]/);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("never emits embedded newlines in error trail", () => {
|
|
252
|
+
onStart("t1", { agent: "scout", task: "x" });
|
|
253
|
+
onEnd(
|
|
254
|
+
"t1",
|
|
255
|
+
{
|
|
256
|
+
details: makeDetails("single", [
|
|
257
|
+
makeResult({ exitCode: 1, stopReason: "error", errorMessage: "boom\nstack\nmore" }),
|
|
258
|
+
]),
|
|
259
|
+
},
|
|
260
|
+
true,
|
|
261
|
+
);
|
|
262
|
+
const { ctx, captured } = makeUICtx();
|
|
263
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
expect(line).not.toMatch(/[\r\n]/);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -21,6 +21,10 @@ import type { AgentProgress, SingleResult, TrackedRun } from "./types.js";
|
|
|
21
21
|
// Strip SGR ANSI escapes to measure visible width for layout math.
|
|
22
22
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
23
23
|
const visibleLen = (s: string): number => s.replace(ANSI_RE, "").length;
|
|
24
|
+
// Defensive: any \n in a returned string[] element splits into physical rows pi-tui can't
|
|
25
|
+
// track, leaving stale artifacts every frame. Run-tracker sanitizes at ingest; this guard
|
|
26
|
+
// covers any future caller that mutates description/displayName directly on the tracked run.
|
|
27
|
+
const oneLine = (s: string): string => s.replace(/\s*[\r\n]+\s*/g, " ");
|
|
24
28
|
|
|
25
29
|
export class SubagentWidget {
|
|
26
30
|
private uiCtx: ExtensionUIContext | undefined;
|
|
@@ -187,10 +191,10 @@ export class SubagentWidget {
|
|
|
187
191
|
const total = run.results.length;
|
|
188
192
|
descriptor = total > 0 ? `${done}/${total} done` : "starting";
|
|
189
193
|
} else {
|
|
190
|
-
descriptor = run.description;
|
|
194
|
+
descriptor = oneLine(run.description);
|
|
191
195
|
}
|
|
192
196
|
|
|
193
|
-
const prefix = `${theme.fg("dim", "├─")} ${theme.fg("accent", frame)} ${theme.bold(run.displayName)}`;
|
|
197
|
+
const prefix = `${theme.fg("dim", "├─")} ${theme.fg("accent", frame)} ${theme.bold(oneLine(run.displayName))}`;
|
|
194
198
|
const tail = `${theme.fg("dim", "·")} ${theme.fg("dim", stats)}`;
|
|
195
199
|
// Overhead: " " between prefix+descriptor, " " between descriptor+tail.
|
|
196
200
|
const budget = Math.max(0, width - visibleLen(prefix) - 2 - 1 - visibleLen(tail));
|
|
@@ -221,11 +225,11 @@ export class SubagentWidget {
|
|
|
221
225
|
trail = theme.fg("warning", " aborted");
|
|
222
226
|
} else {
|
|
223
227
|
icon = theme.fg("error", "✗");
|
|
224
|
-
const msg = run.errorMessage ? `: ${run.errorMessage.slice(0, 60)}` : "";
|
|
228
|
+
const msg = run.errorMessage ? `: ${oneLine(run.errorMessage).slice(0, 60)}` : "";
|
|
225
229
|
trail = theme.fg("error", ` error${msg}`);
|
|
226
230
|
}
|
|
227
231
|
const body =
|
|
228
|
-
`${icon} ${theme.fg("dim", run.displayName)} ${theme.fg("dim", run.description)} ` +
|
|
232
|
+
`${icon} ${theme.fg("dim", oneLine(run.displayName))} ${theme.fg("dim", oneLine(run.description))} ` +
|
|
229
233
|
`${theme.fg("dim", "·")} ${theme.fg("dim", stats)}${trail}`;
|
|
230
234
|
return truncate(`${theme.fg("dim", "├─")} ${body}`);
|
|
231
235
|
}
|