codecartographer-pi 0.1.4 → 0.6.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,169 @@
1
+ // Sub-agent runner for codecarto phases. Spawns an in-memory AgentSession
2
+ // using the SDK's createAgentSession() (NOT ctx.newSession() — that replaces
3
+ // the active TUI session, which is not what we want). Subscribes to the
4
+ // session's event stream and forwards events to caller-provided callbacks
5
+ // so a parent UI (the agents widget; M2) can render live progress while the
6
+ // phase runs in parallel with the orchestrator.
7
+ //
8
+ // The runner is intentionally minimal: no memory tools, no append-mode
9
+ // system prompt, no parent-context inheritance, no turn-limit grace logic.
10
+ // Codecarto phases are bounded by their phase prompt and validation gate;
11
+ // they don't need the full subagent-framework machinery.
12
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
13
+ // Tools available to the phase sub-agent. Matches the codecarto interception
14
+ // allowlist (SAFE_TOOL_NAMES in extensions/codecarto/index.ts), minus bash.
15
+ // Phases analyze source code and write findings; they don't need a shell.
16
+ const PHASE_TOOL_NAMES = ["read", "edit", "write", "grep", "find", "ls"];
17
+ /**
18
+ * Run one CodeCartographer phase as an isolated AgentSession. Awaiting this
19
+ * function blocks until the phase completes (or aborts via signal). The
20
+ * orchestrator's TUI stays active throughout — only the phase's own context
21
+ * window holds the tool calls and reasoning.
22
+ *
23
+ * The phase session is **persisted** to the default Pi session directory
24
+ * (`~/.pi/agent/sessions/<encoded-cwd>/`), the same directory the orchestrator
25
+ * uses, so Pi's `/resume`, `/tree`, and `/export` see phase transcripts as
26
+ * first-class sessions. They're tagged via `appendSessionInfo` (display name)
27
+ * and `parentSession` (the orchestrator's session file path) so the picker
28
+ * shows lineage.
29
+ */
30
+ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal) {
31
+ const cwd = ctx.cwd;
32
+ const agentDir = getAgentDir();
33
+ // Resource loader: load Pi extensions and skills (so codecarto's own tool
34
+ // interception applies to the child) but skip prompt templates, themes,
35
+ // and project context files — they'd just bloat the system prompt.
36
+ const loader = new DefaultResourceLoader({
37
+ cwd,
38
+ agentDir,
39
+ noExtensions: false,
40
+ noSkills: false,
41
+ noPromptTemplates: true,
42
+ noThemes: true,
43
+ noContextFiles: true,
44
+ });
45
+ await loader.reload();
46
+ // File-backed session in the same directory the orchestrator's TUI uses.
47
+ // Pi's /resume, /tree, and /export read this directory, so phase
48
+ // transcripts become first-class browsable artifacts. Tag with
49
+ // parentSession (orchestrator's file) for lineage and a session_info
50
+ // display name so the picker can identify them at a glance.
51
+ const sessionManager = SessionManager.create(cwd);
52
+ const orchestratorSessionFile = ctx.sessionManager.getSessionFile();
53
+ if (orchestratorSessionFile) {
54
+ // SessionManager.create() calls newSession() with no options in its
55
+ // constructor; rewrite the header to attach the parent before the
56
+ // session ever flushes to disk.
57
+ sessionManager.newSession({ parentSession: orchestratorSessionFile });
58
+ }
59
+ if (options.sessionName) {
60
+ sessionManager.appendSessionInfo(options.sessionName);
61
+ }
62
+ const { session } = await createAgentSession({
63
+ cwd,
64
+ agentDir,
65
+ sessionManager,
66
+ settingsManager: SettingsManager.create(cwd, agentDir),
67
+ modelRegistry: ctx.modelRegistry,
68
+ model: ctx.model,
69
+ tools: PHASE_TOOL_NAMES,
70
+ resourceLoader: loader,
71
+ });
72
+ await session.bindExtensions({});
73
+ callbacks.onSessionCreated?.(session);
74
+ let toolUses = 0;
75
+ let turnCount = 0;
76
+ let currentMessageText = "";
77
+ let aborted = false;
78
+ const unsubscribe = session.subscribe((event) => {
79
+ switch (event.type) {
80
+ case "tool_execution_start": {
81
+ toolUses++;
82
+ const id = event.toolCallId ?? `${event.toolName}-${toolUses}`;
83
+ callbacks.onToolStart?.(id, event.toolName);
84
+ break;
85
+ }
86
+ case "tool_execution_end": {
87
+ const id = event.toolCallId ?? `${event.toolName}-${toolUses}`;
88
+ callbacks.onToolEnd?.(id, event.toolName);
89
+ break;
90
+ }
91
+ case "turn_end": {
92
+ turnCount++;
93
+ callbacks.onTurnEnd?.(turnCount);
94
+ break;
95
+ }
96
+ case "message_start": {
97
+ currentMessageText = "";
98
+ break;
99
+ }
100
+ case "message_update": {
101
+ if (event.assistantMessageEvent?.type === "text_delta") {
102
+ currentMessageText += event.assistantMessageEvent.delta;
103
+ callbacks.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
104
+ }
105
+ break;
106
+ }
107
+ case "message_end": {
108
+ if (event.message.role === "assistant") {
109
+ const u = event.message.usage;
110
+ if (u) {
111
+ callbacks.onMessageEnd?.({
112
+ input: u.input ?? 0,
113
+ output: u.output ?? 0,
114
+ cacheWrite: u.cacheWrite ?? 0,
115
+ });
116
+ }
117
+ }
118
+ break;
119
+ }
120
+ }
121
+ });
122
+ let abortCleanup = () => { };
123
+ if (signal) {
124
+ const onAbort = () => {
125
+ aborted = true;
126
+ session.abort();
127
+ };
128
+ signal.addEventListener("abort", onAbort, { once: true });
129
+ abortCleanup = () => signal.removeEventListener("abort", onAbort);
130
+ }
131
+ try {
132
+ await session.prompt(prompt);
133
+ }
134
+ finally {
135
+ unsubscribe();
136
+ abortCleanup();
137
+ }
138
+ return {
139
+ session,
140
+ responseText: getLastAssistantText(session) || currentMessageText,
141
+ toolUses,
142
+ turnCount,
143
+ aborted,
144
+ sessionFile: sessionManager.getSessionFile(),
145
+ };
146
+ }
147
+ /**
148
+ * Walk session.messages backward to find the last non-empty assistant text.
149
+ * Used as a fallback when text_delta streaming missed something or the final
150
+ * message arrived in a single chunk.
151
+ */
152
+ function getLastAssistantText(session) {
153
+ for (let i = session.messages.length - 1; i >= 0; i--) {
154
+ const msg = session.messages[i];
155
+ if (msg.role !== "assistant")
156
+ continue;
157
+ // Assistant content is always a content-block array per SDK types.
158
+ const blocks = msg.content;
159
+ const parts = [];
160
+ for (const c of blocks) {
161
+ if (c.type === "text" && c.text)
162
+ parts.push(c.text);
163
+ }
164
+ const joined = parts.join("\n").trim();
165
+ if (joined)
166
+ return joined;
167
+ }
168
+ return "";
169
+ }
@@ -0,0 +1,35 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ export type PhaseStatus = "running" | "completed" | "error" | "aborted";
3
+ export interface PhaseActivity {
4
+ phaseId: string;
5
+ status: PhaseStatus;
6
+ startedAt: number;
7
+ completedAt?: number;
8
+ turnCount: number;
9
+ toolUses: number;
10
+ /** Currently-executing tool calls keyed by toolCallId, value is the tool name. */
11
+ activeTools: Map<string, string>;
12
+ /** Latest streaming response text from the assistant (for "thinking…" UI). */
13
+ responseText: string;
14
+ /** Cumulative token usage across all assistant turns; survives in-session compaction. */
15
+ lifetimeUsage: {
16
+ input: number;
17
+ output: number;
18
+ cacheWrite: number;
19
+ };
20
+ session?: AgentSession;
21
+ error?: string;
22
+ }
23
+ export declare function getPhaseActivity(phaseId: string): PhaseActivity | undefined;
24
+ export declare function listPhaseActivity(): PhaseActivity[];
25
+ export declare function startPhase(phaseId: string): PhaseActivity;
26
+ export declare function finishPhase(phaseId: string, outcome: {
27
+ status: Exclude<PhaseStatus, "running">;
28
+ error?: string;
29
+ }): void;
30
+ /**
31
+ * Clear a phase from the activity map. The widget (M2) will linger finished
32
+ * phases for a turn or two before calling this; for now (M1) we clear after
33
+ * a fixed timeout so the orchestrator notification stays meaningful.
34
+ */
35
+ export declare function clearPhase(phaseId: string): void;
@@ -0,0 +1,46 @@
1
+ // Shared, per-extension-instance phase activity state. The runner mutates
2
+ // it from session-event callbacks; the agents widget (M2) reads it on each
3
+ // render. Module-scoped Map so different command handlers can hand work to
4
+ // the runner and the widget sees the same state without explicit plumbing.
5
+ const phaseActivity = new Map();
6
+ export function getPhaseActivity(phaseId) {
7
+ return phaseActivity.get(phaseId);
8
+ }
9
+ export function listPhaseActivity() {
10
+ return [...phaseActivity.values()];
11
+ }
12
+ export function startPhase(phaseId) {
13
+ const existing = phaseActivity.get(phaseId);
14
+ if (existing && existing.status === "running")
15
+ return existing;
16
+ const activity = {
17
+ phaseId,
18
+ status: "running",
19
+ startedAt: Date.now(),
20
+ turnCount: 0,
21
+ toolUses: 0,
22
+ activeTools: new Map(),
23
+ responseText: "",
24
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
25
+ };
26
+ phaseActivity.set(phaseId, activity);
27
+ return activity;
28
+ }
29
+ export function finishPhase(phaseId, outcome) {
30
+ const activity = phaseActivity.get(phaseId);
31
+ if (!activity)
32
+ return;
33
+ activity.status = outcome.status;
34
+ activity.completedAt = Date.now();
35
+ if (outcome.error)
36
+ activity.error = outcome.error;
37
+ activity.activeTools.clear();
38
+ }
39
+ /**
40
+ * Clear a phase from the activity map. The widget (M2) will linger finished
41
+ * phases for a turn or two before calling this; for now (M1) we clear after
42
+ * a fixed timeout so the orchestrator notification stays meaningful.
43
+ */
44
+ export function clearPhase(phaseId) {
45
+ phaseActivity.delete(phaseId);
46
+ }
@@ -0,0 +1,17 @@
1
+ import type { PhaseActivity } from "./agent-state.ts";
2
+ export interface PhaseSummaryInput {
3
+ phaseId: string;
4
+ status: PhaseActivity["status"];
5
+ turnCount: number;
6
+ toolUses: number;
7
+ tokens: {
8
+ input: number;
9
+ output: number;
10
+ cacheWrite: number;
11
+ };
12
+ durationMs: number;
13
+ responseText: string;
14
+ sessionFile?: string;
15
+ error?: string;
16
+ }
17
+ export declare function buildPhaseSummary(input: PhaseSummaryInput): string;
@@ -0,0 +1,79 @@
1
+ // Build the markdown summary that's injected into the orchestrator's session
2
+ // when a phase sub-agent finishes. The summary is a CustomMessageEntry
3
+ // (display: true, no triggerTurn) so the user sees it in the TUI and the
4
+ // orchestrator's LLM picks it up as context on the user's next turn.
5
+ const RESPONSE_TEXT_CHAR_BUDGET = 2000;
6
+ export function buildPhaseSummary(input) {
7
+ const stats = formatStats(input);
8
+ const headerLine = formatHeader(input.phaseId, input.status);
9
+ if (input.status === "error") {
10
+ const errorLine = input.error ? `\n\n\`${input.error}\`` : "";
11
+ return `${headerLine}\n\n${stats}${errorLine}`;
12
+ }
13
+ const trailer = formatTrailer(input);
14
+ const excerpt = formatExcerpt(input.responseText);
15
+ const sections = [headerLine, stats];
16
+ if (excerpt)
17
+ sections.push(excerpt);
18
+ sections.push(trailer);
19
+ return sections.join("\n\n");
20
+ }
21
+ function formatHeader(phaseId, status) {
22
+ switch (status) {
23
+ case "completed":
24
+ return `**Phase \`${phaseId}\` finished.**`;
25
+ case "aborted":
26
+ return `**Phase \`${phaseId}\` aborted.**`;
27
+ case "error":
28
+ return `**Phase \`${phaseId}\` failed.**`;
29
+ default:
30
+ return `**Phase \`${phaseId}\`.**`;
31
+ }
32
+ }
33
+ function formatStats(input) {
34
+ const parts = [];
35
+ if (input.turnCount > 0)
36
+ parts.push(`⟳ ${input.turnCount}`);
37
+ if (input.toolUses > 0)
38
+ parts.push(`${input.toolUses} tool use${input.toolUses === 1 ? "" : "s"}`);
39
+ const totalTokens = input.tokens.input + input.tokens.output;
40
+ if (totalTokens > 0)
41
+ parts.push(formatTokens(totalTokens));
42
+ if (input.durationMs > 0)
43
+ parts.push(formatDuration(input.durationMs));
44
+ return parts.length > 0 ? `_${parts.join(" · ")}_` : "_(no activity recorded)_";
45
+ }
46
+ function formatExcerpt(responseText) {
47
+ const trimmed = responseText.trim();
48
+ if (!trimmed)
49
+ return "";
50
+ if (trimmed.length <= RESPONSE_TEXT_CHAR_BUDGET)
51
+ return trimmed;
52
+ return `${trimmed.slice(0, RESPONSE_TEXT_CHAR_BUDGET).trimEnd()}…\n\n_(transcript truncated; resume the phase session for the full output)_`;
53
+ }
54
+ function formatTrailer(input) {
55
+ const lines = [];
56
+ if (input.sessionFile) {
57
+ lines.push(`Phase transcript: \`${input.sessionFile}\` (open via \`/resume\`).`);
58
+ }
59
+ if (input.status === "completed") {
60
+ lines.push("Run `/codecarto-validate` to check the output, then `/codecarto-complete` to advance.");
61
+ }
62
+ return lines.join("\n");
63
+ }
64
+ function formatTokens(count) {
65
+ if (count >= 1_000_000)
66
+ return `${(count / 1_000_000).toFixed(1)}M tokens`;
67
+ if (count >= 1_000)
68
+ return `${(count / 1_000).toFixed(1)}k tokens`;
69
+ return `${count} tokens`;
70
+ }
71
+ function formatDuration(ms) {
72
+ if (ms < 1000)
73
+ return `${ms}ms`;
74
+ if (ms < 60_000)
75
+ return `${(ms / 1000).toFixed(1)}s`;
76
+ const minutes = Math.floor(ms / 60_000);
77
+ const seconds = Math.floor((ms % 60_000) / 1000);
78
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
79
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ declare class CodecartoAgentsWidget {
3
+ private uiCtx;
4
+ private timer;
5
+ private spinnerFrame;
6
+ private widgetRegistered;
7
+ private tui;
8
+ private lastStatus;
9
+ /** Tick counts for finished phases — used to age them out. */
10
+ private finishedAge;
11
+ /**
12
+ * Wire this widget to a UI context. Called from /codecarto-next when a
13
+ * phase starts. Idempotent — if already running on the same context the
14
+ * call is a no-op.
15
+ */
16
+ attach(uiCtx: ExtensionUIContext): void;
17
+ /** Tear down — called from extension dispose. */
18
+ dispose(): void;
19
+ private ensureTimer;
20
+ private update;
21
+ private unregister;
22
+ private renderLines;
23
+ }
24
+ export declare function getAgentsWidget(): CodecartoAgentsWidget;
25
+ export declare function disposeAgentsWidget(): void;
26
+ export {};
@@ -0,0 +1,260 @@
1
+ // Persistent "Agents" widget rendered above the editor while CodeCartographer
2
+ // phase sub-agents are running. Reads the shared agent-state map every ~80ms
3
+ // to update the spinner and per-phase stats; unregisters itself when no phase
4
+ // is active and any finished phases have lingered out.
5
+ //
6
+ // Pattern adapted from @tintinweb/pi-subagents (src/ui/agent-widget.ts).
7
+ // Slimmed down: codecarto runs at most a small number of phases sequentially
8
+ // per workflow, so the overflow/queue logic in tintinweb's version is dropped.
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { listPhaseActivity } from "./agent-state.js";
11
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
+ const WIDGET_KEY = "codecarto-agents";
13
+ const STATUS_KEY = "codecarto-agents-status";
14
+ const TICK_MS = 80;
15
+ /** How many ticks a finished phase lingers in the widget before removal. */
16
+ const FINISHED_LINGER_TICKS = 80; // ~6.4s at 80ms
17
+ const TOOL_DISPLAY = {
18
+ read: "reading",
19
+ edit: "editing",
20
+ write: "writing",
21
+ grep: "searching",
22
+ find: "finding files",
23
+ ls: "listing",
24
+ };
25
+ class CodecartoAgentsWidget {
26
+ uiCtx;
27
+ timer;
28
+ spinnerFrame = 0;
29
+ widgetRegistered = false;
30
+ tui;
31
+ lastStatus;
32
+ /** Tick counts for finished phases — used to age them out. */
33
+ finishedAge = new Map();
34
+ /**
35
+ * Wire this widget to a UI context. Called from /codecarto-next when a
36
+ * phase starts. Idempotent — if already running on the same context the
37
+ * call is a no-op.
38
+ */
39
+ attach(uiCtx) {
40
+ if (this.uiCtx === uiCtx && this.timer !== undefined)
41
+ return;
42
+ this.uiCtx = uiCtx;
43
+ this.widgetRegistered = false;
44
+ this.tui = undefined;
45
+ this.lastStatus = undefined;
46
+ this.ensureTimer();
47
+ this.update();
48
+ }
49
+ /** Tear down — called from extension dispose. */
50
+ dispose() {
51
+ if (this.timer) {
52
+ clearInterval(this.timer);
53
+ this.timer = undefined;
54
+ }
55
+ if (this.uiCtx) {
56
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
57
+ this.uiCtx.setStatus(STATUS_KEY, undefined);
58
+ }
59
+ this.widgetRegistered = false;
60
+ this.tui = undefined;
61
+ this.lastStatus = undefined;
62
+ this.finishedAge.clear();
63
+ }
64
+ ensureTimer() {
65
+ if (this.timer)
66
+ return;
67
+ this.timer = setInterval(() => this.update(), TICK_MS);
68
+ }
69
+ update() {
70
+ if (!this.uiCtx)
71
+ return;
72
+ const all = listPhaseActivity();
73
+ const running = all.filter((a) => a.status === "running");
74
+ const finished = all.filter((a) => a.status !== "running");
75
+ // Age finished phases. Drop those past the linger threshold.
76
+ for (const a of finished) {
77
+ const age = (this.finishedAge.get(a.phaseId) ?? 0) + 1;
78
+ this.finishedAge.set(a.phaseId, age);
79
+ }
80
+ // Forget aged-out phases (visual only — agent-state owns the actual map).
81
+ const visibleFinished = finished.filter((a) => (this.finishedAge.get(a.phaseId) ?? 0) <= FINISHED_LINGER_TICKS);
82
+ // Drop entries for phases that are no longer in the activity map.
83
+ for (const id of [...this.finishedAge.keys()]) {
84
+ if (!all.some((a) => a.phaseId === id))
85
+ this.finishedAge.delete(id);
86
+ }
87
+ const hasActive = running.length > 0;
88
+ const hasContent = hasActive || visibleFinished.length > 0;
89
+ if (!hasContent) {
90
+ this.unregister();
91
+ return;
92
+ }
93
+ const newStatus = hasActive
94
+ ? `${running.length} CodeCartographer phase${running.length === 1 ? "" : "s"} running`
95
+ : undefined;
96
+ if (newStatus !== this.lastStatus) {
97
+ this.uiCtx.setStatus(STATUS_KEY, newStatus);
98
+ this.lastStatus = newStatus;
99
+ }
100
+ this.spinnerFrame++;
101
+ if (!this.widgetRegistered) {
102
+ this.uiCtx.setWidget(WIDGET_KEY, (tui, theme) => {
103
+ this.tui = tui;
104
+ return {
105
+ render: () => this.renderLines(tui, theme),
106
+ invalidate: () => {
107
+ this.widgetRegistered = false;
108
+ this.tui = undefined;
109
+ },
110
+ };
111
+ }, { placement: "aboveEditor" });
112
+ this.widgetRegistered = true;
113
+ }
114
+ else {
115
+ this.tui?.requestRender();
116
+ }
117
+ }
118
+ unregister() {
119
+ if (!this.uiCtx)
120
+ return;
121
+ if (this.widgetRegistered) {
122
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
123
+ this.widgetRegistered = false;
124
+ this.tui = undefined;
125
+ }
126
+ if (this.lastStatus !== undefined) {
127
+ this.uiCtx.setStatus(STATUS_KEY, undefined);
128
+ this.lastStatus = undefined;
129
+ }
130
+ if (this.timer) {
131
+ clearInterval(this.timer);
132
+ this.timer = undefined;
133
+ }
134
+ this.finishedAge.clear();
135
+ }
136
+ renderLines(tui, theme) {
137
+ const all = listPhaseActivity();
138
+ if (all.length === 0)
139
+ return [];
140
+ const cols = tui.terminal?.columns ?? 80;
141
+ const truncate = (line) => truncateToWidth(line, cols);
142
+ const running = all.filter((a) => a.status === "running");
143
+ const finished = all
144
+ .filter((a) => a.status !== "running")
145
+ .filter((a) => (this.finishedAge.get(a.phaseId) ?? 0) <= FINISHED_LINGER_TICKS);
146
+ const headingColor = running.length > 0 ? "accent" : "dim";
147
+ const headingIcon = running.length > 0 ? "●" : "○";
148
+ const heading = `${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "CodeCartographer")}`;
149
+ const lines = [truncate(heading)];
150
+ const allEntries = [];
151
+ for (const a of running)
152
+ allEntries.push({ activity: a, isLast: false });
153
+ for (const a of finished)
154
+ allEntries.push({ activity: a, isLast: false });
155
+ for (let i = 0; i < allEntries.length; i++) {
156
+ allEntries[i].isLast = i === allEntries.length - 1;
157
+ }
158
+ for (const { activity, isLast } of allEntries) {
159
+ const connector = isLast ? "└─" : "├─";
160
+ const continuation = isLast ? " " : "│ ";
161
+ if (activity.status === "running") {
162
+ const frame = SPINNER[this.spinnerFrame % SPINNER.length];
163
+ const stats = formatRunningStats(activity);
164
+ const header = `${theme.fg("dim", connector)} ${theme.fg("accent", frame)} ${theme.bold(activity.phaseId)} phase ${theme.fg("dim", stats)}`;
165
+ const activityText = describeActivity(activity);
166
+ lines.push(truncate(header));
167
+ lines.push(truncate(`${theme.fg("dim", continuation)}${theme.fg("dim", ` ⎿ ${activityText}`)}`));
168
+ }
169
+ else {
170
+ const stats = formatFinishedStats(activity);
171
+ const icon = formatStatusIcon(activity.status, theme);
172
+ const dim = activity.status === "completed" ? "dim" : "warning";
173
+ const errSuffix = activity.error ? ` ${theme.fg("error", `– ${activity.error.slice(0, 60)}`)}` : "";
174
+ lines.push(truncate(`${theme.fg("dim", connector)} ${icon} ${theme.fg(dim, `${activity.phaseId} phase`)} ${theme.fg("dim", stats)}${errSuffix}`));
175
+ }
176
+ }
177
+ return lines;
178
+ }
179
+ }
180
+ function formatRunningStats(a) {
181
+ const parts = [];
182
+ if (a.turnCount > 0)
183
+ parts.push(`⟳ ${a.turnCount}`);
184
+ if (a.toolUses > 0)
185
+ parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
186
+ const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
187
+ if (tokens > 0)
188
+ parts.push(formatTokens(tokens));
189
+ parts.push(formatDuration(Date.now() - a.startedAt));
190
+ return parts.join(" · ");
191
+ }
192
+ function formatFinishedStats(a) {
193
+ const parts = [];
194
+ if (a.turnCount > 0)
195
+ parts.push(`⟳ ${a.turnCount}`);
196
+ if (a.toolUses > 0)
197
+ parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
198
+ const tokens = a.lifetimeUsage.input + a.lifetimeUsage.output;
199
+ if (tokens > 0)
200
+ parts.push(formatTokens(tokens));
201
+ const dur = a.completedAt ? a.completedAt - a.startedAt : 0;
202
+ if (dur > 0)
203
+ parts.push(formatDuration(dur));
204
+ return parts.join(" · ");
205
+ }
206
+ function formatTokens(count) {
207
+ if (count >= 1_000_000)
208
+ return `${(count / 1_000_000).toFixed(1)}M tokens`;
209
+ if (count >= 1_000)
210
+ return `${(count / 1_000).toFixed(1)}k tokens`;
211
+ return `${count} tokens`;
212
+ }
213
+ function formatDuration(ms) {
214
+ if (ms < 1000)
215
+ return `${ms}ms`;
216
+ if (ms < 60_000)
217
+ return `${(ms / 1000).toFixed(1)}s`;
218
+ const minutes = Math.floor(ms / 60_000);
219
+ const seconds = Math.floor((ms % 60_000) / 1000);
220
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
221
+ }
222
+ function formatStatusIcon(status, theme) {
223
+ switch (status) {
224
+ case "completed": return theme.fg("success", "✓");
225
+ case "aborted": return theme.fg("warning", "■");
226
+ case "error": return theme.fg("error", "✗");
227
+ default: return theme.fg("dim", "·");
228
+ }
229
+ }
230
+ function describeActivity(a) {
231
+ if (a.activeTools.size > 0) {
232
+ const counts = new Map();
233
+ for (const name of a.activeTools.values()) {
234
+ const action = TOOL_DISPLAY[name] ?? name;
235
+ counts.set(action, (counts.get(action) ?? 0) + 1);
236
+ }
237
+ const parts = [];
238
+ for (const [action, count] of counts) {
239
+ parts.push(count > 1 ? `${action} ×${count}` : action);
240
+ }
241
+ return `${parts.join(", ")}…`;
242
+ }
243
+ const text = a.responseText.split("\n").find((l) => l.trim()) ?? "";
244
+ if (text.trim().length > 0) {
245
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text;
246
+ }
247
+ return "thinking…";
248
+ }
249
+ let widgetSingleton;
250
+ export function getAgentsWidget() {
251
+ if (!widgetSingleton)
252
+ widgetSingleton = new CodecartoAgentsWidget();
253
+ return widgetSingleton;
254
+ }
255
+ export function disposeAgentsWidget() {
256
+ if (widgetSingleton) {
257
+ widgetSingleton.dispose();
258
+ widgetSingleton = undefined;
259
+ }
260
+ }