@juicesharp/rpiv-pi 0.12.2 → 0.12.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/extensions/subagent-widget/constants.ts +11 -3
- package/extensions/subagent-widget/index.test.ts +48 -2
- package/extensions/subagent-widget/index.ts +19 -7
- package/extensions/subagent-widget/renderer-override.test.ts +79 -28
- package/extensions/subagent-widget/renderer-override.ts +86 -31
- package/extensions/subagent-widget/run-tracker.test.ts +13 -6
- package/extensions/subagent-widget/run-tracker.ts +16 -0
- package/extensions/subagent-widget/widget.render.test.ts +70 -7
- package/extensions/subagent-widget/widget.ts +22 -20
- package/package.json +1 -1
|
@@ -16,11 +16,19 @@ export const ERROR_STATUSES: ReadonlySet<ErrorStatus> = new Set<ErrorStatus>([
|
|
|
16
16
|
"stopped",
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
/** How many turns a completed run lingers before it drops from the tree.
|
|
20
|
-
|
|
19
|
+
/** How many turns a completed run lingers before it drops from the tree.
|
|
20
|
+
* Advanced by both user-input boundaries (`pi.on("input")`) and orchestrator
|
|
21
|
+
* agent-loop iterations (`pi.on("turn_start")`), so completed runs stay visible
|
|
22
|
+
* for ~3 orchestrator turns after the last agent finishes, then auto-evict. */
|
|
23
|
+
export const COMPLETED_LINGER_TURNS = 3;
|
|
21
24
|
|
|
22
25
|
/** How many turns an error/aborted/steered/stopped run lingers. */
|
|
23
|
-
export const ERROR_LINGER_TURNS =
|
|
26
|
+
export const ERROR_LINGER_TURNS = 5;
|
|
24
27
|
|
|
25
28
|
/** Spinner animation tick in ms. TUI's 16 ms render coalescing absorbs this. */
|
|
26
29
|
export const TICK_MS = 80;
|
|
30
|
+
|
|
31
|
+
/** Max visible characters of the descriptor column (task text). Applied
|
|
32
|
+
* identically to running + finished rows so the stats tail is never
|
|
33
|
+
* truncation-clipped off the right edge regardless of terminal width. */
|
|
34
|
+
export const MAX_DESCRIPTOR_CHARS = 40;
|
|
@@ -213,12 +213,13 @@ describe("subagent-widget extension factory", () => {
|
|
|
213
213
|
expect(listRuns()).toHaveLength(0);
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("input
|
|
216
|
+
it("input + turn_start both advance linger ages; evicts after COMPLETED_LINGER_TURNS boundaries", async () => {
|
|
217
217
|
const pi = makePi();
|
|
218
218
|
await initExtension(pi);
|
|
219
219
|
const startHandler = pi.handlers.get("tool_execution_start")!;
|
|
220
220
|
const endHandler = pi.handlers.get("tool_execution_end")!;
|
|
221
221
|
const inputHandler = pi.handlers.get("input")!;
|
|
222
|
+
const turnStartHandler = pi.handlers.get("turn_start")!;
|
|
222
223
|
await startHandler(
|
|
223
224
|
{
|
|
224
225
|
type: "tool_execution_start",
|
|
@@ -239,7 +240,52 @@ describe("subagent-widget extension factory", () => {
|
|
|
239
240
|
makeCtx(true),
|
|
240
241
|
);
|
|
241
242
|
expect(listRuns()).toHaveLength(1);
|
|
242
|
-
|
|
243
|
+
// COMPLETED_LINGER_TURNS = 3 — takes 3 turn boundaries to evict.
|
|
244
|
+
await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 1
|
|
245
|
+
await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 2
|
|
246
|
+
expect(listRuns()).toHaveLength(1);
|
|
247
|
+
await inputHandler({ type: "input", text: "next", source: "interactive" } as any, makeCtx(true)); // age 3 → evicted
|
|
243
248
|
expect(listRuns()).toHaveLength(0);
|
|
244
249
|
});
|
|
250
|
+
|
|
251
|
+
it("purges finished runs when a new wave starts (tool_execution_start with no active runs)", async () => {
|
|
252
|
+
const pi = makePi();
|
|
253
|
+
await initExtension(pi);
|
|
254
|
+
const startHandler = pi.handlers.get("tool_execution_start")!;
|
|
255
|
+
const endHandler = pi.handlers.get("tool_execution_end")!;
|
|
256
|
+
// Wave 1: dispatch + complete.
|
|
257
|
+
await startHandler(
|
|
258
|
+
{
|
|
259
|
+
type: "tool_execution_start",
|
|
260
|
+
toolCallId: "t1",
|
|
261
|
+
toolName: "subagent",
|
|
262
|
+
args: { agent: "scout", task: "x" },
|
|
263
|
+
},
|
|
264
|
+
makeCtx(true),
|
|
265
|
+
);
|
|
266
|
+
await endHandler(
|
|
267
|
+
{
|
|
268
|
+
type: "tool_execution_end",
|
|
269
|
+
toolCallId: "t1",
|
|
270
|
+
toolName: "subagent",
|
|
271
|
+
result: { details: { mode: "single", agentScope: "user", projectAgentsDir: null, results: [] } },
|
|
272
|
+
isError: false,
|
|
273
|
+
},
|
|
274
|
+
makeCtx(true),
|
|
275
|
+
);
|
|
276
|
+
expect(listRuns()).toHaveLength(1);
|
|
277
|
+
// Wave 2: new dispatch while wave 1 is still lingering → wave 1 purged first.
|
|
278
|
+
await startHandler(
|
|
279
|
+
{
|
|
280
|
+
type: "tool_execution_start",
|
|
281
|
+
toolCallId: "t2",
|
|
282
|
+
toolName: "subagent",
|
|
283
|
+
args: { agent: "worker", task: "y" },
|
|
284
|
+
},
|
|
285
|
+
makeCtx(true),
|
|
286
|
+
);
|
|
287
|
+
const runs = listRuns();
|
|
288
|
+
expect(runs).toHaveLength(1);
|
|
289
|
+
expect(runs[0].toolCallId).toBe("t2");
|
|
290
|
+
});
|
|
245
291
|
});
|
|
@@ -49,15 +49,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
49
49
|
tracker.__resetState();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// before the
|
|
56
|
-
//
|
|
57
|
-
|
|
52
|
+
// Turn-boundary eviction: advance linger ages on BOTH user input and
|
|
53
|
+
// orchestrator agent-loop iterations. `input` fires on user-originated
|
|
54
|
+
// messages; `turn_start` fires per agent-loop iteration (after each tool
|
|
55
|
+
// result, before the next assistant call). Together with the bumped
|
|
56
|
+
// `COMPLETED_LINGER_TURNS=3` budget in constants.ts, completed rows stay
|
|
57
|
+
// visible long enough for the user to see, then auto-evict across
|
|
58
|
+
// ~3 orchestrator turns — no more "overlay sticks around forever" when
|
|
59
|
+
// the user doesn't immediately type back.
|
|
60
|
+
const advanceTurn = () => {
|
|
58
61
|
const evicted = tracker.onTurnStart();
|
|
59
62
|
if (evicted) widget?.update();
|
|
60
|
-
}
|
|
63
|
+
};
|
|
64
|
+
pi.on("input", async () => advanceTurn());
|
|
65
|
+
pi.on("turn_start", async () => advanceTurn());
|
|
61
66
|
|
|
62
67
|
// Background dispatches (args.async === true per pi-subagents@0.17.5 schema)
|
|
63
68
|
// return a job handle in ~100ms. Tracking them produces a misleading
|
|
@@ -70,6 +75,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
70
75
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
71
76
|
if (event.toolName !== SUBAGENT_TOOL) return;
|
|
72
77
|
if (isAsyncDispatch(event.args)) return;
|
|
78
|
+
// Wave-boundary purge: if no runs are currently active and we still
|
|
79
|
+
// have finished rows lingering from a prior wave, drop them before
|
|
80
|
+
// the new run appears. Prevents "new wave appends under yesterday's
|
|
81
|
+
// ✓ lines" when waves dispatch back-to-back without a user turn.
|
|
82
|
+
if (tracker.runningCount() === 0 && tracker.hasAnyVisible()) {
|
|
83
|
+
tracker.purgeFinished();
|
|
84
|
+
}
|
|
73
85
|
tracker.onStart(event.toolCallId, event.args);
|
|
74
86
|
if (ctx.hasUI) {
|
|
75
87
|
widget ??= new SubagentWidget();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
2
|
+
import { Container, Text } from "@mariozechner/pi-tui";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
|
|
5
5
|
// Stub the full renderer so the terminal-state branch returns a recognisable sentinel
|
|
@@ -11,7 +11,7 @@ const { renderSubagentResultMock } = vi.hoisted(() => ({
|
|
|
11
11
|
}));
|
|
12
12
|
vi.mock("pi-subagents/render", () => ({ renderSubagentResult: renderSubagentResultMock }));
|
|
13
13
|
|
|
14
|
-
import { buildQuietRenderResult } from "./renderer-override.js";
|
|
14
|
+
import { buildQuietRenderCall, buildQuietRenderResult } from "./renderer-override.js";
|
|
15
15
|
|
|
16
16
|
function makeTheme(): Theme {
|
|
17
17
|
return {
|
|
@@ -20,63 +20,115 @@ function makeTheme(): Theme {
|
|
|
20
20
|
} as unknown as Theme;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
describe("
|
|
24
|
-
it("
|
|
25
|
-
const render =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{ expanded: false },
|
|
29
|
-
makeTheme(),
|
|
30
|
-
);
|
|
23
|
+
describe("buildQuietRenderCall — layout-stable status trailer from first frame", () => {
|
|
24
|
+
it("composes original call + status trailer when no original is provided", () => {
|
|
25
|
+
const render = buildQuietRenderCall(undefined);
|
|
26
|
+
// No original renderCall → just the trailer.
|
|
27
|
+
const out = render({}, makeTheme(), { executionStarted: false, state: {} });
|
|
31
28
|
expect(out).toBeInstanceOf(Text);
|
|
32
|
-
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
33
29
|
});
|
|
34
30
|
|
|
35
|
-
it("emits
|
|
31
|
+
it("emits pending glyph when !executionStarted (before markExecutionStarted)", () => {
|
|
32
|
+
const render = buildQuietRenderCall(undefined);
|
|
33
|
+
const out = render({}, makeTheme(), { executionStarted: false, state: {} }) as Text;
|
|
34
|
+
// Our stubbed theme.fg is identity, so Text content includes the literal glyph + label.
|
|
35
|
+
const text = (out as unknown as { text?: string }).text ?? "";
|
|
36
|
+
expect(text).toContain("○");
|
|
37
|
+
expect(text).toContain("pending");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("emits running glyph when executionStarted === true", () => {
|
|
41
|
+
const render = buildQuietRenderCall(undefined);
|
|
42
|
+
const out = render({}, makeTheme(), { executionStarted: true, state: {} }) as Text;
|
|
43
|
+
const text = (out as unknown as { text?: string }).text ?? "";
|
|
44
|
+
expect(text).toContain("◐");
|
|
45
|
+
expect(text).toContain("running");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("wraps original call + trailer in a Container when both are present", () => {
|
|
49
|
+
const originalCall = vi.fn(() => new Text("subagent peer-comparator", 0, 0));
|
|
50
|
+
const render = buildQuietRenderCall(originalCall);
|
|
51
|
+
const out = render({}, makeTheme(), { executionStarted: true, state: {} });
|
|
52
|
+
expect(out).toBeInstanceOf(Container);
|
|
53
|
+
expect(originalCall).toHaveBeenCalledOnce();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("suppresses trailer once state.subagentTerminal is set (final frame)", () => {
|
|
57
|
+
const originalCall = vi.fn(() => new Text("subagent peer-comparator", 0, 0));
|
|
58
|
+
const render = buildQuietRenderCall(originalCall);
|
|
59
|
+
const out = render({}, makeTheme(), { executionStarted: true, state: { subagentTerminal: true } });
|
|
60
|
+
// Should return the original call as-is, no Container wrapping with trailer.
|
|
61
|
+
expect(out).toBeInstanceOf(Text);
|
|
62
|
+
expect(originalCall).toHaveBeenCalledOnce();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("buildQuietRenderResult — non-terminal stub + terminal delegation", () => {
|
|
67
|
+
it("returns zero-height stub while progress.status === 'running' (renderCall owns trailer)", () => {
|
|
68
|
+
renderSubagentResultMock.mockClear();
|
|
36
69
|
const render = buildQuietRenderResult();
|
|
37
70
|
const out = render(
|
|
38
|
-
{ details: { results: [{ agent: "x", progress: { status: "
|
|
39
|
-
{ expanded: false },
|
|
71
|
+
{ details: { results: [{ agent: "x", progress: { status: "running" } }] } },
|
|
72
|
+
{ expanded: false, isPartial: true },
|
|
40
73
|
makeTheme(),
|
|
74
|
+
{ state: {} },
|
|
41
75
|
);
|
|
42
76
|
expect(out).toBeInstanceOf(Text);
|
|
77
|
+
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
43
78
|
});
|
|
44
79
|
|
|
45
|
-
it("
|
|
80
|
+
it("returns zero-height stub when progress is MISSING (pre-progress first frame)", () => {
|
|
81
|
+
renderSubagentResultMock.mockClear();
|
|
46
82
|
const render = buildQuietRenderResult();
|
|
47
|
-
|
|
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());
|
|
83
|
+
const out = render({ details: { results: [{ agent: "x" }] } }, { expanded: false }, makeTheme(), { state: {} });
|
|
51
84
|
expect(out).toBeInstanceOf(Text);
|
|
52
85
|
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
53
86
|
});
|
|
54
87
|
|
|
55
|
-
it("
|
|
88
|
+
it("returns zero-height stub when result.details is missing entirely", () => {
|
|
89
|
+
renderSubagentResultMock.mockClear();
|
|
56
90
|
const render = buildQuietRenderResult();
|
|
57
|
-
const out = render({}, { expanded: false }, makeTheme());
|
|
91
|
+
const out = render({}, { expanded: false }, makeTheme(), { state: {} });
|
|
58
92
|
expect(out).toBeInstanceOf(Text);
|
|
59
93
|
});
|
|
60
94
|
|
|
61
|
-
it("delegates to full renderer once
|
|
95
|
+
it("delegates to full renderer once terminal AND isPartial === false", () => {
|
|
62
96
|
renderSubagentResultMock.mockClear();
|
|
97
|
+
const state: { subagentTerminal?: boolean } = {};
|
|
63
98
|
const render = buildQuietRenderResult();
|
|
64
99
|
const out = render(
|
|
65
100
|
{ details: { results: [{ agent: "x", exitCode: 0, progress: { status: "complete" } }] } },
|
|
66
|
-
{ expanded: false },
|
|
101
|
+
{ expanded: false, isPartial: false },
|
|
67
102
|
makeTheme(),
|
|
103
|
+
{ state },
|
|
68
104
|
);
|
|
69
105
|
expect(renderSubagentResultMock).toHaveBeenCalledOnce();
|
|
70
106
|
expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
|
|
107
|
+
// State flag is set so renderCall suppresses its trailer next frame.
|
|
108
|
+
expect(state.subagentTerminal).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps stub when terminal but isPartial === true (don't commit until final)", () => {
|
|
112
|
+
renderSubagentResultMock.mockClear();
|
|
113
|
+
const render = buildQuietRenderResult();
|
|
114
|
+
const out = render(
|
|
115
|
+
{ details: { results: [{ agent: "x", exitCode: 0 }] } },
|
|
116
|
+
{ expanded: false, isPartial: true },
|
|
117
|
+
makeTheme(),
|
|
118
|
+
{ state: {} },
|
|
119
|
+
);
|
|
120
|
+
expect(out).toBeInstanceOf(Text);
|
|
121
|
+
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
71
122
|
});
|
|
72
123
|
|
|
73
|
-
it("delegates to full renderer on error stopReason", () => {
|
|
124
|
+
it("delegates to full renderer on error stopReason (terminal)", () => {
|
|
74
125
|
renderSubagentResultMock.mockClear();
|
|
75
126
|
const render = buildQuietRenderResult();
|
|
76
127
|
const out = render(
|
|
77
128
|
{ details: { results: [{ agent: "x", stopReason: "error" }] } },
|
|
78
|
-
{ expanded: false },
|
|
129
|
+
{ expanded: false, isPartial: false },
|
|
79
130
|
makeTheme(),
|
|
131
|
+
{ state: {} },
|
|
80
132
|
);
|
|
81
133
|
expect(renderSubagentResultMock).toHaveBeenCalledOnce();
|
|
82
134
|
expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
|
|
@@ -85,12 +137,11 @@ describe("buildQuietRenderResult — layout-stable quiet card", () => {
|
|
|
85
137
|
it("treats exitCode present + status=running as NON-terminal (streaming finalisation window)", () => {
|
|
86
138
|
renderSubagentResultMock.mockClear();
|
|
87
139
|
const render = buildQuietRenderResult();
|
|
88
|
-
// Mid-transition frames can have exitCode set before progress.status
|
|
89
|
-
// leaves "running". Should stay quiet until status clears.
|
|
90
140
|
const out = render(
|
|
91
141
|
{ details: { results: [{ agent: "x", exitCode: 0, progress: { status: "running" } }] } },
|
|
92
|
-
{ expanded: false },
|
|
142
|
+
{ expanded: false, isPartial: true },
|
|
93
143
|
makeTheme(),
|
|
144
|
+
{ state: {} },
|
|
94
145
|
);
|
|
95
146
|
expect(out).toBeInstanceOf(Text);
|
|
96
147
|
expect(renderSubagentResultMock).not.toHaveBeenCalled();
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Quiet renderResult
|
|
2
|
+
* Quiet renderCall + renderResult overrides for the nicobailon subagent tool.
|
|
3
3
|
*
|
|
4
4
|
* Motivation: pi-coding-agent re-invokes `tool.renderResult` on every
|
|
5
5
|
* `tool_execution_update` while a subagent is streaming. Nicobailon's
|
|
6
|
-
* default renderer produces a multi-line Container
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* default renderer produces a multi-line Container that re-flows the
|
|
7
|
+
* inline tool-call card on every frame → visible flicker and row
|
|
8
|
+
* stacking. Our `aboveEditor` overlay (widget.ts) is the authoritative
|
|
9
|
+
* live view; the inline card should be layout-stable while running,
|
|
10
|
+
* then unfold to full result at completion.
|
|
11
|
+
*
|
|
12
|
+
* Layout-stability contract (see issue doc in CHANGELOG 0.12.3):
|
|
13
|
+
* - `renderCall` always appends a 1-line `◐ pending` / `◐ running`
|
|
14
|
+
* trailer below pi-subagents' own call header. So the card is 2
|
|
15
|
+
* lines from the very first paint — no more 1↔2-line oscillation.
|
|
16
|
+
* - `renderResult` emits a zero-height stub while non-terminal (the
|
|
17
|
+
* status line is owned by renderCall), then delegates to
|
|
18
|
+
* `renderSubagentResult` once the last SingleResult carries a
|
|
19
|
+
* terminal exitCode/stopReason. A shared `ctx.state.subagentTerminal`
|
|
20
|
+
* flag tells renderCall to stop emitting its trailer so we don't
|
|
21
|
+
* duplicate status text next to the full result block.
|
|
11
22
|
*
|
|
12
23
|
* Mechanism: wrap the ExtensionAPI handed to nicobailon's default
|
|
13
24
|
* export in a Proxy that intercepts `registerTool` for the "subagent"
|
|
14
|
-
* tool and swaps
|
|
15
|
-
* every other ExtensionAPI method pass through unchanged.
|
|
25
|
+
* tool and swaps both `renderCall` and `renderResult`.
|
|
16
26
|
*
|
|
17
27
|
* Deployment: settings.json must not list `"npm:pi-subagents"` — only
|
|
18
28
|
* this wrapper loads nicobailon (via `registerSubagentExtension(pi)`)
|
|
@@ -22,7 +32,7 @@
|
|
|
22
32
|
*/
|
|
23
33
|
|
|
24
34
|
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
25
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
35
|
+
import { type Component, Container, Text } from "@mariozechner/pi-tui";
|
|
26
36
|
import registerSubagentExtension from "pi-subagents";
|
|
27
37
|
import { renderSubagentResult } from "pi-subagents/render";
|
|
28
38
|
|
|
@@ -41,16 +51,9 @@ interface DetailsLike {
|
|
|
41
51
|
results?: ResultLike[];
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
+
// Terminal = last SingleResult carries a definite exitCode or stopReason AND
|
|
55
|
+
// progress.status is not explicitly running/pending. Anything else counts as
|
|
56
|
+
// non-terminal, including "no progress yet" (pre-progress partial updates).
|
|
54
57
|
function isTerminal(r: ResultLike | undefined): boolean {
|
|
55
58
|
if (!r) return false;
|
|
56
59
|
const status = r.progress?.status;
|
|
@@ -58,39 +61,91 @@ function isTerminal(r: ResultLike | undefined): boolean {
|
|
|
58
61
|
return r.exitCode != null || r.stopReason != null;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// Shared per-component state written by renderResult (terminal frame) and
|
|
65
|
+
// read by renderCall (to suppress the status trailer once the full block renders).
|
|
66
|
+
interface SharedState {
|
|
67
|
+
subagentTerminal?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Render context shape used by Pi's ToolExecutionComponent
|
|
71
|
+
// (see @mariozechner/pi-coding-agent/dist/modes/interactive/components/tool-execution.js:85).
|
|
72
|
+
// We only read the fields we need — the rest of the context is opaque.
|
|
73
|
+
interface RenderCallCtx {
|
|
74
|
+
executionStarted?: boolean;
|
|
75
|
+
state?: SharedState;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RenderResultCtx {
|
|
79
|
+
state?: SharedState;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildStatusTrailer(theme: Theme, ctx: RenderCallCtx): Text {
|
|
83
|
+
// `executionStarted` flips true once Pi fires markExecutionStarted(), i.e.
|
|
84
|
+
// the tool is actually running (not just queued with complete args).
|
|
85
|
+
const running = ctx.executionStarted === true;
|
|
86
|
+
const glyph = running ? theme.fg("warning", "◐") : theme.fg("dim", "○");
|
|
87
|
+
const label = running ? "running" : "pending";
|
|
88
|
+
return new Text(`${glyph} ${theme.fg("muted", label)}`, 0, 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Type for pi-subagents' original renderCall; we pass through to it for the
|
|
92
|
+
// call header and append our status trailer below in a Container.
|
|
93
|
+
type OriginalRenderCall = (args: unknown, theme: Theme, ctx: unknown) => Component | undefined;
|
|
94
|
+
|
|
95
|
+
export function buildQuietRenderCall(originalRenderCall: OriginalRenderCall | undefined): OriginalRenderCall {
|
|
96
|
+
return (args, theme, ctx) => {
|
|
97
|
+
const callCtx = (ctx ?? {}) as RenderCallCtx;
|
|
98
|
+
const original = originalRenderCall ? originalRenderCall(args, theme, ctx) : undefined;
|
|
99
|
+
// Terminal frame: renderResult owns the full display below the call header;
|
|
100
|
+
// no trailer here, otherwise we'd duplicate status.
|
|
101
|
+
if (callCtx.state?.subagentTerminal === true) {
|
|
102
|
+
return original ?? new Text("", 0, 0);
|
|
103
|
+
}
|
|
104
|
+
const trailer = buildStatusTrailer(theme, callCtx);
|
|
105
|
+
if (!original) return trailer;
|
|
106
|
+
const container = new Container();
|
|
107
|
+
container.addChild(original);
|
|
108
|
+
container.addChild(trailer);
|
|
109
|
+
return container;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
61
113
|
export function buildQuietRenderResult(): (
|
|
62
114
|
result: { details?: DetailsLike; content?: Array<{ type: string; text?: string }> },
|
|
63
|
-
options: { expanded: boolean },
|
|
115
|
+
options: { expanded: boolean; isPartial?: boolean },
|
|
64
116
|
theme: Theme,
|
|
117
|
+
ctx?: unknown,
|
|
65
118
|
) => unknown {
|
|
66
|
-
return (result, options, theme) => {
|
|
119
|
+
return (result, options, theme, ctx) => {
|
|
67
120
|
const r = result.details?.results?.[0];
|
|
68
|
-
|
|
121
|
+
const resultCtx = (ctx ?? {}) as RenderResultCtx;
|
|
122
|
+
if (isTerminal(r) && options.isPartial !== true) {
|
|
123
|
+
// Mark state so the NEXT renderCall invocation suppresses its trailer —
|
|
124
|
+
// prevents "◐ running" appearing above the final result block.
|
|
125
|
+
if (resultCtx.state) resultCtx.state.subagentTerminal = true;
|
|
69
126
|
return renderSubagentResult(result, options, theme);
|
|
70
127
|
}
|
|
71
|
-
// Non-terminal
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
const status = r?.progress?.status ?? "running";
|
|
75
|
-
const glyph = status === "pending" ? theme.fg("dim", "○") : theme.fg("warning", "◐");
|
|
76
|
-
return new Text(`${glyph} ${theme.fg("muted", status)}`, 0, 0);
|
|
128
|
+
// Non-terminal: status line is owned by renderCall. Return a zero-height
|
|
129
|
+
// stub so the card's total height = exactly (call header + 1 trailer).
|
|
130
|
+
return new Text("", 0, 0);
|
|
77
131
|
};
|
|
78
132
|
}
|
|
79
133
|
|
|
80
134
|
/**
|
|
81
135
|
* Invoke nicobailon's registerSubagentExtension with a proxied pi that
|
|
82
|
-
* overrides the "subagent" tool's renderResult on
|
|
83
|
-
* extension runtime. Idempotent iff called once per session.
|
|
136
|
+
* overrides the "subagent" tool's renderCall + renderResult on the way
|
|
137
|
+
* into the extension runtime. Idempotent iff called once per session.
|
|
84
138
|
*/
|
|
85
139
|
export async function registerSubagentsWithQuietRenderer(pi: ExtensionAPI): Promise<void> {
|
|
86
140
|
const quietRenderResult = buildQuietRenderResult();
|
|
87
141
|
const wrappedPi = new Proxy(pi, {
|
|
88
142
|
get(target, prop, receiver) {
|
|
89
143
|
if (prop !== "registerTool") return Reflect.get(target, prop, receiver);
|
|
90
|
-
return (tool: { name: string; renderResult?: unknown }) => {
|
|
144
|
+
return (tool: { name: string; renderCall?: unknown; renderResult?: unknown }) => {
|
|
91
145
|
if (tool.name === SUBAGENT_TOOL) {
|
|
92
146
|
return (target.registerTool as unknown as (t: unknown) => void)({
|
|
93
147
|
...tool,
|
|
148
|
+
renderCall: buildQuietRenderCall(tool.renderCall as OriginalRenderCall | undefined),
|
|
94
149
|
renderResult: quietRenderResult,
|
|
95
150
|
});
|
|
96
151
|
}
|
|
@@ -148,21 +148,28 @@ describe("run-tracker onEnd", () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
describe("run-tracker onTurnStart (turn-based linger)", () => {
|
|
151
|
-
|
|
151
|
+
// COMPLETED_LINGER_TURNS = 3 → evict on the 3rd turn boundary.
|
|
152
|
+
it("evicts completed runs after COMPLETED_LINGER_TURNS boundaries", () => {
|
|
152
153
|
onStart("t1", { agent: "scout", task: "x" });
|
|
153
154
|
onEnd("t1", { details: makeDetails("single", [makeResult()]) }, false);
|
|
154
155
|
expect(listRuns()).toHaveLength(1);
|
|
155
|
-
|
|
156
|
-
expect(
|
|
156
|
+
expect(onTurnStart()).toBe(false); // age 1
|
|
157
|
+
expect(onTurnStart()).toBe(false); // age 2
|
|
158
|
+
expect(listRuns()).toHaveLength(1);
|
|
159
|
+
expect(onTurnStart()).toBe(true); // age 3 → evicted
|
|
157
160
|
expect(listRuns()).toHaveLength(0);
|
|
158
161
|
});
|
|
159
162
|
|
|
160
|
-
|
|
163
|
+
// ERROR_LINGER_TURNS = 5 → evict on the 5th turn boundary.
|
|
164
|
+
it("keeps error runs through ERROR_LINGER_TURNS boundaries", () => {
|
|
161
165
|
onStart("t1", { agent: "scout", task: "x" });
|
|
162
166
|
onEnd("t1", { details: makeDetails("single", [makeResult({ exitCode: 1 })]) }, true);
|
|
163
|
-
expect(onTurnStart()).toBe(false);
|
|
167
|
+
expect(onTurnStart()).toBe(false); // age 1
|
|
168
|
+
expect(onTurnStart()).toBe(false); // age 2
|
|
169
|
+
expect(onTurnStart()).toBe(false); // age 3
|
|
170
|
+
expect(onTurnStart()).toBe(false); // age 4
|
|
164
171
|
expect(listRuns()).toHaveLength(1);
|
|
165
|
-
expect(onTurnStart()).toBe(true);
|
|
172
|
+
expect(onTurnStart()).toBe(true); // age 5 → evicted
|
|
166
173
|
expect(listRuns()).toHaveLength(0);
|
|
167
174
|
});
|
|
168
175
|
|
|
@@ -144,6 +144,22 @@ export function onTurnStart(): boolean {
|
|
|
144
144
|
return evicted;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Evict all finished (non-running) runs immediately. Used at wave boundaries
|
|
148
|
+
* so a fresh subagent dispatch starts with a clean overlay instead of new rows
|
|
149
|
+
* appended under lingering ✓ lines from the prior wave. Returns true iff at
|
|
150
|
+
* least one run was evicted. */
|
|
151
|
+
export function purgeFinished(): boolean {
|
|
152
|
+
let evicted = false;
|
|
153
|
+
for (const [id, run] of runs) {
|
|
154
|
+
if (run.status !== "running") {
|
|
155
|
+
runs.delete(id);
|
|
156
|
+
finishedAge.delete(id);
|
|
157
|
+
evicted = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return evicted;
|
|
161
|
+
}
|
|
162
|
+
|
|
147
163
|
/** Snapshot of currently-visible runs. Callers must not retain references. */
|
|
148
164
|
export function listRuns(): readonly TrackedRun[] {
|
|
149
165
|
return [...runs.values()];
|
|
@@ -78,15 +78,16 @@ describe("SubagentWidget render — empty", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
describe("SubagentWidget render — single", () => {
|
|
81
|
-
it("renders heading + 2-line running block", () => {
|
|
81
|
+
it("renders heading + 2-line running block + trailing blank separator", () => {
|
|
82
82
|
onStart("t1", { agent: "scout", task: "probe" });
|
|
83
83
|
onUpdate("t1", makeDetails("single", [makeResult()]));
|
|
84
84
|
const { ctx, captured } = makeUICtx();
|
|
85
85
|
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
86
|
-
expect(lines).toHaveLength(
|
|
86
|
+
expect(lines).toHaveLength(4);
|
|
87
87
|
expect(lines[0]).toContain("Subagents");
|
|
88
88
|
expect(lines[1]).toContain("scout");
|
|
89
89
|
expect(lines[2]).toContain("⎿");
|
|
90
|
+
expect(lines[3]).toBe("");
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
it("renders live tool-uses + tokens from details.progress during streaming", () => {
|
|
@@ -188,21 +189,45 @@ describe("SubagentWidget render — overflow", () => {
|
|
|
188
189
|
}
|
|
189
190
|
const { ctx, captured } = makeUICtx();
|
|
190
191
|
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// MAX_WIDGET_LINES (12) + 1 trailing blank separator.
|
|
193
|
+
expect(lines.length).toBeLessThanOrEqual(13);
|
|
194
|
+
// Last real line is the footer; appended blank separator sits below.
|
|
195
|
+
expect(lines[lines.length - 1]).toBe("");
|
|
196
|
+
const footer = lines[lines.length - 2];
|
|
193
197
|
expect(footer).toMatch(/\+\d+ more/);
|
|
194
198
|
expect(footer).toContain("running");
|
|
195
199
|
});
|
|
196
200
|
});
|
|
197
201
|
|
|
198
202
|
describe("SubagentWidget render — tail connector", () => {
|
|
199
|
-
it("swaps ├─ to └─ on the final line when nothing else follows", () => {
|
|
203
|
+
it("swaps ├─ to └─ on the final tree line when nothing else follows (trailing blank ignored)", () => {
|
|
200
204
|
onStart("t1", { agent: "scout", task: "probe" });
|
|
201
205
|
onEnd("t1", { details: makeDetails("single", [makeResult()]) }, false);
|
|
202
206
|
const { ctx, captured } = makeUICtx();
|
|
203
207
|
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
204
|
-
|
|
205
|
-
expect(lines[lines.length - 1]).
|
|
208
|
+
// Last entry is the trailing blank separator; the tree line is the one above.
|
|
209
|
+
expect(lines[lines.length - 1]).toBe("");
|
|
210
|
+
expect(lines[lines.length - 2]).toContain("└─");
|
|
211
|
+
expect(lines[lines.length - 2]).not.toMatch(/^├─/);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("SubagentWidget render — separator", () => {
|
|
216
|
+
it("appends an empty trailing line so adjacent widgets don't hug the tree", () => {
|
|
217
|
+
onStart("t1", { agent: "scout", task: "probe" });
|
|
218
|
+
onUpdate("t1", makeDetails("single", [makeResult()]));
|
|
219
|
+
const { ctx, captured } = makeUICtx();
|
|
220
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
221
|
+
expect(lines[lines.length - 1]).toBe("");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns [] (no trailing blank) when there are no tracked runs", () => {
|
|
225
|
+
const { ctx, captured } = makeUICtx();
|
|
226
|
+
const widget = new SubagentWidget();
|
|
227
|
+
widget.setUICtx(ctx);
|
|
228
|
+
widget.update();
|
|
229
|
+
const comp = captured[0].factory?.(makeTUI(), makeTheme());
|
|
230
|
+
expect(comp?.render(120)).toEqual([]);
|
|
206
231
|
});
|
|
207
232
|
});
|
|
208
233
|
|
|
@@ -266,3 +291,41 @@ describe("SubagentWidget render — newline safety", () => {
|
|
|
266
291
|
}
|
|
267
292
|
});
|
|
268
293
|
});
|
|
294
|
+
|
|
295
|
+
describe("SubagentWidget render — descriptor character cap", () => {
|
|
296
|
+
it("truncates long task descriptions with ellipsis (running row)", () => {
|
|
297
|
+
onStart("t1", {
|
|
298
|
+
agent: "peer-comparator",
|
|
299
|
+
task: "Peer-mirror check. PeerPairs (orchestrator-computed): 1. (TruVisibility.TruEcommerce.Domain/Subscriptions/PhysicalProductSubscriptionProcessService.cs)",
|
|
300
|
+
});
|
|
301
|
+
onUpdate("t1", makeDetails("single", [makeResult()]));
|
|
302
|
+
const { ctx, captured } = makeUICtx();
|
|
303
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
304
|
+
// Stats tail ("⟳…tool uses…tokens…running") must still be visible.
|
|
305
|
+
expect(lines[1]).toMatch(/running/);
|
|
306
|
+
// Capped line includes the ellipsis marker.
|
|
307
|
+
expect(lines[1]).toContain("…");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("truncates long task descriptions with ellipsis (finished row)", () => {
|
|
311
|
+
onStart("t1", {
|
|
312
|
+
agent: "peer-comparator",
|
|
313
|
+
task: "Peer-mirror check. PeerPairs (orchestrator-computed): 1. (TruVisibility.TruEcommerce.Domain/Subscriptions/PhysicalProductSubscriptionProcessService.cs)",
|
|
314
|
+
});
|
|
315
|
+
onEnd("t1", { details: makeDetails("single", [makeResult()]) }, false);
|
|
316
|
+
const { ctx, captured } = makeUICtx();
|
|
317
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
318
|
+
// Finished line includes stats (1 tool use / tokens / duration) — no `running`.
|
|
319
|
+
expect(lines[1]).not.toMatch(/running/);
|
|
320
|
+
expect(lines[1]).toContain("…");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("preserves short task descriptions without ellipsis", () => {
|
|
324
|
+
onStart("t1", { agent: "scout", task: "probe auth module" });
|
|
325
|
+
onUpdate("t1", makeDetails("single", [makeResult()]));
|
|
326
|
+
const { ctx, captured } = makeUICtx();
|
|
327
|
+
const lines = renderOnce(new SubagentWidget(), ctx, captured);
|
|
328
|
+
expect(lines[1]).toContain("probe auth module");
|
|
329
|
+
expect(lines[1]).not.toContain("…");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -14,17 +14,20 @@
|
|
|
14
14
|
import type { ExtensionUIContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
15
15
|
import { type TUI, truncateToWidth } from "@mariozechner/pi-tui";
|
|
16
16
|
import { describeActivity, formatDuration, formatTokens, formatToolUses, formatTurns } from "./activity.js";
|
|
17
|
-
import { MAX_WIDGET_LINES, SPINNER, TICK_MS, WIDGET_KEY } from "./constants.js";
|
|
17
|
+
import { MAX_DESCRIPTOR_CHARS, MAX_WIDGET_LINES, SPINNER, TICK_MS, WIDGET_KEY } from "./constants.js";
|
|
18
18
|
import { listRuns, runningCount } from "./run-tracker.js";
|
|
19
19
|
import type { AgentProgress, SingleResult, TrackedRun } from "./types.js";
|
|
20
20
|
|
|
21
|
-
// Strip SGR ANSI escapes to measure visible width for layout math.
|
|
22
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
23
|
-
const visibleLen = (s: string): number => s.replace(ANSI_RE, "").length;
|
|
24
21
|
// Defensive: any \n in a returned string[] element splits into physical rows pi-tui can't
|
|
25
22
|
// track, leaving stale artifacts every frame. Run-tracker sanitizes at ingest; this guard
|
|
26
23
|
// covers any future caller that mutates description/displayName directly on the tracked run.
|
|
27
24
|
const oneLine = (s: string): string => s.replace(/\s*[\r\n]+\s*/g, " ");
|
|
25
|
+
// Character cap on the descriptor column. Keeps stats visible on both running
|
|
26
|
+
// and finished rows regardless of terminal width — prior width-aware budget
|
|
27
|
+
// let long prompts push stats off the right edge where `truncateToWidth`
|
|
28
|
+
// silently clipped them with "...".
|
|
29
|
+
const capDescriptor = (s: string): string =>
|
|
30
|
+
s.length <= MAX_DESCRIPTOR_CHARS ? s : `${s.slice(0, MAX_DESCRIPTOR_CHARS - 1)}…`;
|
|
28
31
|
|
|
29
32
|
export class SubagentWidget {
|
|
30
33
|
private uiCtx: ExtensionUIContext | undefined;
|
|
@@ -98,7 +101,10 @@ export class SubagentWidget {
|
|
|
98
101
|
const runs = listRuns();
|
|
99
102
|
if (runs.length === 0) return [];
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
// Single-char `…` marker matches our descriptor cap (capDescriptor) and the
|
|
105
|
+
// activity-line helper (activity.ts:truncateLine). pi-tui's default is "..."
|
|
106
|
+
// which would mix two ellipsis styles inside the same widget.
|
|
107
|
+
const truncate = (line: string) => truncateToWidth(line, width, "…");
|
|
102
108
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
103
109
|
const active = runningCount() > 0;
|
|
104
110
|
|
|
@@ -110,7 +116,7 @@ export class SubagentWidget {
|
|
|
110
116
|
const finishedLines: string[] = [];
|
|
111
117
|
for (const run of runs) {
|
|
112
118
|
if (run.status === "running") {
|
|
113
|
-
runningBlocks.push(this.renderRunningBlock(run, theme, frame, truncate
|
|
119
|
+
runningBlocks.push(this.renderRunningBlock(run, theme, frame, truncate));
|
|
114
120
|
} else {
|
|
115
121
|
finishedLines.push(this.renderFinishedLine(run, theme, truncate));
|
|
116
122
|
}
|
|
@@ -124,6 +130,10 @@ export class SubagentWidget {
|
|
|
124
130
|
for (const pair of runningBlocks) lines.push(...pair);
|
|
125
131
|
lines.push(...finishedLines);
|
|
126
132
|
this.fixupLastConnector(lines, runningBlocks.length, finishedLines.length);
|
|
133
|
+
// Trailing blank separates our overlay from whatever sits below (Todos,
|
|
134
|
+
// editor, next widget). Without it our last tree row hugs the next
|
|
135
|
+
// overlay's heading row with no visual break.
|
|
136
|
+
lines.push("");
|
|
127
137
|
return lines;
|
|
128
138
|
}
|
|
129
139
|
|
|
@@ -153,6 +163,7 @@ export class SubagentWidget {
|
|
|
153
163
|
if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
|
|
154
164
|
const footer = parts.length > 0 ? `+${total} more (${parts.join(", ")})` : `+${total} more`;
|
|
155
165
|
lines.push(truncate(`${theme.fg("dim", "└─")} ${theme.fg("dim", footer)}`));
|
|
166
|
+
lines.push("");
|
|
156
167
|
return lines;
|
|
157
168
|
}
|
|
158
169
|
|
|
@@ -166,17 +177,10 @@ export class SubagentWidget {
|
|
|
166
177
|
}
|
|
167
178
|
}
|
|
168
179
|
|
|
169
|
-
private renderRunningBlock(
|
|
170
|
-
run: TrackedRun,
|
|
171
|
-
theme: Theme,
|
|
172
|
-
frame: string,
|
|
173
|
-
truncate: (s: string) => string,
|
|
174
|
-
width: number,
|
|
175
|
-
): string[] {
|
|
180
|
+
private renderRunningBlock(run: TrackedRun, theme: Theme, frame: string, truncate: (s: string) => string): string[] {
|
|
176
181
|
// Layout: `├─ {frame} {bold(name)} {muted(descriptor)} · {dim(stats)}`.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// (naive truncateToWidth would cut the rightmost stats tail first).
|
|
182
|
+
// Descriptor capped by `MAX_DESCRIPTOR_CHARS` so the stats tail is never
|
|
183
|
+
// clipped off the right edge by `truncateToWidth`.
|
|
180
184
|
const last = run.results[run.results.length - 1];
|
|
181
185
|
const progress = run.progress?.[run.progress.length - 1];
|
|
182
186
|
const stats = this.buildStats(run);
|
|
@@ -196,9 +200,7 @@ export class SubagentWidget {
|
|
|
196
200
|
|
|
197
201
|
const prefix = `${theme.fg("dim", "├─")} ${theme.fg("accent", frame)} ${theme.bold(oneLine(run.displayName))}`;
|
|
198
202
|
const tail = `${theme.fg("dim", "·")} ${theme.fg("dim", stats)}`;
|
|
199
|
-
|
|
200
|
-
const budget = Math.max(0, width - visibleLen(prefix) - 2 - 1 - visibleLen(tail));
|
|
201
|
-
const descriptorOut = visibleLen(descriptor) > budget ? descriptor.slice(0, budget).trimEnd() : descriptor;
|
|
203
|
+
const descriptorOut = capDescriptor(descriptor);
|
|
202
204
|
const middle = descriptorOut ? ` ${theme.fg("muted", descriptorOut)} ` : " ";
|
|
203
205
|
const activity = describeActivity(last, progress);
|
|
204
206
|
return [
|
|
@@ -229,7 +231,7 @@ export class SubagentWidget {
|
|
|
229
231
|
trail = theme.fg("error", ` error${msg}`);
|
|
230
232
|
}
|
|
231
233
|
const body =
|
|
232
|
-
`${icon} ${theme.fg("dim", oneLine(run.displayName))} ${theme.fg("dim", oneLine(run.description))} ` +
|
|
234
|
+
`${icon} ${theme.fg("dim", oneLine(run.displayName))} ${theme.fg("dim", capDescriptor(oneLine(run.description)))} ` +
|
|
233
235
|
`${theme.fg("dim", "·")} ${theme.fg("dim", stats)}${trail}`;
|
|
234
236
|
return truncate(`${theme.fg("dim", "├─")} ${body}`);
|
|
235
237
|
}
|