pi-subagents-lite 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -50,39 +50,16 @@ export interface AgentConfig {
50
50
 
51
51
  export interface AgentRecord {
52
52
  id: string;
53
- type: SubagentType;
54
- description: string;
55
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
56
53
  result?: string;
57
54
  error?: string;
58
- toolUses: number;
59
- startedAt: number;
60
- completedAt?: number;
61
- session?: AgentSession;
62
- abortController?: AbortController;
63
- promise?: Promise<string>;
64
- /** Steering messages queued before the session was ready. */
65
- pendingSteers?: string[];
66
- /** The tool_use_id from the original Agent tool call. */
67
- toolCallId?: string;
68
- /** Path to the streaming output transcript file. */
69
- outputFile?: string;
70
- /** Cleanup function for the output file stream subscription. */
71
- outputCleanup?: () => void;
72
- /**
73
- * Lifetime usage breakdown, accumulated via `message_end` events. Survives
74
- * compaction. Total = input + output + cacheWrite + cost (cacheRead deliberately
75
- * excluded — see issue #38). Initialized to zeros at spawn.
76
- */
77
- lifetimeUsage: LifetimeUsage;
78
- /** Final turn count (set on completion). Used by widget after activity cleanup. */
79
- turnCount?: number;
80
- /** Max turns limit (from invocation or default). */
81
- maxTurns?: number;
82
- /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
83
- compactionCount: number;
84
- /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
85
- invocation?: AgentInvocation;
55
+ /** Lifecycle state: status, timestamps. */
56
+ lifecycle: AgentLifecycle;
57
+ /** Display-oriented info: type, description, output file, invocation. */
58
+ display: AgentDisplayInfo;
59
+ /** Execution internals: session, abort controller, pending steers. */
60
+ execution: AgentExecutionState;
61
+ /** Accumulated statistics: usage, tool uses, turns. */
62
+ stats: AgentAccumulatedStats;
86
63
  }
87
64
 
88
65
  export interface AgentInvocation {
@@ -102,6 +79,30 @@ export interface EnvInfo {
102
79
  /** How many characters of agent ID to show in display. */
103
80
  export const SHORT_ID_LENGTH = 8;
104
81
 
82
+ /**
83
+ * Theme for terminal rendering — used by format.ts, renderer.ts, and UI widgets.
84
+ * Defined here (not in ui/agent-widget.ts) so non-UI modules can import it
85
+ * without depending on the UI layer.
86
+ */
87
+ export type Theme = {
88
+ fg(color: string, text: string): string;
89
+ bg(color: string, text: string): string;
90
+ bold(text: string): string;
91
+ italic?: (text: string) => string;
92
+ };
93
+
94
+ /** Non-model keys in config.agent — preserved when clearing all overrides. */
95
+ export const CONFIG_AGENT_NON_MODEL_KEYS = [
96
+ "default",
97
+ "forceBackground",
98
+ "graceTurns",
99
+ "showCost",
100
+ "widgetMaxLines",
101
+ "widgetMaxLinesCompact",
102
+ "widgetCompact",
103
+ "widgetShortcut",
104
+ ];
105
+
105
106
  /** Reason for a context compaction event. */
106
107
  export type CompactionReason = "manual" | "threshold" | "overflow";
107
108
 
@@ -111,4 +112,72 @@ export interface CompactionInfo {
111
112
  tokensBefore: number;
112
113
  }
113
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Sub-object interfaces for decomposed AgentRecord
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Possible agent lifecycle statuses. */
120
+ export type AgentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
121
+
122
+ /**
123
+ * Lifecycle state: when the agent started, completed, and its current status.
124
+ * Used by agent-manager (lifecycle control), menus (status display), widget (linger logic).
125
+ */
126
+ export interface AgentLifecycle {
127
+ status: AgentStatus;
128
+ startedAt: number;
129
+ completedAt?: number;
130
+ }
131
+
132
+ /**
133
+ * Display-oriented fields: type name, description, output file, invocation params.
134
+ * Used by widget (rendering), menus (listing), renderer (display).
135
+ */
136
+ export interface AgentDisplayInfo {
137
+ type: SubagentType;
138
+ description: string;
139
+ /** Path to the streaming output transcript file. */
140
+ outputFile?: string;
141
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
142
+ invocation?: AgentInvocation;
143
+ /** The tool_use_id from the original Agent tool call. */
144
+ toolCallId?: string;
145
+ }
146
+
147
+ /**
148
+ * Execution internals: session handle, abort controller, pending steers.
149
+ * Used by agent-manager (session lifecycle), tool-execution (steering, nudge).
150
+ */
151
+ export interface AgentExecutionState {
152
+ session?: AgentSession;
153
+ abortController?: AbortController;
154
+ promise?: Promise<string>;
155
+ /** Steering messages queued before the session was ready. */
156
+ pendingSteers?: string[];
157
+ /** Cleanup function for the output file stream subscription. */
158
+ outputCleanup?: () => void;
159
+ }
160
+
161
+ /**
162
+ * Accumulated statistics: usage breakdown, tool uses, turn count.
163
+ * Used by widget (stats display), tool-execution (details building), menus (result viewer).
164
+ */
165
+ export interface AgentAccumulatedStats {
166
+ /**
167
+ * Lifetime usage breakdown, accumulated via `message_end` events. Survives
168
+ * compaction. Total = input + output + cacheWrite + cost (cacheRead deliberately
169
+ * excluded — see issue #38). Initialized to zeros at spawn.
170
+ */
171
+ lifetimeUsage: LifetimeUsage;
172
+ toolUses: number;
173
+ /** Final turn count (set on completion). Used by widget after activity cleanup. */
174
+ turnCount?: number;
175
+ /** Max turns limit (from invocation or default). */
176
+ maxTurns?: number;
177
+ /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
178
+ compactionCount: number;
179
+ /** Last-known context usage percentage (0–100), captured at completion. */
180
+ contextPercent?: number | null;
181
+ }
182
+
114
183
 
@@ -4,20 +4,23 @@
4
4
 
5
5
  import { truncateToWidth } from "@earendil-works/pi-tui";
6
6
  import type { AgentManager } from "../agent-manager.js";
7
- import { getConfig } from "../agent-types.js";
8
- import type { AgentRecord, SubagentType } from "../types.js";
7
+ import type { AgentRecord, Theme } from "../types.js";
9
8
  import {
10
- formatTokens,
9
+ formatCost,
11
10
  getLifetimeTotal,
12
11
  getSessionContextPercent,
13
12
  type LifetimeUsage,
14
13
  type SessionLike,
15
14
  } from "../usage.js";
15
+ import { formatMs, buildStatsParts, getDisplayName } from "../format.js";
16
+
17
+ // Re-export Theme so existing consumers (model-selector, result-viewer) don't break
18
+ export type { Theme } from "../types.js";
16
19
 
17
20
  // ---- Constants ----
18
21
 
19
22
  /** Maximum number of rendered lines before overflow collapse kicks in. */
20
- const MAX_WIDGET_LINES = 12;
23
+ const DEFAULT_MAX_WIDGET_LINES = 12;
21
24
 
22
25
  /** Braille spinner frames for animated running indicator. */
23
26
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -58,11 +61,6 @@ const TOOL_DISPLAY: Record<string, string> = {
58
61
 
59
62
  // ---- Types ----
60
63
 
61
- export type Theme = {
62
- fg(color: string, text: string): string;
63
- bold(text: string): string;
64
- italic?: (text: string) => string;
65
- };
66
64
 
67
65
  export type UICtx = {
68
66
  setStatus(key: string, text: string | undefined): void;
@@ -99,95 +97,10 @@ export interface AgentActivity {
99
97
  lifetimeUsage: LifetimeUsage;
100
98
  }
101
99
 
100
+ // ---- Re-exports from format.ts (backward compatibility) ----
101
+ export { formatMs, buildStatsParts, getDisplayName } from "../format.js";
102
102
 
103
-
104
- // ---- Formatting helpers ----
105
-
106
- /**
107
- * Token count with optional context-fill % and compaction-count annotations.
108
- * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
109
- * Compaction count rendered as `↻ N` in dim.
110
- *
111
- * "12.3k" — no annotations
112
- * "12.3k(45%)" — percent only
113
- * "12.3k(↻ 2)" — compactions only (e.g. right after compact)
114
- * "12.3k(45%·↻ 2)" — both
115
- */
116
- function formatSessionTokens(
117
- tokens: number,
118
- percent: number | null,
119
- theme: Theme,
120
- compactions = 0,
121
- ): string {
122
- const tokenStr = formatTokens(tokens);
123
- const annot: string[] = [];
124
- if (percent !== null) {
125
- const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
126
- annot.push(theme.fg(color, `${Math.round(percent)}%`));
127
- }
128
- if (compactions > 0) {
129
- annot.push(theme.fg("dim", `↻ ${compactions}`));
130
- }
131
- if (annot.length === 0) return tokenStr;
132
- // Include closing paren in the last annotation's color span to prevent
133
- // ANSI reset from leaving `)` in default color when wrapped in outer dim.
134
- const lastIdx = annot.length - 1;
135
- annot[lastIdx] += ")";
136
- return `${tokenStr}(${annot.join("·")}`;
137
- }
138
-
139
- /** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
140
- function formatTurns(turnCount: number, maxTurns?: number | null): string {
141
- return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
142
- }
143
-
144
- /** Format milliseconds as a compact human-readable duration: "1h 1m 1s", "5m 37s", "10s", "<1s". */
145
- export function formatMs(ms: number): string {
146
- if (!Number.isFinite(ms) || ms < 1000) return "<1s";
147
-
148
- const totalSeconds = Math.floor(ms / 1000);
149
- const hours = Math.floor(totalSeconds / 3600);
150
- const minutes = Math.floor((totalSeconds % 3600) / 60);
151
- const seconds = totalSeconds % 60;
152
-
153
- const parts: string[] = [];
154
- if (hours > 0) parts.push(`${hours}h`);
155
- if (minutes > 0) parts.push(`${minutes}m`);
156
- if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
157
-
158
- return parts.join(" ");
159
- }
160
-
161
- /**
162
- * Build common stats parts: toolUses · turns · tokens with context %.
163
- * Shared by AgentWidget and index.ts for consistent stats display.
164
- */
165
- export function buildStatsParts(
166
- args: {
167
- toolUses: number;
168
- turnCount?: number;
169
- maxTurns?: number;
170
- tokens: number;
171
- contextPercent: number | null;
172
- compactions: number;
173
- },
174
- theme: Theme,
175
- ): string[] {
176
- const parts: string[] = [];
177
- if (args.toolUses > 0) parts.push(`${args.toolUses}🛠 `);
178
- if (args.turnCount != null) parts.push(formatTurns(args.turnCount, args.maxTurns));
179
- if (args.tokens > 0) {
180
- parts.push(formatSessionTokens(
181
- args.tokens, args.contextPercent, theme, args.compactions,
182
- ));
183
- }
184
- return parts;
185
- }
186
-
187
- /** Get display name for any agent type (built-in or custom). */
188
- export function getDisplayName(type: SubagentType): string {
189
- return getConfig(type).displayName;
190
- }
103
+ // ---- Widget-internal helpers ----
191
104
 
192
105
  /**
193
106
  * Wrap a stats line in dim ANSI codes, re-applying dim after any inner
@@ -245,6 +158,8 @@ export class AgentWidget {
245
158
  /** Finished agents: agent ID → turns since finished. */
246
159
  private finishedTurnAge = new Map<string, number>();
247
160
 
161
+ /** Whether to show cost in stats and status bar. */
162
+ private showCost = false;
248
163
 
249
164
  /** Whether the widget callback is currently registered with the TUI. */
250
165
  private widgetRegistered = false;
@@ -252,12 +167,67 @@ export class AgentWidget {
252
167
  private tui: TUI | undefined;
253
168
  /** Last status bar text, used to avoid redundant setStatus calls. */
254
169
  private lastStatusText: string | undefined;
170
+ /** Pending tool expansion state from onTerminalInput (push-based, no polling). */
171
+ private pendingToolsExpanded: boolean | undefined;
172
+
173
+ /** Whether to use compact mode (1-line per agent). */
174
+ private compactMode = false;
175
+
176
+ /** Whether "force compact" mode is ON — overrides ctrl+o shortcut. */
177
+ private forceCompact = false;
178
+
179
+ /** Whether ctrl+o shortcut is enabled (syncs compact with toolsExpanded). */
180
+ private widgetShortcut = false;
181
+
182
+ /** Maximum lines for full mode. */
183
+ private maxLines = DEFAULT_MAX_WIDGET_LINES;
184
+
185
+ /** Maximum lines for compact mode. */
186
+ private maxLinesCompact = Math.floor(DEFAULT_MAX_WIDGET_LINES / 2);
255
187
 
256
188
  constructor(
257
189
  private manager: AgentManager,
258
190
  private agentActivity: Map<string, AgentActivity>,
259
191
  ) {}
260
192
 
193
+ /** Set whether to show cost in stats and status bar. */
194
+ setShowCost(enabled: boolean) {
195
+ this.showCost = enabled;
196
+ }
197
+
198
+ /** Set compact mode (internal, for sync from ctrl+o). */
199
+ setCompactMode(enabled: boolean) {
200
+ if (this.compactMode === enabled) return;
201
+ this.compactMode = enabled;
202
+ this.update();
203
+ }
204
+
205
+ /** Set force compact mode — overrides ctrl+o shortcut. */
206
+ setForceCompact(enabled: boolean) {
207
+ this.forceCompact = enabled;
208
+ }
209
+
210
+ /** Set whether ctrl+o shortcut is enabled. */
211
+ setWidgetShortcut(enabled: boolean) {
212
+ this.widgetShortcut = enabled;
213
+ }
214
+
215
+ /** Notify widget that tool expansion state changed (push-based, no polling). */
216
+ notifyToolsExpansionChanged(expanded: boolean) {
217
+ this.pendingToolsExpanded = expanded;
218
+ this.update();
219
+ }
220
+
221
+ /** Set max lines for full mode. */
222
+ setMaxLines(lines: number) {
223
+ this.maxLines = lines;
224
+ }
225
+
226
+ /** Set max lines for compact mode. */
227
+ setMaxLinesCompact(lines: number) {
228
+ this.maxLinesCompact = lines;
229
+ }
230
+
261
231
  /** Set the UI context (grabbed from first tool execution). */
262
232
  setUICtx(ctx: UICtx) {
263
233
  if (ctx !== this.uiCtx) {
@@ -297,9 +267,9 @@ export class AgentWidget {
297
267
  const queued: AgentRecord[] = [];
298
268
  const finished: AgentRecord[] = [];
299
269
  for (const a of allAgents) {
300
- if (a.status === "running") running.push(a);
301
- else if (a.status === "queued") queued.push(a);
302
- else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) finished.push(a);
270
+ if (a.lifecycle.status === "running") running.push(a);
271
+ else if (a.lifecycle.status === "queued") queued.push(a);
272
+ else if (a.lifecycle.completedAt && this.shouldShowFinished(a.id, a.lifecycle.status)) finished.push(a);
303
273
  }
304
274
  return { running, queued, finished };
305
275
  }
@@ -342,47 +312,44 @@ export class AgentWidget {
342
312
  }
343
313
 
344
314
  /** Render a finished agent line. */
345
- private renderFinishedLine(a: {
346
- id: string; type: SubagentType; status: string; description: string;
347
- toolUses: number; startedAt: number; completedAt?: number; error?: string;
348
- compactionCount: number; lifetimeUsage: LifetimeUsage;
349
- turnCount?: number; maxTurns?: number; session?: SessionLike;
350
- outputFile?: string;
351
- }, theme: Theme): string {
352
- const name = getDisplayName(a.type);
353
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
354
- const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
315
+ private renderFinishedLine(a: AgentRecord, theme: Theme): string {
316
+ const name = getDisplayName(a.display.type);
317
+ const duration = formatMs((a.lifecycle.completedAt ?? Date.now()) - a.lifecycle.startedAt);
318
+ const { icon, statusText } = this.finishedIconAndStatus(a.lifecycle.status, a.error, theme);
355
319
 
356
320
  const activity = this.agentActivity.get(a.id);
321
+ const usage = activity?.lifetimeUsage ?? a.stats.lifetimeUsage;
357
322
  const statsParts = buildStatsParts({
358
- toolUses: a.toolUses,
359
- turnCount: activity?.turnCount ?? a.turnCount,
360
- maxTurns: activity?.maxTurns ?? a.maxTurns,
361
- tokens: getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage),
362
- contextPercent: getSessionContextPercent(activity?.session ?? a.session),
363
- compactions: a.compactionCount,
323
+ toolUses: a.stats.toolUses,
324
+ turnCount: activity?.turnCount ?? a.stats.turnCount,
325
+ maxTurns: activity?.maxTurns ?? a.stats.maxTurns,
326
+ tokens: getLifetimeTotal(usage),
327
+ contextPercent: activity?.session ? getSessionContextPercent(activity.session) : a.stats.contextPercent ?? null,
328
+ compactions: a.stats.compactionCount,
329
+ cost: this.showCost ? usage.cost : undefined,
364
330
  }, theme);
365
331
  statsParts.push(duration);
366
332
 
367
333
  const statsLine = statsParts.join("·");
368
- return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
334
+ return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.display.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
369
335
  }
370
336
 
371
- /** Build the stats line (toolUses · turns · tokens · elapsed) for a running agent. */
337
+ /** Build the stats line (toolUses · turns · tokens · cost · elapsed) for a running agent. */
372
338
  private buildStatsLine(
373
- agent: { toolUses: number; compactionCount: number; startedAt: number },
339
+ agent: AgentRecord,
374
340
  activity: AgentActivity | undefined,
375
341
  theme: Theme,
376
342
  ): string {
377
343
  const parts = buildStatsParts({
378
- toolUses: activity?.toolUses ?? agent.toolUses,
344
+ toolUses: activity?.toolUses ?? agent.stats.toolUses,
379
345
  turnCount: activity?.turnCount,
380
346
  maxTurns: activity?.maxTurns,
381
347
  tokens: getLifetimeTotal(activity?.lifetimeUsage),
382
348
  contextPercent: getSessionContextPercent(activity?.session),
383
- compactions: agent.compactionCount,
349
+ compactions: agent.stats.compactionCount,
350
+ cost: this.showCost ? activity?.lifetimeUsage?.cost : undefined,
384
351
  }, theme);
385
- parts.push(formatMs(Date.now() - agent.startedAt));
352
+ parts.push(formatMs(Date.now() - agent.lifecycle.startedAt));
386
353
  return parts.join("·");
387
354
  }
388
355
 
@@ -397,8 +364,8 @@ export class AgentWidget {
397
364
  for (const a of finished) {
398
365
  blocks.push({
399
366
  header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
400
- continuations: a.outputFile
401
- ? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
367
+ continuations: a.display.outputFile
368
+ ? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
402
369
  : [],
403
370
  });
404
371
  }
@@ -415,21 +382,32 @@ export class AgentWidget {
415
382
  const truncate = (line: string) => truncateToWidth(line, w);
416
383
  const blocks: RenderBlock[] = [];
417
384
  for (const a of running) {
418
- const name = getDisplayName(a.type);
385
+ const name = getDisplayName(a.display.type);
419
386
  const bg = this.agentActivity.get(a.id);
420
387
  const statsLine = this.buildStatsLine(a, bg, theme);
421
388
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
422
389
 
423
- const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`;
424
- blocks.push({
425
- header: truncate(headerLine),
426
- continuations: [
427
- ...(a.outputFile
428
- ? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
429
- : []),
430
- truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
431
- ],
432
- });
390
+ if (this.isCompact()) {
391
+ // Compact: single line with activity inline, truncated description
392
+ const desc = a.display.description.length > 30 ? a.display.description.slice(0, 27) + "..." : a.display.description;
393
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${desc} ${statsLine} ${theme.fg("dim", activity)}`;
394
+ blocks.push({
395
+ header: truncate(headerLine),
396
+ continuations: [],
397
+ });
398
+ } else {
399
+ // Full: header + continuation lines
400
+ const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.display.description} ${statsLine}`;
401
+ blocks.push({
402
+ header: truncate(headerLine),
403
+ continuations: [
404
+ ...(a.display.outputFile
405
+ ? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
406
+ : []),
407
+ truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
408
+ ],
409
+ });
410
+ }
433
411
  }
434
412
  return blocks;
435
413
  }
@@ -455,6 +433,11 @@ export class AgentWidget {
455
433
  * connectors in a single pass. Last visible block gets CORNER + spaces, all others
456
434
  * keep BRANCH + VLINE.
457
435
  */
436
+ /** Whether the widget should render in compact mode. */
437
+ private isCompact(): boolean {
438
+ return this.forceCompact || (this.widgetShortcut && this.compactMode);
439
+ }
440
+
458
441
  private renderWidget(tui: TUI, theme: Theme): string[] {
459
442
  const { running, queued, finished } = this.categorizeAgents();
460
443
 
@@ -485,7 +468,8 @@ export class AgentWidget {
485
468
 
486
469
  // ---- Overflow logic (works with blocks, not lines) ----
487
470
 
488
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
471
+ const maxBodyLines = this.isCompact() ? this.maxLinesCompact : this.maxLines;
472
+ const maxBody = maxBodyLines - 1; // heading takes 1 line
489
473
  const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
490
474
 
491
475
  const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`;
@@ -601,21 +585,40 @@ export class AgentWidget {
601
585
  }
602
586
 
603
587
  /** Update the status bar text, only if it changed. */
604
- private updateStatusBar(runningCount: number, queuedCount: number) {
605
- const statusParts: string[] = [];
606
- if (runningCount > 0) statusParts.push(`${runningCount} running`);
607
- if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
588
+ private updateStatusBar(runningCount: number, queuedCount: number, running: AgentRecord[]) {
608
589
  const total = runningCount + queuedCount;
609
- const newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
610
- if (newStatusText !== this.lastStatusText) {
611
- this.uiCtx?.setStatus(STATUS_KEY, newStatusText);
612
- this.lastStatusText = newStatusText;
590
+ let statusText = total > 0 ? `${total} agent${total === 1 ? "" : "s"}` : `agents`;
591
+ if (this.showCost) {
592
+ const sessionCost = this.manager.getTotalAgentCost();
593
+ // Also include in-flight running agents (not yet completed, so not in accumulator)
594
+ const runningCost = running.reduce((sum, a) => sum + a.stats.lifetimeUsage.cost, 0);
595
+ const totalCost = sessionCost + runningCost;
596
+ if (totalCost > 0) statusText += `: ${formatCost(totalCost)}`;
597
+ }
598
+ if (statusText !== this.lastStatusText) {
599
+ this.uiCtx?.setStatus(STATUS_KEY, statusText);
600
+ this.lastStatusText = statusText;
613
601
  }
614
602
  }
615
603
 
616
604
  /** Force an immediate widget update. */
617
605
  update() {
606
+ if (!this.manager) {
607
+ // Widget lost its manager reference (e.g., after session shutdown)
608
+ clearInterval(this.widgetInterval);
609
+ this.widgetInterval = undefined;
610
+ return;
611
+ }
618
612
  if (!this.uiCtx) return;
613
+
614
+ // Sync compact mode with tool expansion state (ctrl+o)
615
+ // Tools expanded → widget full, tools collapsed → widget compact
616
+ // Note: sync is triggered by onTerminalInput detecting ctrl+o, not polling
617
+ if (this.widgetShortcut && !this.forceCompact && this.pendingToolsExpanded !== undefined) {
618
+ this.compactMode = !this.pendingToolsExpanded;
619
+ this.pendingToolsExpanded = undefined;
620
+ }
621
+
619
622
  const { running, queued, finished } = this.categorizeAgents();
620
623
 
621
624
  const hasActive = running.length > 0 || queued.length > 0;
@@ -628,7 +631,7 @@ export class AgentWidget {
628
631
  }
629
632
 
630
633
  // Status bar — only call setStatus when the text actually changes
631
- this.updateStatusBar(running.length, queued.length);
634
+ this.updateStatusBar(running.length, queued.length, running);
632
635
 
633
636
  this.widgetFrame++;
634
637
 
package/src/usage.ts CHANGED
@@ -36,6 +36,11 @@ export function formatTokens(count: number): string {
36
36
  return `${count}`;
37
37
  }
38
38
 
39
+ /** Format cost as a dollar amount: "$0.00", "$0.01", "$1.23". */
40
+ export function formatCost(cost: number): string {
41
+ return `$${cost.toFixed(2)}`;
42
+ }
43
+
39
44
  /**
40
45
  * Context-window utilization (0–100), or null when unavailable
41
46
  * (no model contextWindow, or post-compaction before the next response).
@@ -1,77 +0,0 @@
1
- /**
2
- * stop-agent-tool.ts — StopAgent tool execute handler.
3
- *
4
- * Registered in index.ts alongside the Agent tool.
5
- * Uses manager.abort(id) to stop running or queued agents.
6
- *
7
- * Response formats:
8
- * - Success: "Stopped agent <short_id>"
9
- * - Not found: "Agent <id> not found. Running agents: <type>·<short_id>, ..."
10
- * - Already terminal: "Agent <id> is already <status>. Running agents: ..."
11
- */
12
-
13
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { successResult, errorResult } from "./tool-execution.js";
15
- import { manager } from "./index.js";
16
- import { SHORT_ID_LENGTH } from "./types.js";
17
-
18
- // ============================================================================
19
- // Running agents list helper
20
- // ============================================================================
21
-
22
- /**
23
- * Build a compact list of running (or queued) agents.
24
- * Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
25
- */
26
- function formatRunningAgents(): string {
27
- const agents = manager.listAgents().filter(
28
- (a) => a.status === "running" || a.status === "queued",
29
- );
30
-
31
- if (agents.length === 0) return "none";
32
-
33
- return agents
34
- .map((a) => `${a.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
35
- .join(", ");
36
- }
37
-
38
- // ============================================================================
39
- // Execute handler
40
- // ============================================================================
41
-
42
- export async function executeStopAgentTool(
43
- _toolCallId: string,
44
- params: Record<string, unknown>,
45
- _signal: AbortSignal | undefined,
46
- _onUpdate: ((update: any) => void) | undefined,
47
- _ctx: ExtensionContext,
48
- ): Promise<any> {
49
- const agentId = params.agent_id as string | undefined;
50
-
51
- if (!agentId) {
52
- return errorResult("agent_id is required");
53
- }
54
-
55
- const record = manager.getRecord(agentId);
56
-
57
- if (!record) {
58
- // Agent not found → return error + list of running agents
59
- return errorResult(
60
- `Agent ${agentId} not found. Running agents: ${formatRunningAgents()}`,
61
- );
62
- }
63
-
64
- // Check if already in a terminal state (not running or queued)
65
- if (record.status !== "running" && record.status !== "queued") {
66
- return successResult(
67
- `Agent ${agentId} is already ${record.status}. Running agents: ${formatRunningAgents()}`,
68
- );
69
- }
70
-
71
- // Attempt to stop the running/queued agent
72
- if (manager.abort(agentId)) {
73
- return successResult(`Stopped agent ${agentId.slice(0, SHORT_ID_LENGTH)}`);
74
- }
75
-
76
- return errorResult(`Failed to stop agent ${agentId}`);
77
- }