@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.
@@ -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
- export const COMPLETED_LINGER_TURNS = 1;
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 = 2;
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 evicts lingering finished runs on the next user turn", async () => {
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
- await inputHandler({ type: "input", text: "next", source: "interactive" } as any, makeCtx(true));
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
- // User-turn boundary: advance linger ages on user input, NOT on "turn_start".
53
- // Pi fires turn_start per agent-loop iteration (after each tool result,
54
- // before the next assistant call), which would evict completed runs
55
- // before the user sees them. "input" fires only on user-originated
56
- // messages the correct semantics for "persists until next user turn".
57
- pi.on("input", async () => {
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("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
- );
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 ONE Text line when progress.status === 'pending'", () => {
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: "pending" } }] } },
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("emits ONE Text line when progress is MISSING (pre-progress partial update)", () => {
80
+ it("returns zero-height stub when progress is MISSING (pre-progress first frame)", () => {
81
+ renderSubagentResultMock.mockClear();
46
82
  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());
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("emits ONE Text line when result.details itself is missing (very first frame)", () => {
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 exitCode lands (terminal state)", () => {
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 override for the nicobailon subagent tool.
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 (current tool,
7
- * recent output, recent tools, token line) that re-flows the inline
8
- * tool-call card on every frame visible flicker. Our `aboveEditor`
9
- * overlay (widget.ts) is the authoritative live view; the inline card
10
- * should be static while running, full at completion.
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 its `renderResult` with ours. All other tools +
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
- // 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
+ // 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
- if (isTerminal(r)) {
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 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";
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 its way into the
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
- it("evicts completed runs on the first turn boundary", () => {
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
- const evicted = onTurnStart();
156
- expect(evicted).toBe(true);
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
- it("keeps error runs through two turn boundaries", () => {
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(3);
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
- expect(lines.length).toBeLessThanOrEqual(12);
192
- const footer = lines[lines.length - 1];
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
- expect(lines[lines.length - 1]).toContain("└─");
205
- expect(lines[lines.length - 1]).not.toMatch(/^├─/);
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
- const truncate = (line: string) => truncateToWidth(line, width);
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, width));
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
- // Mirrors tintinweb/pi-subagents agent-widget.ts but with width-aware
178
- // descriptor truncation so stats stay visible on narrow terminals
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
- // Overhead: " " between prefix+descriptor, " " between descriptor+tail.
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-pi",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Skill-based development workflow for Pi Agent — discover, research, design, plan, implement, validate",
5
5
  "keywords": [
6
6
  "pi-package",