@clanker-code/pi-subagents 0.10.5
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/.plans/PLAN-next-changes.md +183 -0
- package/.plans/README.md +14 -0
- package/AGENTS.md +31 -0
- package/CHANGELOG.md +583 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/RELEASE.md +39 -0
- package/dist/abort-resend.d.ts +35 -0
- package/dist/abort-resend.js +71 -0
- package/dist/agent-details.d.ts +17 -0
- package/dist/agent-details.js +22 -0
- package/dist/agent-manager.d.ts +132 -0
- package/dist/agent-manager.js +493 -0
- package/dist/agent-runner.d.ts +165 -0
- package/dist/agent-runner.js +732 -0
- package/dist/agent-tool-description.d.ts +9 -0
- package/dist/agent-tool-description.js +147 -0
- package/dist/agent-types.d.ts +60 -0
- package/dist/agent-types.js +157 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +76 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +149 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +119 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1918 -0
- package/dist/invocation-config.d.ts +25 -0
- package/dist/invocation-config.js +19 -0
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/notifications.d.ts +6 -0
- package/dist/notifications.js +107 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +86 -0
- package/dist/peek.d.ts +37 -0
- package/dist/peek.js +121 -0
- package/dist/prompts.d.ts +40 -0
- package/dist/prompts.js +95 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +135 -0
- package/dist/settings.js +168 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.js +7 -0
- package/dist/ui/agent-tool-rendering.d.ts +34 -0
- package/dist/ui/agent-tool-rendering.js +154 -0
- package/dist/ui/agent-widget-tree.d.ts +33 -0
- package/dist/ui/agent-widget-tree.js +130 -0
- package/dist/ui/agent-widget.d.ts +156 -0
- package/dist/ui/agent-widget.js +408 -0
- package/dist/ui/conversation-viewer.d.ts +47 -0
- package/dist/ui/conversation-viewer.js +290 -0
- package/dist/ui/menu-select.d.ts +20 -0
- package/dist/ui/menu-select.js +46 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +99 -0
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/dist/wait.d.ts +10 -0
- package/dist/wait.js +37 -0
- package/dist/worktree.d.ts +45 -0
- package/dist/worktree.js +160 -0
- package/docs/design/default-extension-tool-exposure.md +56 -0
- package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
- package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
- package/examples/agent-tool-description.md +45 -0
- package/package.json +56 -0
- package/reviews/proposal-structured-output-schema.md +135 -0
- package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
- package/reviews/recursive-subagent-widget-preview.html +137 -0
- package/reviews/recursive-subagent-widget-preview.png +0 -0
- package/reviews/subagent-features-comparison.md +350 -0
- package/src/abort-resend.ts +75 -0
- package/src/agent-details.ts +31 -0
- package/src/agent-manager.ts +596 -0
- package/src/agent-runner.ts +872 -0
- package/src/agent-tool-description.ts +163 -0
- package/src/agent-types.ts +189 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +122 -0
- package/src/custom-agents.ts +160 -0
- package/src/default-agents.ts +123 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +2115 -0
- package/src/invocation-config.ts +42 -0
- package/src/memory.ts +165 -0
- package/src/model-resolver.ts +81 -0
- package/src/notifications.ts +120 -0
- package/src/output-file.ts +96 -0
- package/src/peek.ts +155 -0
- package/src/prompts.ts +129 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +289 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +195 -0
- package/src/ui/agent-tool-rendering.ts +175 -0
- package/src/ui/agent-widget-tree.ts +169 -0
- package/src/ui/agent-widget.ts +497 -0
- package/src/ui/conversation-viewer.ts +297 -0
- package/src/ui/menu-select.ts +68 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/usage.ts +60 -0
- package/src/wait.ts +44 -0
- package/src/worktree.ts +191 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
|
+
* Uses the callback form of setWidget for themed rendering.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
8
|
+
import type { AgentInvocation, SubagentType } from "../types.js";
|
|
9
|
+
import type { LifetimeUsage, SessionLike } from "../usage.js";
|
|
10
|
+
import { type WidgetAgentSnapshot, type WidgetDisplayMode } from "./agent-widget-tree.js";
|
|
11
|
+
/** Braille spinner frames for animated running indicator. */
|
|
12
|
+
export declare const SPINNER: string[];
|
|
13
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
14
|
+
export declare const ERROR_STATUSES: Set<string>;
|
|
15
|
+
export type Theme = {
|
|
16
|
+
fg(color: string, text: string): string;
|
|
17
|
+
bold(text: string): string;
|
|
18
|
+
};
|
|
19
|
+
export type UICtx = {
|
|
20
|
+
setStatus(key: string, text: string | undefined): void;
|
|
21
|
+
setWidget(key: string, content: undefined | ((tui: any, theme: Theme) => {
|
|
22
|
+
render(): string[];
|
|
23
|
+
invalidate(): void;
|
|
24
|
+
}), options?: {
|
|
25
|
+
placement?: "aboveEditor" | "belowEditor";
|
|
26
|
+
}): void;
|
|
27
|
+
};
|
|
28
|
+
/** Per-agent live activity state. */
|
|
29
|
+
export interface AgentActivity {
|
|
30
|
+
activeTools: Map<string, string>;
|
|
31
|
+
toolUses: number;
|
|
32
|
+
responseText: string;
|
|
33
|
+
session?: SessionLike;
|
|
34
|
+
/** Current turn count. */
|
|
35
|
+
turnCount: number;
|
|
36
|
+
/** Effective max turns for this agent (undefined = unlimited). */
|
|
37
|
+
maxTurns?: number;
|
|
38
|
+
/** Lifetime usage breakdown — see LifetimeUsage docs. */
|
|
39
|
+
lifetimeUsage: LifetimeUsage;
|
|
40
|
+
/** Last rendered activity description and the time it became current. */
|
|
41
|
+
activityDescription?: string;
|
|
42
|
+
activityDescriptionUpdatedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
/** Metadata attached to Agent tool results for custom rendering. */
|
|
45
|
+
export interface AgentDetails {
|
|
46
|
+
displayName: string;
|
|
47
|
+
description: string;
|
|
48
|
+
subagentType: string;
|
|
49
|
+
toolUses: number;
|
|
50
|
+
tokens: string;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
|
|
53
|
+
/** Human-readable description of what the agent is currently doing. */
|
|
54
|
+
activity?: string;
|
|
55
|
+
/** Current spinner frame index (for animated running indicator). */
|
|
56
|
+
spinnerFrame?: number;
|
|
57
|
+
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
|
|
58
|
+
modelName?: string;
|
|
59
|
+
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
|
|
60
|
+
tags?: string[];
|
|
61
|
+
/** Current turn count. */
|
|
62
|
+
turnCount?: number;
|
|
63
|
+
/** Effective max turns (undefined = unlimited). */
|
|
64
|
+
maxTurns?: number;
|
|
65
|
+
agentId?: string;
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
69
|
+
export declare function formatTokens(count: number): string;
|
|
70
|
+
/** Format a model context window: "200k", "1M". */
|
|
71
|
+
export declare function formatContextWindow(tokens: number): string;
|
|
72
|
+
/**
|
|
73
|
+
* Token count with optional context-fill % and compaction-count annotations.
|
|
74
|
+
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
75
|
+
* Compaction count rendered as `⇊N` in dim.
|
|
76
|
+
*
|
|
77
|
+
* "12.3k token" — no annotations
|
|
78
|
+
* "12.3k token (45%)" — percent only
|
|
79
|
+
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
|
|
80
|
+
* "12.3k token (45% · ⇊2)" — both
|
|
81
|
+
*/
|
|
82
|
+
export declare function formatSessionTokens(tokens: number, percent: number | null, theme: Theme, compactions?: number): string;
|
|
83
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
84
|
+
export declare function formatTurns(turnCount: number, maxTurns?: number | null): string;
|
|
85
|
+
/**
|
|
86
|
+
* Format milliseconds as a compact, humanized duration using the two largest
|
|
87
|
+
* relevant units: "12.3s", "5m 12s", "1h 5m". A bare seconds value keeps one
|
|
88
|
+
* decimal (matches the old output for sub-minute durations); larger units drop
|
|
89
|
+
* decimals so "12m 3s" never reads "12.0m 3s".
|
|
90
|
+
*/
|
|
91
|
+
export declare function formatMs(ms: number): string;
|
|
92
|
+
/** Format duration from start/completed timestamps. */
|
|
93
|
+
export declare function formatDuration(startedAt: number, completedAt?: number): string;
|
|
94
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
95
|
+
export declare function getDisplayName(type: SubagentType): string;
|
|
96
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
97
|
+
export declare function getPromptModeLabel(type: SubagentType): string | undefined;
|
|
98
|
+
/** Status bar text for the subagents entry, truncated to the available width. */
|
|
99
|
+
export declare function formatSubagentStatusText(runningCount: number, queuedCount: number, width?: number): string | undefined;
|
|
100
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
101
|
+
export declare function buildInvocationTags(invocation: AgentInvocation | undefined): {
|
|
102
|
+
modelName?: string;
|
|
103
|
+
tags: string[];
|
|
104
|
+
};
|
|
105
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
106
|
+
export declare function describeActivity(activeTools: Map<string, string>, responseText?: string): string;
|
|
107
|
+
export declare function describeActivityWithAge(activeTools: Map<string, string>, responseText?: string, updatedAt?: number, now?: number): string;
|
|
108
|
+
export declare class AgentWidget {
|
|
109
|
+
private manager;
|
|
110
|
+
private agentActivity;
|
|
111
|
+
private uiCtx;
|
|
112
|
+
private widgetFrame;
|
|
113
|
+
private widgetInterval;
|
|
114
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
115
|
+
private finishedTurnAge;
|
|
116
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
117
|
+
private static readonly ERROR_LINGER_TURNS;
|
|
118
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
119
|
+
private widgetRegistered;
|
|
120
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
121
|
+
private tui;
|
|
122
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
123
|
+
private lastStatusText;
|
|
124
|
+
/** Descendant snapshots observed from recursive child managers. */
|
|
125
|
+
private descendantSnapshots;
|
|
126
|
+
/** User-selected widget display mode. */
|
|
127
|
+
private displayMode;
|
|
128
|
+
constructor(manager: AgentManager, agentActivity: Map<string, AgentActivity>);
|
|
129
|
+
setDisplayMode(mode: WidgetDisplayMode): void;
|
|
130
|
+
upsertSnapshot(snapshot: WidgetAgentSnapshot): void;
|
|
131
|
+
removeSnapshot(id: string): void;
|
|
132
|
+
clearSnapshots(): void;
|
|
133
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
134
|
+
setUICtx(ctx: UICtx): void;
|
|
135
|
+
/**
|
|
136
|
+
* Called on each new turn (tool_execution_start).
|
|
137
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
138
|
+
*/
|
|
139
|
+
onTurnStart(): void;
|
|
140
|
+
/** Ensure the widget update timer is running. */
|
|
141
|
+
ensureTimer(): void;
|
|
142
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
143
|
+
private shouldShowFinished;
|
|
144
|
+
/** Record an agent as finished (call when agent completes). */
|
|
145
|
+
markFinished(agentId: string): void;
|
|
146
|
+
private recordToSnapshot;
|
|
147
|
+
private visibleSnapshots;
|
|
148
|
+
/**
|
|
149
|
+
* Render the widget content. Called from the registered widget's render() callback,
|
|
150
|
+
* reading live state each time instead of capturing it in a closure.
|
|
151
|
+
*/
|
|
152
|
+
private renderWidget;
|
|
153
|
+
/** Force an immediate widget update. */
|
|
154
|
+
update(): void;
|
|
155
|
+
dispose(): void;
|
|
156
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
|
+
* Uses the callback form of setWidget for themed rendering.
|
|
6
|
+
*/
|
|
7
|
+
import { getConfig } from "../agent-types.js";
|
|
8
|
+
import { renderAgentTree, } from "./agent-widget-tree.js";
|
|
9
|
+
// ---- Constants ----
|
|
10
|
+
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
11
|
+
const MAX_WIDGET_LINES = 12;
|
|
12
|
+
/** Default cap for the status-bar text before the widget knows the terminal width. */
|
|
13
|
+
const DEFAULT_STATUS_TEXT_WIDTH = 20;
|
|
14
|
+
/** Braille spinner frames for animated running indicator. */
|
|
15
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
17
|
+
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
18
|
+
/** Tool name → human-readable action for activity descriptions. */
|
|
19
|
+
const TOOL_DISPLAY = {
|
|
20
|
+
read: "reading",
|
|
21
|
+
bash: "running command",
|
|
22
|
+
edit: "editing",
|
|
23
|
+
write: "writing",
|
|
24
|
+
grep: "searching",
|
|
25
|
+
find: "finding files",
|
|
26
|
+
ls: "listing",
|
|
27
|
+
};
|
|
28
|
+
// ---- Formatting helpers ----
|
|
29
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
30
|
+
export function formatTokens(count) {
|
|
31
|
+
if (count >= 1_000_000)
|
|
32
|
+
return `${(count / 1_000_000).toFixed(1)}M token`;
|
|
33
|
+
if (count >= 1_000)
|
|
34
|
+
return `${(count / 1_000).toFixed(1)}k token`;
|
|
35
|
+
return `${count} token`;
|
|
36
|
+
}
|
|
37
|
+
/** Format a model context window: "200k", "1M". */
|
|
38
|
+
export function formatContextWindow(tokens) {
|
|
39
|
+
if (tokens >= 1_000_000)
|
|
40
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
41
|
+
if (tokens >= 1_000)
|
|
42
|
+
return `${Math.round(tokens / 1_000)}k`;
|
|
43
|
+
return String(tokens);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Token count with optional context-fill % and compaction-count annotations.
|
|
47
|
+
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
48
|
+
* Compaction count rendered as `⇊N` in dim.
|
|
49
|
+
*
|
|
50
|
+
* "12.3k token" — no annotations
|
|
51
|
+
* "12.3k token (45%)" — percent only
|
|
52
|
+
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
|
|
53
|
+
* "12.3k token (45% · ⇊2)" — both
|
|
54
|
+
*/
|
|
55
|
+
export function formatSessionTokens(tokens, percent, theme, compactions = 0) {
|
|
56
|
+
const tokenStr = formatTokens(tokens);
|
|
57
|
+
const annot = [];
|
|
58
|
+
if (percent !== null) {
|
|
59
|
+
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
|
60
|
+
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
61
|
+
}
|
|
62
|
+
if (compactions > 0) {
|
|
63
|
+
annot.push(theme.fg("dim", `⇊${compactions}`));
|
|
64
|
+
}
|
|
65
|
+
if (annot.length === 0)
|
|
66
|
+
return tokenStr;
|
|
67
|
+
return `${tokenStr} (${annot.join(" · ")})`;
|
|
68
|
+
}
|
|
69
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
70
|
+
export function formatTurns(turnCount, maxTurns) {
|
|
71
|
+
return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Format milliseconds as a compact, humanized duration using the two largest
|
|
75
|
+
* relevant units: "12.3s", "5m 12s", "1h 5m". A bare seconds value keeps one
|
|
76
|
+
* decimal (matches the old output for sub-minute durations); larger units drop
|
|
77
|
+
* decimals so "12m 3s" never reads "12.0m 3s".
|
|
78
|
+
*/
|
|
79
|
+
export function formatMs(ms) {
|
|
80
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
81
|
+
ms = 0;
|
|
82
|
+
const totalSeconds = ms / 1000;
|
|
83
|
+
if (totalSeconds < 60)
|
|
84
|
+
return `${totalSeconds.toFixed(1)}s`;
|
|
85
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
86
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
87
|
+
if (totalMinutes < 60)
|
|
88
|
+
return `${totalMinutes}m${seconds > 0 ? ` ${seconds}s` : ""}`;
|
|
89
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
90
|
+
const minutes = totalMinutes % 60;
|
|
91
|
+
return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
|
|
92
|
+
}
|
|
93
|
+
/** Format duration from start/completed timestamps. */
|
|
94
|
+
export function formatDuration(startedAt, completedAt) {
|
|
95
|
+
if (completedAt)
|
|
96
|
+
return formatMs(completedAt - startedAt);
|
|
97
|
+
return `${formatMs(Date.now() - startedAt)} (running)`;
|
|
98
|
+
}
|
|
99
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
100
|
+
export function getDisplayName(type) {
|
|
101
|
+
return getConfig(type).displayName;
|
|
102
|
+
}
|
|
103
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
104
|
+
export function getPromptModeLabel(type) {
|
|
105
|
+
const config = getConfig(type);
|
|
106
|
+
return config.promptMode === "append" ? "twin" : undefined;
|
|
107
|
+
}
|
|
108
|
+
function truncatePlainText(text, width) {
|
|
109
|
+
if (!Number.isFinite(width) || width <= 0)
|
|
110
|
+
return "";
|
|
111
|
+
const max = Math.floor(width);
|
|
112
|
+
if (text.length <= max)
|
|
113
|
+
return text;
|
|
114
|
+
if (max <= 1)
|
|
115
|
+
return "…".slice(0, max);
|
|
116
|
+
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
117
|
+
}
|
|
118
|
+
/** Status bar text for the subagents entry, truncated to the available width. */
|
|
119
|
+
export function formatSubagentStatusText(runningCount, queuedCount, width = DEFAULT_STATUS_TEXT_WIDTH) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
if (runningCount > 0)
|
|
122
|
+
parts.push(`${runningCount} running`);
|
|
123
|
+
if (queuedCount > 0)
|
|
124
|
+
parts.push(`${queuedCount} queued`);
|
|
125
|
+
if (parts.length === 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
const total = runningCount + queuedCount;
|
|
128
|
+
return truncatePlainText(`${parts.join(", ")} agent${total === 1 ? "" : "s"}`, width);
|
|
129
|
+
}
|
|
130
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
131
|
+
export function buildInvocationTags(invocation) {
|
|
132
|
+
const tags = [];
|
|
133
|
+
if (!invocation)
|
|
134
|
+
return { tags };
|
|
135
|
+
if (invocation.thinking)
|
|
136
|
+
tags.push(`thinking: ${invocation.thinking}`);
|
|
137
|
+
if (invocation.isolated)
|
|
138
|
+
tags.push("isolated");
|
|
139
|
+
if (invocation.isolation === "worktree")
|
|
140
|
+
tags.push("worktree");
|
|
141
|
+
if (invocation.inheritContext)
|
|
142
|
+
tags.push("inherit context");
|
|
143
|
+
if (invocation.maxTurns != null)
|
|
144
|
+
tags.push(`max turns: ${invocation.maxTurns}`);
|
|
145
|
+
if (invocation.depth != null)
|
|
146
|
+
tags.push(`depth: ${invocation.depth}/${invocation.maxDepth ?? 4}`);
|
|
147
|
+
return { modelName: invocation.modelName, tags };
|
|
148
|
+
}
|
|
149
|
+
/** Truncate text to a single line, max `len` chars. */
|
|
150
|
+
function truncateLine(text, len = 60) {
|
|
151
|
+
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
152
|
+
if (line.length <= len)
|
|
153
|
+
return line;
|
|
154
|
+
return line.slice(0, len) + "…";
|
|
155
|
+
}
|
|
156
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
157
|
+
export function describeActivity(activeTools, responseText) {
|
|
158
|
+
if (activeTools.size > 0) {
|
|
159
|
+
const groups = new Map();
|
|
160
|
+
for (const toolName of activeTools.values()) {
|
|
161
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
162
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
163
|
+
}
|
|
164
|
+
const parts = [];
|
|
165
|
+
for (const [action, count] of groups) {
|
|
166
|
+
if (count > 1) {
|
|
167
|
+
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
parts.push(action);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return parts.join(", ") + "…";
|
|
174
|
+
}
|
|
175
|
+
// No tools active — show truncated response text if available
|
|
176
|
+
if (responseText && responseText.trim().length > 0) {
|
|
177
|
+
return truncateLine(responseText);
|
|
178
|
+
}
|
|
179
|
+
return "thinking…";
|
|
180
|
+
}
|
|
181
|
+
export function describeActivityWithAge(activeTools, responseText, updatedAt, now = Date.now()) {
|
|
182
|
+
const activity = describeActivity(activeTools, responseText);
|
|
183
|
+
if (updatedAt == null)
|
|
184
|
+
return activity;
|
|
185
|
+
return `${activity} · ${formatMs(now - updatedAt)}`;
|
|
186
|
+
}
|
|
187
|
+
// ---- Widget manager ----
|
|
188
|
+
export class AgentWidget {
|
|
189
|
+
manager;
|
|
190
|
+
agentActivity;
|
|
191
|
+
uiCtx;
|
|
192
|
+
widgetFrame = 0;
|
|
193
|
+
widgetInterval;
|
|
194
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
195
|
+
finishedTurnAge = new Map();
|
|
196
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
197
|
+
static ERROR_LINGER_TURNS = 2;
|
|
198
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
199
|
+
widgetRegistered = false;
|
|
200
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
201
|
+
tui;
|
|
202
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
203
|
+
lastStatusText;
|
|
204
|
+
/** Descendant snapshots observed from recursive child managers. */
|
|
205
|
+
descendantSnapshots = new Map();
|
|
206
|
+
/** User-selected widget display mode. */
|
|
207
|
+
displayMode = "auto";
|
|
208
|
+
constructor(manager, agentActivity) {
|
|
209
|
+
this.manager = manager;
|
|
210
|
+
this.agentActivity = agentActivity;
|
|
211
|
+
}
|
|
212
|
+
setDisplayMode(mode) {
|
|
213
|
+
this.displayMode = mode;
|
|
214
|
+
this.update();
|
|
215
|
+
}
|
|
216
|
+
upsertSnapshot(snapshot) {
|
|
217
|
+
this.descendantSnapshots.set(snapshot.id, snapshot);
|
|
218
|
+
if (snapshot.status !== "running" && snapshot.status !== "queued") {
|
|
219
|
+
this.markFinished(snapshot.id);
|
|
220
|
+
}
|
|
221
|
+
this.update();
|
|
222
|
+
}
|
|
223
|
+
removeSnapshot(id) {
|
|
224
|
+
this.descendantSnapshots.delete(id);
|
|
225
|
+
this.update();
|
|
226
|
+
}
|
|
227
|
+
clearSnapshots() {
|
|
228
|
+
this.descendantSnapshots.clear();
|
|
229
|
+
this.update();
|
|
230
|
+
}
|
|
231
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
232
|
+
setUICtx(ctx) {
|
|
233
|
+
if (ctx !== this.uiCtx) {
|
|
234
|
+
// UICtx changed — the widget registered on the old context is gone.
|
|
235
|
+
// Force re-registration on next update().
|
|
236
|
+
this.uiCtx = ctx;
|
|
237
|
+
this.widgetRegistered = false;
|
|
238
|
+
this.tui = undefined;
|
|
239
|
+
this.lastStatusText = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Called on each new turn (tool_execution_start).
|
|
244
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
245
|
+
*/
|
|
246
|
+
onTurnStart() {
|
|
247
|
+
// Age all finished agents
|
|
248
|
+
for (const [id, age] of this.finishedTurnAge) {
|
|
249
|
+
this.finishedTurnAge.set(id, age + 1);
|
|
250
|
+
}
|
|
251
|
+
// Trigger a widget refresh (will filter out expired agents)
|
|
252
|
+
this.update();
|
|
253
|
+
}
|
|
254
|
+
/** Ensure the widget update timer is running. */
|
|
255
|
+
ensureTimer() {
|
|
256
|
+
if (!this.widgetInterval) {
|
|
257
|
+
this.widgetInterval = setInterval(() => this.update(), 80);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
261
|
+
shouldShowFinished(agentId, status) {
|
|
262
|
+
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
263
|
+
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
|
|
264
|
+
return age < maxAge;
|
|
265
|
+
}
|
|
266
|
+
/** Record an agent as finished (call when agent completes). */
|
|
267
|
+
markFinished(agentId) {
|
|
268
|
+
if (!this.finishedTurnAge.has(agentId)) {
|
|
269
|
+
this.finishedTurnAge.set(agentId, 0);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
recordToSnapshot(a) {
|
|
273
|
+
const activity = this.agentActivity.get(a.id);
|
|
274
|
+
return {
|
|
275
|
+
id: a.id,
|
|
276
|
+
type: a.type,
|
|
277
|
+
description: a.description,
|
|
278
|
+
status: a.status,
|
|
279
|
+
startedAt: a.startedAt,
|
|
280
|
+
completedAt: a.completedAt,
|
|
281
|
+
error: a.error,
|
|
282
|
+
toolUses: activity?.toolUses ?? a.toolUses ?? 0,
|
|
283
|
+
depth: a.depth,
|
|
284
|
+
parentAgentId: a.parentAgentId,
|
|
285
|
+
invocation: a.invocation,
|
|
286
|
+
activity,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
visibleSnapshots() {
|
|
290
|
+
const merged = new Map(this.descendantSnapshots);
|
|
291
|
+
const allAgents = this.manager.listAgents();
|
|
292
|
+
for (const a of allAgents) {
|
|
293
|
+
if (a.status === "running" || a.status === "queued" || (a.completedAt && this.shouldShowFinished(a.id, a.status))) {
|
|
294
|
+
merged.set(a.id, this.recordToSnapshot(a));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const [id, snapshot] of merged) {
|
|
298
|
+
if (snapshot.status !== "running" && snapshot.status !== "queued" && snapshot.completedAt && !this.shouldShowFinished(id, snapshot.status)) {
|
|
299
|
+
merged.delete(id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return [...merged.values()];
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Render the widget content. Called from the registered widget's render() callback,
|
|
306
|
+
* reading live state each time instead of capturing it in a closure.
|
|
307
|
+
*/
|
|
308
|
+
renderWidget(tui, theme) {
|
|
309
|
+
const snapshots = this.visibleSnapshots();
|
|
310
|
+
if (snapshots.length === 0)
|
|
311
|
+
return [];
|
|
312
|
+
return renderAgentTree(snapshots, {
|
|
313
|
+
mode: this.displayMode,
|
|
314
|
+
width: tui.terminal.columns,
|
|
315
|
+
maxLines: MAX_WIDGET_LINES,
|
|
316
|
+
theme,
|
|
317
|
+
frame: SPINNER[this.widgetFrame % SPINNER.length],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Force an immediate widget update. */
|
|
321
|
+
update() {
|
|
322
|
+
if (!this.uiCtx)
|
|
323
|
+
return;
|
|
324
|
+
const allAgents = this.manager.listAgents();
|
|
325
|
+
const snapshots = this.visibleSnapshots();
|
|
326
|
+
// Lightweight existence checks — full categorization happens in renderWidget()
|
|
327
|
+
let runningCount = 0;
|
|
328
|
+
let queuedCount = 0;
|
|
329
|
+
let hasFinished = false;
|
|
330
|
+
for (const a of snapshots) {
|
|
331
|
+
if (a.status === "running") {
|
|
332
|
+
runningCount++;
|
|
333
|
+
}
|
|
334
|
+
else if (a.status === "queued") {
|
|
335
|
+
queuedCount++;
|
|
336
|
+
}
|
|
337
|
+
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) {
|
|
338
|
+
hasFinished = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
342
|
+
// Nothing to show — clear widget
|
|
343
|
+
if (!hasActive && !hasFinished) {
|
|
344
|
+
if (this.widgetRegistered) {
|
|
345
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
346
|
+
this.widgetRegistered = false;
|
|
347
|
+
this.tui = undefined;
|
|
348
|
+
}
|
|
349
|
+
if (this.lastStatusText !== undefined) {
|
|
350
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
351
|
+
this.lastStatusText = undefined;
|
|
352
|
+
}
|
|
353
|
+
if (this.widgetInterval) {
|
|
354
|
+
clearInterval(this.widgetInterval);
|
|
355
|
+
this.widgetInterval = undefined;
|
|
356
|
+
}
|
|
357
|
+
// Clean up stale entries
|
|
358
|
+
for (const [id] of this.finishedTurnAge) {
|
|
359
|
+
if (!allAgents.some(a => a.id === id))
|
|
360
|
+
this.finishedTurnAge.delete(id);
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Status bar — only call setStatus when the text actually changes
|
|
365
|
+
const statusWidth = this.tui?.terminal.columns ?? DEFAULT_STATUS_TEXT_WIDTH;
|
|
366
|
+
const newStatusText = hasActive
|
|
367
|
+
? formatSubagentStatusText(runningCount, queuedCount, statusWidth)
|
|
368
|
+
: undefined;
|
|
369
|
+
if (newStatusText !== this.lastStatusText) {
|
|
370
|
+
this.uiCtx.setStatus("subagents", newStatusText);
|
|
371
|
+
this.lastStatusText = newStatusText;
|
|
372
|
+
}
|
|
373
|
+
this.widgetFrame++;
|
|
374
|
+
// Register widget callback once; subsequent updates use requestRender()
|
|
375
|
+
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
376
|
+
if (!this.widgetRegistered) {
|
|
377
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
378
|
+
this.tui = tui;
|
|
379
|
+
return {
|
|
380
|
+
render: () => this.renderWidget(tui, theme),
|
|
381
|
+
invalidate: () => {
|
|
382
|
+
// Theme changed — force re-registration so factory captures fresh theme.
|
|
383
|
+
this.widgetRegistered = false;
|
|
384
|
+
this.tui = undefined;
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}, { placement: "aboveEditor" });
|
|
388
|
+
this.widgetRegistered = true;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// Widget already registered — just request a re-render of existing components.
|
|
392
|
+
this.tui?.requestRender();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
dispose() {
|
|
396
|
+
if (this.widgetInterval) {
|
|
397
|
+
clearInterval(this.widgetInterval);
|
|
398
|
+
this.widgetInterval = undefined;
|
|
399
|
+
}
|
|
400
|
+
if (this.uiCtx) {
|
|
401
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
402
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
403
|
+
}
|
|
404
|
+
this.widgetRegistered = false;
|
|
405
|
+
this.tui = undefined;
|
|
406
|
+
this.lastStatusText = undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Displays a scrollable, live-updating view of an agent's conversation.
|
|
5
|
+
* Subscribes to session events for real-time streaming updates.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { AgentRecord } from "../types.js";
|
|
10
|
+
import type { Theme } from "./agent-widget.js";
|
|
11
|
+
import { type AgentActivity } from "./agent-widget.js";
|
|
12
|
+
import { type ViewerKeybindings } from "./viewer-keys.js";
|
|
13
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
14
|
+
export declare const VIEWPORT_HEIGHT_PCT = 70;
|
|
15
|
+
export declare class ConversationViewer implements Component {
|
|
16
|
+
private tui;
|
|
17
|
+
private session;
|
|
18
|
+
private record;
|
|
19
|
+
private activity;
|
|
20
|
+
private theme;
|
|
21
|
+
private done;
|
|
22
|
+
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
23
|
+
private onStop?;
|
|
24
|
+
private scrollOffset;
|
|
25
|
+
private autoScroll;
|
|
26
|
+
private unsubscribe;
|
|
27
|
+
private lastInnerW;
|
|
28
|
+
private closed;
|
|
29
|
+
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
30
|
+
private stopArmed;
|
|
31
|
+
private keys;
|
|
32
|
+
constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
|
|
33
|
+
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
34
|
+
onStop?: (() => void) | undefined,
|
|
35
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
36
|
+
keybindings?: ViewerKeybindings);
|
|
37
|
+
handleInput(data: string): void;
|
|
38
|
+
render(width: number): string[];
|
|
39
|
+
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
40
|
+
private isStoppable;
|
|
41
|
+
invalidate(): void;
|
|
42
|
+
dispose(): void;
|
|
43
|
+
private viewportHeight;
|
|
44
|
+
private chromeLines;
|
|
45
|
+
private invocationLine;
|
|
46
|
+
private buildContentLines;
|
|
47
|
+
}
|