@narumitw/pi-subagents 0.43.0 → 0.43.1

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.
@@ -0,0 +1,234 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import type { SubagentInspectParams } from "./inspect.js";
5
+ import {
6
+ booleanValue,
7
+ COLLAPSED_LIST_LIMIT,
8
+ expansionHint,
9
+ numberValue,
10
+ recordList,
11
+ recordValue,
12
+ renderFallbackResult,
13
+ safeBlock,
14
+ safeLine,
15
+ statusBadge,
16
+ stringValue,
17
+ type ToolRendererContext,
18
+ toolHeader,
19
+ } from "./render-common.js";
20
+
21
+ export function renderInspectCall(args: Partial<SubagentInspectParams>, theme: Theme) {
22
+ const action = safeLine(args.action, "...", 128);
23
+ const metadata: string[] = [];
24
+ if (args.agentScope) metadata.push(`[${args.agentScope}]`);
25
+ if (args.agent) metadata.push(`agent:${safeLine(args.agent, "", 256)}`);
26
+ if (args.agentId) metadata.push(`id:${safeLine(args.agentId, "", 256)}`);
27
+ if (args.includeClosed) metadata.push("include closed");
28
+ return new Text(toolHeader(theme, "subagent_inspect", action, metadata), 0, 0);
29
+ }
30
+
31
+ export function renderInspectResult(
32
+ result: AgentToolResult<Record<string, unknown>>,
33
+ options: ToolRenderResultOptions,
34
+ theme: Theme,
35
+ context: ToolRendererContext<SubagentInspectParams>,
36
+ ) {
37
+ const details = recordValue(result.details);
38
+ const action = stringValue(details?.action);
39
+ if (!details || !action) return renderFallbackResult(result, options, theme, context.isError);
40
+
41
+ const rendered = renderAction(action, details, options.expanded, theme);
42
+ if (!rendered) return renderFallbackResult(result, options, theme, context.isError);
43
+ return new Text(rendered, 0, 0);
44
+ }
45
+
46
+ function renderAction(
47
+ action: string,
48
+ details: Record<string, unknown>,
49
+ expanded: boolean,
50
+ theme: Theme,
51
+ ): string | undefined {
52
+ switch (action) {
53
+ case "list_agents":
54
+ return renderList("agent", recordList(details.agents), details, expanded, theme, formatAgent);
55
+ case "get_agent": {
56
+ const agent = recordValue(details.agent);
57
+ if (!agent) return undefined;
58
+ const lines = [
59
+ `${statusBadge(theme, "completed")} · ${theme.fg("toolTitle", theme.bold(safeLine(agent.name, "agent", 256)))}${theme.fg("muted", ` (${safeLine(agent.source, "unknown", 128)})`)}`,
60
+ formatAgent(agent, theme, true),
61
+ ];
62
+ if (!expanded) lines.push(expansionHint());
63
+ return lines.join("\n");
64
+ }
65
+ case "list_runs":
66
+ return renderList("run", recordList(details.runs), details, expanded, theme, formatRun);
67
+ case "get_run": {
68
+ const run = recordValue(details.run);
69
+ if (!run) return undefined;
70
+ const lines = [
71
+ `${statusBadge(theme, "completed")} · run ${theme.fg("accent", safeLine(run.id, "run", 256))}`,
72
+ formatRun(run, theme, true),
73
+ ];
74
+ if (!expanded) lines.push(expansionHint());
75
+ return lines.join("\n");
76
+ }
77
+ case "list_models":
78
+ return renderList(
79
+ "model",
80
+ recordList(details.models),
81
+ details,
82
+ expanded,
83
+ theme,
84
+ formatModel,
85
+ stringValue(details.source),
86
+ );
87
+ case "status":
88
+ return renderStatus(details, expanded, theme);
89
+ case "diagnose":
90
+ return renderDiagnose(details, expanded, theme);
91
+ default:
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ function renderList(
97
+ label: string,
98
+ items: Record<string, unknown>[],
99
+ details: Record<string, unknown>,
100
+ expanded: boolean,
101
+ theme: Theme,
102
+ format: (item: Record<string, unknown>, theme: Theme, expanded: boolean) => string,
103
+ source = "",
104
+ ): string {
105
+ const returned = numberValue(details.returned, items.length);
106
+ const omitted = numberValue(details.omitted);
107
+ const noun = `${label}${returned === 1 ? "" : "s"}`;
108
+ const lines = [
109
+ `${statusBadge(theme, "completed")} · ${returned} ${noun}${source ? theme.fg("muted", ` · ${safeLine(source, "", 256)}`) : ""}`,
110
+ ];
111
+ const selected = expanded ? items : items.slice(0, COLLAPSED_LIST_LIMIT);
112
+ for (const item of selected) lines.push(format(item, theme, expanded));
113
+ const hidden = Math.max(0, items.length - selected.length) + omitted;
114
+ if (hidden > 0) lines.push(theme.fg("muted", `… ${hidden} omitted`));
115
+ if (items.length === 0) lines.push(theme.fg("muted", `(no ${label}s)`));
116
+ if (!expanded && (items.length > 0 || omitted > 0)) lines.push(expansionHint());
117
+ return lines.join("\n");
118
+ }
119
+
120
+ function formatAgent(agent: Record<string, unknown>, theme: Theme, expanded: boolean): string {
121
+ const name = safeLine(agent.name, "agent", 256);
122
+ const source = safeLine(agent.source, "unknown", 128);
123
+ const model = stringValue(agent.model);
124
+ const toolCount = typeof agent.toolCount === "number" ? agent.toolCount : undefined;
125
+ const description = safeBlock(agent.description, "", 512).trim();
126
+ const lines = [
127
+ `${theme.fg("muted", "• ")}${theme.fg("accent", name)} ${theme.fg("muted", source)}${model ? theme.fg("dim", ` · ${safeLine(model, "", 256)}`) : ""}${toolCount !== undefined ? theme.fg("dim", ` · ${toolCount} tools`) : ""}`,
128
+ ];
129
+ if (description) lines.push(` ${theme.fg("toolOutput", description)}`);
130
+ if (expanded) {
131
+ const path = stringValue(agent.path);
132
+ const consultTools = stringList(agent.consultTools);
133
+ if (path) lines.push(` ${theme.fg("dim", `path: ${safeLine(path, "", 2 * 1024)}`)}`);
134
+ lines.push(` ${theme.fg("dim", `consult tools: ${consultTools.join(", ") || "none"}`)}`);
135
+ }
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function formatRun(run: Record<string, unknown>, theme: Theme, expanded: boolean): string {
140
+ const id = safeLine(run.id, "run", 256);
141
+ const agent = safeLine(run.agent, "agent", 256);
142
+ const state = safeLine(run.state, "unknown", 128);
143
+ const unread = numberValue(run.unreadMessages);
144
+ const lines = [
145
+ `${theme.fg("muted", "• ")}${theme.fg("accent", id)} ${theme.fg("toolOutput", agent)} ${theme.fg("muted", state)}${unread > 0 ? theme.fg("warning", ` · unread:${unread}`) : ""}`,
146
+ ];
147
+ if (expanded) {
148
+ const thinking = stringValue(run.thinkingLevel);
149
+ const task = safeBlock(run.currentTask, "", 2 * 1024).trim();
150
+ const error = safeBlock(run.error, "", 2 * 1024).trim();
151
+ lines.push(
152
+ ` ${theme.fg("dim", `${numberValue(run.historyCount)} history · ${thinking ? `thinking:${safeLine(thinking, "", 128)} · ` : ""}${numberValue(run.children)} children`)}`,
153
+ );
154
+ if (task) lines.push(` ${theme.fg("dim", `task: ${task}`)}`);
155
+ if (error) lines.push(` ${theme.fg("error", `error: ${error}`)}`);
156
+ }
157
+ return lines.join("\n");
158
+ }
159
+
160
+ function formatModel(model: Record<string, unknown>, theme: Theme, expanded: boolean): string {
161
+ const identity = `${safeLine(model.provider, "provider", 256)}/${safeLine(model.id, "model", 256)}`;
162
+ const current = booleanValue(model.current) ? theme.fg("success", " · current") : "";
163
+ const reasoning = booleanValue(model.reasoning) ? theme.fg("dim", " · reasoning") : "";
164
+ const lines = [`${theme.fg("muted", "• ")}${theme.fg("accent", identity)}${current}${reasoning}`];
165
+ if (expanded) {
166
+ const name = stringValue(model.name);
167
+ const thinking = stringValue(model.thinkingLevel);
168
+ if (name) lines.push(` ${theme.fg("toolOutput", safeLine(name, "", 512))}`);
169
+ lines.push(
170
+ ` ${theme.fg("dim", `context:${numberValue(model.contextWindow)} · max:${numberValue(model.maxTokens)}${thinking ? ` · thinking:${safeLine(thinking, "", 128)}` : ""}`)}`,
171
+ );
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+
176
+ function renderStatus(
177
+ details: Record<string, unknown>,
178
+ expanded: boolean,
179
+ theme: Theme,
180
+ ): string | undefined {
181
+ const status = recordValue(details.status);
182
+ const stateful = recordValue(status?.stateful);
183
+ if (!status || !stateful) return undefined;
184
+ const lines = [
185
+ `${statusBadge(theme, "completed")} · runtime status`,
186
+ `${theme.fg("muted", "workflow: ")}${theme.fg("accent", safeLine(status.workflow, "unknown", 128))} · ${numberValue(stateful.activeAgents)} active · ${numberValue(stateful.retainedAgents)} retained`,
187
+ `${theme.fg("muted", "stateful: ")}${stateful.initialized === true ? "initialized" : "not initialized"} · resources: ${safeLine(status.consultResources, "unknown", 128)}`,
188
+ ];
189
+ if (expanded) {
190
+ const delivery = stringValue(stateful.completionDelivery);
191
+ const transport = stringValue(stateful.transport);
192
+ if (transport || delivery) {
193
+ lines.push(
194
+ theme.fg(
195
+ "dim",
196
+ [
197
+ transport && `transport:${safeLine(transport)}`,
198
+ delivery && `delivery:${safeLine(delivery)}`,
199
+ ]
200
+ .filter(Boolean)
201
+ .join(" · "),
202
+ ),
203
+ );
204
+ }
205
+ } else lines.push(expansionHint());
206
+ return lines.join("\n");
207
+ }
208
+
209
+ function renderDiagnose(details: Record<string, unknown>, expanded: boolean, theme: Theme): string {
210
+ const checks = recordList(details.checks);
211
+ const hasFail = checks.some((check) => check.status === "fail");
212
+ const hasWarning = checks.some((check) => check.status === "warning");
213
+ const overall = hasFail ? "failed" : hasWarning ? "warnings" : "passed";
214
+ const status = hasFail ? "failed" : hasWarning ? "warning" : "completed";
215
+ const lines = [`${statusBadge(theme, status)} · Diagnostics ${overall}`];
216
+ const selected = expanded ? checks : checks.slice(0, COLLAPSED_LIST_LIMIT);
217
+ for (const check of selected) {
218
+ const checkStatus = safeLine(check.status, "unknown", 64);
219
+ const color =
220
+ checkStatus === "fail" ? "error" : checkStatus === "warning" ? "warning" : "success";
221
+ lines.push(
222
+ `${theme.fg(color, checkStatus.toUpperCase())} ${theme.fg("accent", safeLine(check.name, "check", 256))} · ${theme.fg("toolOutput", safeBlock(check.message, "", 2 * 1024))}`,
223
+ );
224
+ }
225
+ if (checks.length === 0) lines.push(theme.fg("muted", "(no diagnostic checks)"));
226
+ if (!expanded && checks.length > 0) lines.push(expansionHint());
227
+ return lines.join("\n");
228
+ }
229
+
230
+ function stringList(value: unknown): string[] {
231
+ return Array.isArray(value)
232
+ ? value.flatMap((item) => (typeof item === "string" ? [safeLine(item, "", 256)] : []))
233
+ : [];
234
+ }
package/src/inspect.ts CHANGED
@@ -2,12 +2,21 @@ import * as path from "node:path";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { type Static, Type } from "typebox";
5
- import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
5
+ import {
6
+ type AgentConfig,
7
+ type AgentScope,
8
+ type ConsultationCwdPolicy,
9
+ type DelegationCwdPolicy,
10
+ discoverAgents,
11
+ } from "./agents.js";
6
12
  import { resolveConsultTools } from "./consult-policy.js";
13
+ import { renderInspectCall, renderInspectResult } from "./inspect-render.js";
7
14
  import type { AgentRunInspectionDetail, AgentRunInspectionSummary } from "./registry.js";
8
15
  import { boundedPrivateText, boundText, safeDisplayPath, safeTerminalLine } from "./safe-text.js";
9
16
  import {
17
+ inspectCompletionDeliverySettings,
10
18
  inspectConsultResourceSettings,
19
+ inspectCwdPolicySettings,
11
20
  inspectDelegationWorkflowSettings,
12
21
  inspectSubagentSettings,
13
22
  resolveDelegationWorkflow,
@@ -49,6 +58,8 @@ export type SubagentInspectParams = Static<typeof SubagentInspectParams>;
49
58
  export interface SubagentInspectRuntime {
50
59
  getBlockingEnabled(): boolean;
51
60
  getConsultResourcePolicy(): "project-context" | "none" | "all";
61
+ getConsultationCwdPolicy(): ConsultationCwdPolicy;
62
+ getDelegationCwdPolicy(): DelegationCwdPolicy;
52
63
  getRuntimeStatus(): StatefulSubagentRuntimeStatus;
53
64
  listRunInspection(includeClosed?: boolean): AgentRunInspectionSummary[];
54
65
  getRunInspection(agentId: string): AgentRunInspectionDetail | undefined;
@@ -79,6 +90,12 @@ export function registerSubagentInspect(pi: ExtensionAPI, runtime: SubagentInspe
79
90
  async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<InspectToolResult> {
80
91
  return executeSubagentInspect(validateInspectParams(params), ctx, runtime);
81
92
  },
93
+ renderCall(args, theme) {
94
+ return renderInspectCall(args, theme);
95
+ },
96
+ renderResult(result, options, theme, context) {
97
+ return renderInspectResult(result, options, theme, context);
98
+ },
82
99
  });
83
100
  }
84
101
 
@@ -280,9 +297,26 @@ function projectRun(run: AgentRunInspectionDetail, ctx: ExtensionContext): Recor
280
297
  return {
281
298
  ...projectRunSummary(run),
282
299
  cwd: safeDisplayPath(run.cwd, ctx.cwd),
300
+ workspaceMode: run.workspaceMode ?? "shared",
283
301
  thinkingLevel: run.thinkingLevel,
284
302
  currentTask: run.currentTask ? boundedPrivateText(run.currentTask, 2 * 1024) : undefined,
285
303
  error: run.error ? boundedPrivateText(run.error, 2 * 1024) : undefined,
304
+ target: run.target
305
+ ? {
306
+ cwd: safeDisplayPath(run.target.cwd, ctx.cwd),
307
+ boundary: run.target.boundary,
308
+ trust: {
309
+ kind: run.target.trust.kind,
310
+ projectTrusted: run.target.trust.projectTrusted,
311
+ sourcePath: run.target.trust.sourcePath
312
+ ? safeDisplayPath(run.target.trust.sourcePath, ctx.cwd)
313
+ : undefined,
314
+ warning: run.target.trust.warning
315
+ ? boundedPrivateText(run.target.trust.warning, 512)
316
+ : undefined,
317
+ },
318
+ }
319
+ : undefined,
286
320
  policy: run.policy
287
321
  ? {
288
322
  inherited: projectToolNames(run.policy.inherited),
@@ -323,17 +357,31 @@ function projectStatus(runtime: SubagentInspectRuntime): Record<string, unknown>
323
357
  const workflow = resolveDelegationWorkflow(runtime.getBlockingEnabled(), stateful.enabled);
324
358
  const configured = inspectDelegationWorkflowSettings();
325
359
  const resources = inspectConsultResourceSettings();
360
+ const cwdPolicy = inspectCwdPolicySettings();
361
+ const completion = inspectCompletionDeliverySettings();
326
362
  return {
327
363
  workflow,
328
364
  configuredWorkflow: configured.value,
365
+ configuredWorkflowSource: configured.source,
329
366
  stateful,
367
+ configuredCompletionDelivery: completion.value,
368
+ configuredCompletionDeliverySource: completion.source,
330
369
  consultResources: runtime.getConsultResourcePolicy(),
370
+ consultationCwdPolicy: runtime.getConsultationCwdPolicy(),
371
+ configuredConsultationCwdPolicy: cwdPolicy.consultation.value,
372
+ consultationCwdPolicySource: cwdPolicy.consultation.source,
373
+ delegationCwdPolicy: runtime.getDelegationCwdPolicy(),
374
+ configuredDelegationCwdPolicy: cwdPolicy.delegation.value,
375
+ delegationCwdPolicySource: cwdPolicy.delegation.source,
331
376
  configuredConsultResources: resources.value,
332
377
  consultResourcesSource: resources.source,
333
378
  settingsPath: safeDisplayPath(resources.path, process.cwd()),
334
379
  settingsError:
335
- configured.error || resources.error
336
- ? boundedPrivateText(configured.error ?? resources.error ?? "", 2 * 1024)
380
+ configured.error || resources.error || cwdPolicy.error || completion.error
381
+ ? boundedPrivateText(
382
+ configured.error ?? resources.error ?? cwdPolicy.error ?? completion.error ?? "",
383
+ 2 * 1024,
384
+ )
337
385
  : undefined,
338
386
  };
339
387
  }
@@ -167,6 +167,8 @@ function isStoredState(value: unknown): value is StoredState {
167
167
  Number.isFinite(record.updatedAt) &&
168
168
  (record.parentId === undefined || typeof record.parentId === "string") &&
169
169
  (record.thinkingLevel === undefined || isThinkingLevel(record.thinkingLevel)) &&
170
+ (record.workspaceMode === undefined || record.workspaceMode === "worktree") &&
171
+ (record.target === undefined || isTargetPolicyAudit(record.target)) &&
170
172
  (record.children === undefined ||
171
173
  (Array.isArray(record.children) &&
172
174
  record.children.every((id) => typeof id === "string"))) &&
@@ -178,6 +180,33 @@ function isStoredState(value: unknown): value is StoredState {
178
180
  });
179
181
  }
180
182
 
183
+ function isTargetPolicyAudit(value: unknown): boolean {
184
+ if (!value || typeof value !== "object") return false;
185
+ const target = value as Record<string, unknown>;
186
+ if (
187
+ typeof target.cwd !== "string" ||
188
+ (target.boundary !== "current-workspace" && target.boundary !== "external") ||
189
+ !target.trust ||
190
+ typeof target.trust !== "object"
191
+ ) {
192
+ return false;
193
+ }
194
+ const trust = target.trust as Record<string, unknown>;
195
+ return (
196
+ [
197
+ "session-trusted",
198
+ "session-untrusted",
199
+ "saved-trusted",
200
+ "saved-denied",
201
+ "unsaved",
202
+ "trust-error",
203
+ ].includes(String(trust.kind)) &&
204
+ typeof trust.projectTrusted === "boolean" &&
205
+ (trust.sourcePath === undefined || typeof trust.sourcePath === "string") &&
206
+ (trust.warning === undefined || typeof trust.warning === "string")
207
+ );
208
+ }
209
+
181
210
  function isAgentTurn(value: unknown): boolean {
182
211
  if (!value || typeof value !== "object") return false;
183
212
  const turn = value as Record<string, unknown>;
package/src/registry.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { SubagentThinkingLevel } from "./agents.js";
3
+ import type { TargetPolicyAudit } from "./cwd-policy.js";
3
4
  import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
4
5
  import { type AgentTurnRunner, normalizeTransport, type SubagentTransport } from "./transport.js";
5
6
 
@@ -50,6 +51,8 @@ export interface ManagedAgent {
50
51
  context?: string;
51
52
  contextSourceIds?: string[];
52
53
  contextTruncated?: boolean;
54
+ workspaceMode?: "worktree";
55
+ target?: TargetPolicyAudit;
53
56
  policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
54
57
  mailbox: AgentMailboxMessage[];
55
58
  currentMailboxMessageIds?: string[];
@@ -70,6 +73,8 @@ export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
70
73
  thinkingLevel?: SubagentThinkingLevel;
71
74
  currentTask?: string;
72
75
  error?: string;
76
+ workspaceMode?: "worktree";
77
+ target?: TargetPolicyAudit;
73
78
  policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
74
79
  }
75
80
 
@@ -243,6 +248,8 @@ export class AgentRegistry {
243
248
  context?: string;
244
249
  contextSourceIds?: string[];
245
250
  contextTruncated?: boolean;
251
+ workspaceMode?: "worktree";
252
+ target?: TargetPolicyAudit;
246
253
  }): Promise<ManagedAgent> {
247
254
  if (!input.task.trim()) throw new Error("Subagent tasks cannot be empty");
248
255
  const task = truncateUtf8(input.task, this.maxTaskBytes).text;
@@ -286,6 +293,8 @@ export class AgentRegistry {
286
293
  context: input.context,
287
294
  contextSourceIds: input.contextSourceIds,
288
295
  contextTruncated: input.contextTruncated,
296
+ workspaceMode: input.workspaceMode,
297
+ target: input.target,
289
298
  };
290
299
  this.agents.set(record.id, record);
291
300
  if (parent) {
@@ -549,6 +558,8 @@ export class AgentRegistry {
549
558
  thinkingLevel: agent.thinkingLevel,
550
559
  currentTask: agent.currentTask,
551
560
  error: agent.error,
561
+ workspaceMode: agent.workspaceMode,
562
+ target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
552
563
  policy: agent.policy
553
564
  ? {
554
565
  inherited: [...agent.policy.inherited],
@@ -840,6 +851,7 @@ export class AgentRegistry {
840
851
  : undefined,
841
852
  history: agent.history.map((turn) => ({ ...turn })),
842
853
  mailbox: agent.mailbox.map((message) => ({ ...message })),
854
+ target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
843
855
  policy: agent.policy
844
856
  ? {
845
857
  inherited: [...agent.policy.inherited],
@@ -0,0 +1,252 @@
1
+ import * as os from "node:os";
2
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
+ import {
4
+ keyHint,
5
+ type Theme,
6
+ type ThemeColor,
7
+ type ToolRenderResultOptions,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { boundedPrivateText, safeTerminalLine } from "./safe-text.js";
11
+
12
+ export const COLLAPSED_LIST_LIMIT = 5;
13
+ export const COLLAPSED_ANSWER_LINES = 3;
14
+
15
+ export interface ToolRendererContext<TArgs> {
16
+ args: TArgs;
17
+ isError: boolean;
18
+ }
19
+
20
+ export type RenderStatus =
21
+ | "starting"
22
+ | "running"
23
+ | "completed"
24
+ | "failed"
25
+ | "cancelled"
26
+ | "interrupted"
27
+ | "idle"
28
+ | "closed"
29
+ | "warning";
30
+
31
+ const STATUS_PRESENTATION: Record<
32
+ RenderStatus,
33
+ { icon: string; label: string; color: ThemeColor }
34
+ > = {
35
+ starting: { icon: "⏳", label: "Starting", color: "warning" },
36
+ running: { icon: "⏳", label: "Running", color: "warning" },
37
+ completed: { icon: "✓", label: "Completed", color: "success" },
38
+ failed: { icon: "✗", label: "Failed", color: "error" },
39
+ cancelled: { icon: "■", label: "Cancelled", color: "warning" },
40
+ interrupted: { icon: "■", label: "Interrupted", color: "warning" },
41
+ idle: { icon: "○", label: "Idle", color: "muted" },
42
+ closed: { icon: "✓", label: "Closed", color: "muted" },
43
+ warning: { icon: "◐", label: "Warning", color: "warning" },
44
+ };
45
+
46
+ export function recordValue(value: unknown): Record<string, unknown> | undefined {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value)
48
+ ? (value as Record<string, unknown>)
49
+ : undefined;
50
+ }
51
+
52
+ export function recordList(value: unknown): Record<string, unknown>[] {
53
+ return Array.isArray(value)
54
+ ? value.flatMap((item) => {
55
+ const record = recordValue(item);
56
+ return record ? [record] : [];
57
+ })
58
+ : [];
59
+ }
60
+
61
+ export function stringValue(value: unknown, fallback = ""): string {
62
+ return typeof value === "string" ? value : fallback;
63
+ }
64
+
65
+ export function numberValue(value: unknown, fallback = 0): number {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
67
+ }
68
+
69
+ export function booleanValue(value: unknown): boolean {
70
+ return value === true;
71
+ }
72
+
73
+ export function safeLine(value: unknown, fallback = "...", maxBytes = 2 * 1024): string {
74
+ if (typeof value !== "string" || !value.trim()) return fallback;
75
+ return safeTerminalLine(value, maxBytes) || fallback;
76
+ }
77
+
78
+ export function safeBlock(value: unknown, fallback = "", maxBytes = 50 * 1024): string {
79
+ if (typeof value !== "string" || !value) return fallback;
80
+ return boundedPrivateText(value, maxBytes);
81
+ }
82
+
83
+ export function previewLines(value: unknown, maxLines = COLLAPSED_ANSWER_LINES): string {
84
+ const text = safeBlock(value, "", 8 * 1024).trim();
85
+ return text.split("\n").slice(0, maxLines).join("\n");
86
+ }
87
+
88
+ export function textResult(result: AgentToolResult<unknown>): string {
89
+ return result.content
90
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
91
+ .join("\n")
92
+ .trim();
93
+ }
94
+
95
+ export function toolHeader(
96
+ theme: Theme,
97
+ toolName: string,
98
+ primary?: unknown,
99
+ metadata: readonly string[] = [],
100
+ ): string {
101
+ let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
102
+ if (primary !== undefined) text += theme.fg("accent", safeLine(primary));
103
+ const safeMetadata = metadata.filter(Boolean).map((item) => safeLine(item, "", 512));
104
+ if (safeMetadata.length > 0) text += theme.fg("muted", ` · ${safeMetadata.join(" · ")}`);
105
+ return text.trimEnd();
106
+ }
107
+
108
+ export function statusBadge(theme: Theme, status: RenderStatus, suffix?: string): string {
109
+ const presentation = STATUS_PRESENTATION[status];
110
+ const label = suffix
111
+ ? `${presentation.label} · ${safeLine(suffix, "", 2 * 1024)}`
112
+ : presentation.label;
113
+ return `${theme.fg(presentation.color, presentation.icon)} ${theme.fg(presentation.color, label)}`;
114
+ }
115
+
116
+ export function renderFallbackResult(
117
+ result: AgentToolResult<unknown>,
118
+ options: ToolRenderResultOptions,
119
+ theme: Theme,
120
+ isError = false,
121
+ ) {
122
+ const status: RenderStatus = isError ? "failed" : options.isPartial ? "running" : "completed";
123
+ const content = safeBlock(textResult(result), "(no output)", 8 * 1024);
124
+ return new Text(`${statusBadge(theme, status)}\n${theme.fg("toolOutput", content)}`, 0, 0);
125
+ }
126
+
127
+ export function expansionHint(): string {
128
+ return keyHint("app.tools.expand", "to expand");
129
+ }
130
+
131
+ export interface RenderActivityItem {
132
+ type: "text" | "toolCall";
133
+ text?: string;
134
+ name?: string;
135
+ args?: Record<string, unknown>;
136
+ }
137
+
138
+ export function projectRenderActivity(value: unknown): RenderActivityItem[] {
139
+ if (!Array.isArray(value)) return [];
140
+ const items: RenderActivityItem[] = [];
141
+ for (const item of value) {
142
+ const record = recordValue(item);
143
+ if (!record) continue;
144
+ if (record.type === "text" && typeof record.text === "string") {
145
+ items.push({ type: "text", text: safeBlock(record.text, "", 1024) });
146
+ continue;
147
+ }
148
+ if (record.type === "toolCall" && typeof record.name === "string") {
149
+ items.push({
150
+ type: "toolCall",
151
+ name: safeLine(record.name, "tool", 256),
152
+ args: recordValue(record.args) ?? {},
153
+ });
154
+ }
155
+ }
156
+ return items;
157
+ }
158
+
159
+ export function renderActivityLines(
160
+ items: readonly RenderActivityItem[],
161
+ theme: Theme,
162
+ limit?: number,
163
+ total = items.length,
164
+ ): string {
165
+ const selected = limit === undefined ? items : items.slice(-limit);
166
+ const lines: string[] = [];
167
+ const skipped = Math.max(0, total - selected.length);
168
+ if (skipped > 0) lines.push(theme.fg("muted", `… ${skipped} earlier activities`));
169
+ for (const item of selected) {
170
+ if (item.type === "text") {
171
+ const text = safeBlock(item.text, "", 1024).trim();
172
+ if (text) lines.push(theme.fg("toolOutput", text));
173
+ } else {
174
+ lines.push(
175
+ theme.fg("muted", "→ ") +
176
+ formatToolActivity(item.name ?? "tool", item.args ?? {}, theme.fg.bind(theme)),
177
+ );
178
+ }
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+
183
+ export function formatToolActivity(
184
+ toolNameValue: unknown,
185
+ argsValue: unknown,
186
+ themeFg: (color: ThemeColor, text: string) => string,
187
+ ): string {
188
+ const toolName = safeLine(toolNameValue, "tool", 256);
189
+ const args = recordValue(argsValue) ?? {};
190
+ const shortenPath = (value: unknown, fallback = ".") => {
191
+ const filePath = safeLine(value, fallback, 2 * 1024);
192
+ const home = os.homedir();
193
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
194
+ };
195
+
196
+ switch (toolName) {
197
+ case "bash": {
198
+ const command = safeLine(args.command, "...", 512);
199
+ return themeFg("muted", "$ ") + themeFg("toolOutput", command);
200
+ }
201
+ case "read": {
202
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
203
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
204
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
205
+ let text = themeFg("accent", filePath);
206
+ if (offset !== undefined || limit !== undefined) {
207
+ const startLine = offset ?? 1;
208
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
209
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
210
+ }
211
+ return themeFg("muted", "read ") + text;
212
+ }
213
+ case "write": {
214
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
215
+ const content = safeBlock(args.content, "", 2 * 1024);
216
+ const lines = content ? content.split("\n").length : 0;
217
+ return (
218
+ themeFg("muted", "write ") +
219
+ themeFg("accent", filePath) +
220
+ (lines > 1 ? themeFg("dim", ` (${lines} lines)`) : "")
221
+ );
222
+ }
223
+ case "edit":
224
+ return (
225
+ themeFg("muted", "edit ") +
226
+ themeFg("accent", shortenPath(args.file_path ?? args.path, "..."))
227
+ );
228
+ case "ls":
229
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(args.path));
230
+ case "find":
231
+ return (
232
+ themeFg("muted", "find ") +
233
+ themeFg("accent", safeLine(args.pattern, "*", 512)) +
234
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
235
+ );
236
+ case "grep":
237
+ return (
238
+ themeFg("muted", "grep ") +
239
+ themeFg("accent", `/${safeLine(args.pattern, "", 512)}/`) +
240
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
241
+ );
242
+ default: {
243
+ let serialized = "{}";
244
+ try {
245
+ serialized = JSON.stringify(args);
246
+ } catch {
247
+ serialized = "{…}";
248
+ }
249
+ return themeFg("accent", toolName) + themeFg("dim", ` ${safeLine(serialized, "{}", 512)}`);
250
+ }
251
+ }
252
+ }