@mystilleef/pi-subagent 0.9.0 → 0.10.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.
@@ -1,5 +1,5 @@
1
1
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
2
- import { type Message, StringEnum } from "@earendil-works/pi-ai";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
@@ -12,6 +12,12 @@ import type {
12
12
  ThinkingLevel,
13
13
  } from "../agent/agents.js";
14
14
  import { runSingleAgent, SubagentAbortError } from "../child/process.js";
15
+ import { deliverNotification } from "../notification/delivery.js";
16
+ import {
17
+ buildNotificationRequest,
18
+ isDesktopNotificationsEnabled,
19
+ isPerJobNotificationEnabled,
20
+ } from "../notification/desktop-notification.js";
15
21
  import { formatSubagentResultForParent } from "../output/summary.js";
16
22
  import {
17
23
  cancelProgressState,
@@ -22,12 +28,13 @@ import {
22
28
  } from "../progress/progress.js";
23
29
  import {
24
30
  createSubagentError,
31
+ type DetailsOptions,
25
32
  getFeedbackSummaryText,
26
33
  getLatestResult,
27
34
  getResultDisplayText,
28
- hasSubagentFailed,
29
35
  patchProgressFromDetails,
30
36
  sanitizeDetailsForDisplay,
37
+ sanitizeResultDetails,
31
38
  } from "../progress/result-details.js";
32
39
  import { generateSubagentInstanceName } from "../shared/instance-name.js";
33
40
  import type {
@@ -36,7 +43,7 @@ import type {
36
43
  SubagentDetails,
37
44
  SubagentToolResult,
38
45
  } from "../shared/types.js";
39
- import { getSubagentDepth } from "../shared/utils.js";
46
+ import { getSubagentDepth, hasSubagentFailed } from "../shared/utils.js";
40
47
  import {
41
48
  listRunJobs,
42
49
  type RunJob,
@@ -71,7 +78,6 @@ export const SubagentParams = Type.Object({
71
78
 
72
79
  export type { SubagentToolResult };
73
80
 
74
- type DetailsOptions = { includeMessages?: boolean; recentMessages?: Message[] };
75
81
  type DetailsBuilder = (
76
82
  results: SingleResult[],
77
83
  options?: DetailsOptions,
@@ -112,63 +118,6 @@ function createDetailsBuilder(
112
118
  });
113
119
  }
114
120
 
115
- function sanitizeResultDetails(
116
- result: SingleResult,
117
- includeDebugMessages: boolean,
118
- options: DetailsOptions | undefined,
119
- ): SingleResult {
120
- const includeMessages =
121
- includeDebugMessages && (options?.includeMessages ?? true);
122
- const { messages, termination, progress, stderr, usage, ...core } = result;
123
- const { contextWindowTokens, ...usageBase } = usage;
124
- const sanitized: Record<string, unknown> = {
125
- ...core,
126
- stderr: includeDebugMessages ? stderr : "",
127
- usage: { ...usageBase },
128
- };
129
- if (contextWindowTokens !== undefined) {
130
- (sanitized["usage"] as Record<string, unknown>)["contextWindowTokens"] =
131
- contextWindowTokens;
132
- }
133
- if (progress !== undefined) {
134
- const {
135
- activityText,
136
- activeToolActivity,
137
- lastToolPreview,
138
- toolResultCompleted,
139
- ...progBase
140
- } = progress;
141
- sanitized["progress"] = {
142
- toolCalls: progBase.toolCalls.map((tc) => ({
143
- id: tc.id,
144
- preview: tc.preview,
145
- })),
146
- ...(activityText !== undefined && { activityText }),
147
- ...(activeToolActivity !== undefined && { activeToolActivity }),
148
- ...(lastToolPreview !== undefined && { lastToolPreview }),
149
- ...(toolResultCompleted !== undefined && { toolResultCompleted }),
150
- };
151
- }
152
- if (includeMessages) {
153
- sanitized["messages"] = options?.recentMessages
154
- ? [...options.recentMessages]
155
- : messages !== undefined
156
- ? [...messages]
157
- : undefined;
158
- if (includeDebugMessages && termination !== undefined) {
159
- const { cancelReason, terminationSignal, fallbackCause, ...termBase } =
160
- termination;
161
- sanitized["termination"] = {
162
- ...termBase,
163
- ...(cancelReason !== undefined && { cancelReason }),
164
- ...(terminationSignal !== undefined && { terminationSignal }),
165
- ...(fallbackCause !== undefined && { fallbackCause }),
166
- };
167
- }
168
- }
169
- return sanitized as unknown as SingleResult;
170
- }
171
-
172
121
  function createProgressRenderRequester(
173
122
  ctx: ExtensionContext,
174
123
  requestId: string,
@@ -198,11 +147,26 @@ function sendSubagentResultMessage(
198
147
  });
199
148
  }
200
149
 
150
+ function deliverDesktopCompletionNotification(
151
+ state: NonNullable<ReturnType<typeof getProgressState>>,
152
+ ): void {
153
+ if (state.status === "cancelled") return;
154
+ const request = buildNotificationRequest(state);
155
+ deliverNotification(request).catch(() => {});
156
+ }
157
+
201
158
  export function emitCompletionAlert(
202
159
  state: ReturnType<typeof getProgressState>,
203
160
  ): void {
204
161
  if (!state) return;
205
162
  if (state.status === "cancelled") return;
163
+ if (
164
+ isDesktopNotificationsEnabled() &&
165
+ !isPerJobNotificationEnabled() &&
166
+ getSubagentDepth() === 0
167
+ ) {
168
+ deliverDesktopCompletionNotification(state);
169
+ }
206
170
  const tty = (process.stdout as { isTTY?: boolean }).isTTY;
207
171
  if (!tty) return;
208
172
  process.stdout.write("\x07");
@@ -272,10 +236,11 @@ function createPayloadFingerprint(payload: {
272
236
  const contentText = payload.content[0]?.text ?? "";
273
237
  const latestResult = payload.details.results[0];
274
238
  const activityText = latestResult?.progress?.activityText ?? "";
275
- const toolCallIds =
276
- [...new Set(latestResult?.progress?.toolCalls?.map((tc) => tc.id))]
277
- .sort()
278
- .join(",") ?? "";
239
+ const toolCallIds = [
240
+ ...new Set(latestResult?.progress?.toolCalls?.map((tc) => tc.id)),
241
+ ]
242
+ .sort()
243
+ .join(",");
279
244
  const exitCode = latestResult?.exitCode ?? 0;
280
245
  const stopReason = latestResult?.stopReason ?? "";
281
246
  return `${contentText}|${activityText}|${toolCallIds}|${exitCode}|${stopReason}`;
@@ -337,15 +302,25 @@ async function runSubagentLifecycle(
337
302
  clearInterval(timerTick);
338
303
  requestProgressRender();
339
304
  removeRunJob(lc.requestId);
340
- if (listRunJobs().length === 0) {
341
- const state = getProgressState(lc.requestId);
342
- if (state) {
343
- emitCompletionAlert(state);
344
- }
345
- }
305
+ const state = getProgressState(lc.requestId);
306
+ if (state) deliverLifecycleCompletionNotification(state);
346
307
  }
347
308
  }
348
309
 
310
+ function deliverLifecycleCompletionNotification(
311
+ state: NonNullable<ReturnType<typeof getProgressState>>,
312
+ ): void {
313
+ if (
314
+ isDesktopNotificationsEnabled() &&
315
+ isPerJobNotificationEnabled() &&
316
+ getSubagentDepth() === 0
317
+ ) {
318
+ deliverDesktopCompletionNotification(state);
319
+ return;
320
+ }
321
+ if (listRunJobs().length === 0) emitCompletionAlert(state);
322
+ }
323
+
349
324
  type StartJobResult =
350
325
  | {
351
326
  kind: "started";
@@ -366,8 +341,7 @@ type PrepareSubagentJobResult =
366
341
  hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails> | undefined;
367
342
  }
368
343
  | { kind: "not_found"; makeDetails: DetailsBuilder }
369
- | { kind: "cancelled"; makeDetails: DetailsBuilder }
370
- | { kind: "aborted"; makeDetails: DetailsBuilder };
344
+ | { kind: "cancelled"; makeDetails: DetailsBuilder };
371
345
 
372
346
  export function formatSubagentToolResult(
373
347
  agentName: string,
@@ -479,7 +453,7 @@ async function prepareSubagentJob(
479
453
  if (mergedSignal.aborted) {
480
454
  cancelStartedJob(job, job.cancelReason ?? "Aborted");
481
455
  requestProgressRender();
482
- return { kind: "aborted", makeDetails: makeStartedDetails };
456
+ return { kind: "cancelled", makeDetails: makeStartedDetails };
483
457
  }
484
458
  return {
485
459
  kind: "ready",
@@ -518,11 +492,7 @@ export async function startSubagentJob(
518
492
  hostSignal,
519
493
  hostOnUpdate,
520
494
  );
521
- if (prepared.kind !== "ready") {
522
- if (prepared.kind === "aborted")
523
- return { kind: "cancelled", makeDetails: prepared.makeDetails };
524
- return prepared;
525
- }
495
+ if (prepared.kind !== "ready") return prepared;
526
496
  const { lc, instanceName, requestProgressRender } = prepared;
527
497
  if (getSubagentDepth() > 0) {
528
498
  const result = await runSubagentLifecycle(lc);
@@ -1,4 +1,4 @@
1
- export const TERMINAL_SENTENCE_MAX_CHARS = 100;
1
+ const TERMINAL_SENTENCE_MAX_CHARS = 100;
2
2
 
3
3
  export function normalizeSummaryValue(value: string): string {
4
4
  const normalized = value.trim().replace(/\s+/g, " ");
@@ -67,7 +67,7 @@ export function normalizeTerminalSentence(
67
67
  return truncateText(collapsed, limit);
68
68
  }
69
69
 
70
- export const TOOL_PREVIEW_MAX_CHARS = 120;
70
+ const TOOL_PREVIEW_MAX_CHARS = 120;
71
71
 
72
72
  export function normalizeAndTruncate(
73
73
  text: string,
package/src/output/ui.ts CHANGED
@@ -11,6 +11,8 @@ import type { AgentScope } from "../agent/agents.js";
11
11
  import {
12
12
  formatContextPercent,
13
13
  formatElapsed,
14
+ } from "../progress/progress-format.js";
15
+ import {
14
16
  type ProgressStatus,
15
17
  STATUS_BG,
16
18
  STATUS_COLOR,
@@ -18,8 +20,8 @@ import {
18
20
  type SubagentProgressState,
19
21
  type ThemeBg,
20
22
  } from "../progress/progress-state.js";
21
- import { hasSubagentFailed } from "../progress/result-details.js";
22
23
  import type { SubagentDetails, UsageStats } from "../shared/types.js";
24
+ import { hasSubagentFailed } from "../shared/utils.js";
23
25
  import {
24
26
  extractSemanticToolTarget,
25
27
  normalizeSummaryValue,
@@ -115,9 +117,6 @@ export function formatResultFooter(usage: UsageStats, model?: string): string {
115
117
  return parts.join(" · ");
116
118
  }
117
119
 
118
- /**
119
- * Formats a tool call for the UI, optionally extracting a semantic target for clarity.
120
- */
121
120
  export function formatToolCall(
122
121
  toolName: string,
123
122
  args: Record<string, unknown>,
@@ -131,9 +130,6 @@ export function formatToolCall(
131
130
  return themeFg("accent", toolName) + themeFg("dim", ` ${target}`);
132
131
  }
133
132
 
134
- /**
135
- * Extracts the final text response from an array of assistant messages.
136
- */
137
133
  export function getFinalOutput(messages: Message[]): string {
138
134
  const lastAsst = messages.findLast((m) => m.role === "assistant");
139
135
  const lastText = lastAsst?.content.findLast((p) => p.type === "text");
@@ -148,9 +144,6 @@ function stripOutcomeLineForResultUi(output: string): string {
148
144
  return stripped.trim() ? stripped : output;
149
145
  }
150
146
 
151
- /**
152
- * Maps subagent theme colors to Markdown rendering components.
153
- */
154
147
  function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
155
148
  const fg = (c: ThemeColor) => (text: string) => theme.fg(c, text);
156
149
  return {
@@ -172,9 +165,6 @@ function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
172
165
  };
173
166
  }
174
167
 
175
- /**
176
- * Renders the pending subagent call UI component.
177
- */
178
168
  export function renderSubagentCall(
179
169
  args: { agent?: string; task?: string; agentScope?: AgentScope },
180
170
  theme: SubagentTheme,
@@ -205,7 +195,7 @@ export function renderSubagentToolResult(
205
195
  export function renderSubagentResult(
206
196
  result: { content: { type: string; text?: string }[]; details?: unknown },
207
197
  theme: SubagentTheme,
208
- _display?: { isPartial?: boolean },
198
+ display?: { isPartial?: boolean },
209
199
  bodyOverride?: string,
210
200
  ): Component {
211
201
  const details = result.details as SubagentDetails | undefined;
@@ -228,7 +218,7 @@ export function renderSubagentResult(
228
218
  const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
229
219
  const title = formatSubagentTitle(r.agent, r.instanceName, theme);
230
220
  let effectiveBody = bodyOverride ?? finalOutput;
231
- if (_display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
221
+ if (display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
232
222
  effectiveBody =
233
223
  result.content[0]?.text ||
234
224
  r.progress?.activityText ||
@@ -241,7 +231,7 @@ export function renderSubagentResult(
241
231
  const ctxPercent = formatContextPercent({
242
232
  contextTokens: r.usage.contextTokens,
243
233
  contextWindowTokens: r.usage.contextWindowTokens,
244
- } as SubagentProgressState);
234
+ });
245
235
  const metadata = `${toolLabel} · ${ctxPercent} ctx · ${formatElapsed(r.durationMs ?? 0)}`;
246
236
  const usageStr = formatResultFooter(r.usage, r.model);
247
237
  return renderStatusCard(
@@ -285,7 +275,7 @@ function renderStatusCard(
285
275
  : "";
286
276
  box.addChild(new Text(`${icon} ${options.title} ${status}${metadata}`, 0, 0));
287
277
  box.addChild(makeStatusCardBody(options, theme));
288
- if (options.variant === "full" && options.footer)
278
+ if (options.footer)
289
279
  box.addChild(new Text(theme.fg("dim", options.footer), 0, 0));
290
280
  return box;
291
281
  }
@@ -294,7 +284,7 @@ function makeStatusCardBody(
294
284
  options: StatusCardOptions,
295
285
  theme: SubagentTheme,
296
286
  ): Box {
297
- const body = new Box(2, options.variant === "full" ? 1 : 0);
287
+ const body = new Box(2, options.variant === "full" || options.footer ? 1 : 0);
298
288
  const bodyText = options.body ?? "";
299
289
  if (bodyText && options.variant === "full") {
300
290
  body.addChild(
@@ -336,16 +326,15 @@ function renderJobCard(
336
326
  bodyText.length > BODY_PREVIEW_MAX
337
327
  ? `${bodyText.slice(0, BODY_PREVIEW_MAX - 1)}…`
338
328
  : bodyText;
339
- return renderStatusCard(
340
- {
341
- status: state.status,
342
- title,
343
- variant: "abridged",
344
- metadata,
345
- body: preview,
346
- },
347
- theme,
348
- );
329
+ const options: StatusCardOptions = {
330
+ status: state.status,
331
+ title,
332
+ variant: "abridged",
333
+ metadata,
334
+ body: preview,
335
+ };
336
+ if (state.modelDisplay) options.footer = state.modelDisplay;
337
+ return renderStatusCard(options, theme);
349
338
  }
350
339
 
351
340
  function sortByStartTimeDesc(
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Formatting and rendering helpers for subagent progress display.
3
+ *
4
+ * Pure presentation logic extracted from progress-state.js. No state
5
+ * management or store access — only transforms data into display strings.
6
+ */
7
+
8
+ import {
9
+ normalizeAndTruncate,
10
+ normalizeSummaryValue,
11
+ truncateText,
12
+ } from "../output/normalize.js";
13
+ import type { ToolActivity } from "../shared/types.js";
14
+ import type { SubagentProgressState } from "./progress-state.js";
15
+
16
+ export const SENSITIVE_PATTERN = /secret|token|password/i;
17
+
18
+ const REDACTED_PLACEHOLDER = "(running...)";
19
+ const REDACTED_PLACEHOLDER_LENGTH = REDACTED_PLACEHOLDER.length;
20
+
21
+ function redactOrTruncate(text: string, maxChars: number): string {
22
+ if (SENSITIVE_PATTERN.test(text))
23
+ return maxChars >= REDACTED_PLACEHOLDER_LENGTH ? REDACTED_PLACEHOLDER : "";
24
+ return truncateText(text, maxChars);
25
+ }
26
+
27
+ function walkActivityTree(activity: ToolActivity): string[] {
28
+ const parts: string[] = [];
29
+ let current: ToolActivity | undefined = activity;
30
+ while (current) {
31
+ if (current.inputSummary) {
32
+ const annotated = current.instanceName
33
+ ? `${current.inputSummary} [${current.instanceName}]`
34
+ : current.inputSummary;
35
+ parts.push(annotated);
36
+ }
37
+ current = current.child;
38
+ }
39
+ return parts;
40
+ }
41
+
42
+ /**
43
+ * Format a millisecond duration for compact display.
44
+ * Renders sub-minute durations as decimal seconds (`45.2s`),
45
+ * longer durations as minutes and whole seconds (`2m 15s`).
46
+ */
47
+ export function formatElapsed(ms: number): string {
48
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
49
+ const mins = Math.floor(ms / 60000);
50
+ const secs = Math.floor((ms % 60000) / 1000);
51
+ return `${mins}m ${secs}s`;
52
+ }
53
+
54
+ export function formatContextPercent(state: {
55
+ contextTokens?: number | undefined;
56
+ contextWindowTokens?: number | undefined;
57
+ }): string {
58
+ const windowTokens = state.contextWindowTokens;
59
+ if (!windowTokens || windowTokens <= 0 || !Number.isFinite(windowTokens))
60
+ return "--%";
61
+ const usedTokens = state.contextTokens;
62
+ if (!usedTokens || usedTokens <= 0 || !Number.isFinite(usedTokens))
63
+ return "0%";
64
+ return `${Math.round((usedTokens / windowTokens) * 100)}%`;
65
+ }
66
+
67
+ /**
68
+ * Format the one-line statistics header for a subagent progress display.
69
+ * Includes tool count, context window usage, and elapsed time.
70
+ * When the subagent is still running (`durationMs` unset), elapsed is
71
+ * computed live from `startTime`.
72
+ *
73
+ * @returns Single line, e.g. `"3 tools · 45% ctx · 12.3s"`
74
+ */
75
+ export function formatHeaderStats(state: SubagentProgressState): string {
76
+ const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
77
+ const toolLabel = state.toolCount === 1 ? "tool" : "tools";
78
+ return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}`;
79
+ }
80
+
81
+ /**
82
+ * Renders a ToolActivity tree for storage. Each segment is
83
+ * independently normalized and truncated to TOOL_PREVIEW_MAX_CHARS (120).
84
+ */
85
+ export function renderToolActivity(
86
+ activity: ToolActivity | undefined,
87
+ ): string | undefined {
88
+ if (!activity) return undefined;
89
+ const parts = walkActivityTree(activity);
90
+ if (parts.length === 0) return activity.toolName;
91
+ const result = parts.map((p) => normalizeAndTruncate(p)).join(" - ");
92
+ if (SENSITIVE_PATTERN.test(result)) return REDACTED_PLACEHOLDER;
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Renders a ToolActivity tree for display with a caller-provided truncation
98
+ * budget. Segments are normalized without individual truncation so the
99
+ * joined result shares one post-join display budget.
100
+ */
101
+ export function renderToolActivityForDisplay(
102
+ activity: ToolActivity | undefined,
103
+ maxChars: number,
104
+ ): string | undefined {
105
+ if (!activity) return undefined;
106
+ if (maxChars <= 0) return "";
107
+ const parts = walkActivityTree(activity);
108
+ if (parts.length === 0) return redactOrTruncate(activity.toolName, maxChars);
109
+ const joined = parts.map((p) => normalizeSummaryValue(p)).join(" - ");
110
+ return redactOrTruncate(joined, maxChars);
111
+ }
@@ -6,7 +6,6 @@ import {
6
6
  normalizeAndTruncate,
7
7
  normalizeSummaryValue,
8
8
  normalizeTerminalSentence,
9
- truncateText,
10
9
  } from "../output/normalize.js";
11
10
  import type {
12
11
  SingleResult,
@@ -14,8 +13,6 @@ import type {
14
13
  ToolActivity,
15
14
  } from "../shared/types.js";
16
15
 
17
- export const SENSITIVE_PATTERN = /secret|token|password/i;
18
-
19
16
  export type ThemeBg = "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
20
17
 
21
18
  export type ProgressStatus = "running" | "success" | "error" | "cancelled";
@@ -57,6 +54,7 @@ export interface SubagentProgressState {
57
54
  outputTokens?: number | undefined;
58
55
  contextTokens?: number | undefined;
59
56
  contextWindowTokens?: number | undefined;
57
+ modelDisplay?: string | undefined;
60
58
  finalOutput?: string | undefined;
61
59
  errorText?: string | undefined;
62
60
  }
@@ -97,12 +95,8 @@ type ProgressTransientFields = Pick<
97
95
  function stripTransientFields(
98
96
  merged: SubagentProgressState,
99
97
  ): Omit<SubagentProgressState, keyof ProgressTransientFields> {
100
- const {
101
- activeToolActivity: _a,
102
- lastToolPreview: _l,
103
- toolResultCompleted: _t,
104
- ...base
105
- } = merged;
98
+ const { activeToolActivity, lastToolPreview, toolResultCompleted, ...base } =
99
+ merged;
106
100
  return base;
107
101
  }
108
102
 
@@ -112,11 +106,15 @@ export function patchProgressState(
112
106
  ): void {
113
107
  const state = store.get(requestId);
114
108
  if (!state) return;
109
+ const merged: SubagentProgressState = { ...state, ...patch };
110
+ if (merged.modelDisplay === undefined || merged.modelDisplay === "") {
111
+ delete merged.modelDisplay;
112
+ }
115
113
  if (state.status !== "running") {
116
- store.set(requestId, stripTransientFields({ ...state, ...patch }));
114
+ store.set(requestId, stripTransientFields(merged));
117
115
  return;
118
116
  }
119
- store.set(requestId, { ...state, ...patch });
117
+ store.set(requestId, merged);
120
118
  }
121
119
 
122
120
  function storeTerminalProgressState(
@@ -256,22 +254,26 @@ function extractProgressFromExistingProgress(
256
254
  if (progress.activeToolActivity) {
257
255
  state.activeToolActivity = progress.activeToolActivity;
258
256
  }
259
- if (
260
- typeof progress.lastToolPreview === "string" &&
261
- progress.lastToolPreview.trim()
262
- ) {
263
- state.progressLastToolPreview = normalizeAndTruncate(
264
- progress.lastToolPreview,
265
- );
257
+ const previewValue = progress.lastToolPreview;
258
+ const truncatedPreview =
259
+ typeof previewValue === "string" && previewValue.trim()
260
+ ? normalizeAndTruncate(previewValue)
261
+ : undefined;
262
+ if (truncatedPreview) {
263
+ state.progressLastToolPreview = truncatedPreview;
266
264
  }
267
265
  if (progress.toolResultCompleted) {
268
266
  state.toolResultCompleted = true;
269
267
  }
268
+ const hasToolCalls = progress.toolCalls.some(isDerivedToolCall);
270
269
  for (const toolCall of progress.toolCalls) {
271
270
  if (!isDerivedToolCall(toolCall)) continue;
272
271
  const preview = normalizeAndTruncate(toolCall.preview);
273
272
  trackNewToolCall(toolCall.id, preview, seenToolCallIds, state);
274
273
  }
274
+ if (truncatedPreview && !hasToolCalls) {
275
+ state.lastToolPreview = truncatedPreview;
276
+ }
275
277
  }
276
278
 
277
279
  function extractProgressFromMessages(
@@ -336,93 +338,3 @@ export function isToolCallPart(part: unknown): part is {
336
338
  typeof part["name"] === "string"
337
339
  );
338
340
  }
339
-
340
- /**
341
- * Format a millisecond duration for compact display.
342
- * Renders sub-minute durations as decimal seconds (`45.2s`),
343
- * longer durations as minutes and whole seconds (`2m 15s`).
344
- */
345
- export function formatElapsed(ms: number): string {
346
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
347
- const mins = Math.floor(ms / 60000);
348
- const secs = Math.floor((ms % 60000) / 1000);
349
- return `${mins}m ${secs}s`;
350
- }
351
-
352
- export function formatContextPercent(state: SubagentProgressState): string {
353
- const d = state.contextWindowTokens;
354
- if (!d || d <= 0 || !Number.isFinite(d)) return "--%";
355
- const n = state.contextTokens;
356
- if (!n || n <= 0 || !Number.isFinite(n)) return "0%";
357
- return `${Math.round((n / d) * 100)}%`;
358
- }
359
-
360
- /**
361
- * Format the one-line statistics header for a subagent progress display.
362
- * Includes tool count, context window usage, and elapsed time.
363
- * When the subagent is still running (`durationMs` unset), elapsed is
364
- * computed live from `startTime`.
365
- *
366
- * @returns Single line ending in `\n`, e.g. `"3 tools · 45% ctx · 12.3s\n"`
367
- */
368
- export function formatHeaderStats(state: SubagentProgressState): string {
369
- const elapsedMs = state.durationMs ?? Date.now() - state.startTime;
370
- const toolLabel = state.toolCount === 1 ? "tool" : "tools";
371
- return `${state.toolCount} ${toolLabel} · ${formatContextPercent(state)} ctx · ${formatElapsed(elapsedMs)}\n`;
372
- }
373
-
374
- const REDACTED_PLACEHOLDER = "(running...)";
375
- const REDACTED_PLACEHOLDER_LENGTH = REDACTED_PLACEHOLDER.length;
376
-
377
- function redactOrTruncate(text: string, maxChars: number): string {
378
- if (SENSITIVE_PATTERN.test(text))
379
- return maxChars >= REDACTED_PLACEHOLDER_LENGTH ? REDACTED_PLACEHOLDER : "";
380
- return truncateText(text, maxChars);
381
- }
382
-
383
- function walkActivityTree(activity: ToolActivity): string[] {
384
- const parts: string[] = [];
385
- let current: ToolActivity | undefined = activity;
386
- while (current) {
387
- if (current.inputSummary) {
388
- const annotated = current.instanceName
389
- ? `${current.inputSummary} [${current.instanceName}]`
390
- : current.inputSummary;
391
- parts.push(annotated);
392
- }
393
- current = current.child;
394
- }
395
- return parts;
396
- }
397
-
398
- /**
399
- * Renders a ToolActivity tree for storage. Each segment is
400
- * independently normalized and truncated to TOOL_PREVIEW_MAX_CHARS (120).
401
- */
402
- export function renderToolActivity(
403
- activity: ToolActivity | undefined,
404
- ): string | undefined {
405
- if (!activity) return undefined;
406
- const parts = walkActivityTree(activity);
407
- if (parts.length === 0) return activity.toolName;
408
- const result = parts.map((p) => normalizeAndTruncate(p)).join(" - ");
409
- if (SENSITIVE_PATTERN.test(result)) return REDACTED_PLACEHOLDER;
410
- return result;
411
- }
412
-
413
- /**
414
- * Renders a ToolActivity tree for display with a caller-provided truncation
415
- * budget. Segments are normalized without individual truncation so the
416
- * joined result shares one post-join display budget.
417
- */
418
- export function renderToolActivityForDisplay(
419
- activity: ToolActivity | undefined,
420
- maxChars: number,
421
- ): string | undefined {
422
- if (!activity) return undefined;
423
- if (maxChars <= 0) return "";
424
- const parts = walkActivityTree(activity);
425
- if (parts.length === 0) return redactOrTruncate(activity.toolName, maxChars);
426
- const joined = parts.map((p) => normalizeSummaryValue(p)).join(" - ");
427
- return redactOrTruncate(joined, maxChars);
428
- }