@gotgenes/pi-subagents 6.16.0 → 6.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/docs/architecture/architecture.md +27 -24
- package/docs/plans/0146-narrow-ui-context.md +319 -0
- package/docs/plans/0148-split-agent-widget-rendering.md +255 -0
- package/docs/retro/0144-consolidate-observation-model.md +39 -0
- package/docs/retro/0148-split-agent-widget-rendering.md +39 -0
- package/package.json +1 -1
- package/src/index.ts +19 -13
- package/src/tools/get-result-tool.ts +11 -14
- package/src/tools/steer-tool.ts +12 -15
- package/src/ui/agent-config-editor.ts +63 -67
- package/src/ui/agent-creation-wizard.ts +50 -39
- package/src/ui/agent-menu.ts +118 -74
- package/src/ui/agent-widget.ts +12 -188
- package/src/ui/display.ts +2 -1
- package/src/ui/widget-renderer.ts +236 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* widget-renderer.ts — Pure rendering functions for the agent widget.
|
|
3
|
+
*
|
|
4
|
+
* All functions are stateless: they receive data and return formatted strings.
|
|
5
|
+
* No timers, no SDK types, no side effects. Consumed by AgentWidget.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import type { AgentConfigLookup } from "../agent-types.js";
|
|
10
|
+
import type { SubagentType } from "../types.js";
|
|
11
|
+
import type { LifetimeUsage, SessionLike } from "../usage.js";
|
|
12
|
+
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
|
+
import {
|
|
14
|
+
describeActivity,
|
|
15
|
+
formatMs,
|
|
16
|
+
formatSessionTokens,
|
|
17
|
+
formatTurns,
|
|
18
|
+
getDisplayName,
|
|
19
|
+
getPromptModeLabel,
|
|
20
|
+
SPINNER,
|
|
21
|
+
type Theme,
|
|
22
|
+
} from "./display.js";
|
|
23
|
+
|
|
24
|
+
// ── Data interfaces ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Minimal agent snapshot for rendering — no class methods, no mutation surface. */
|
|
27
|
+
export interface WidgetAgent {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly type: SubagentType;
|
|
30
|
+
readonly status: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly toolUses: number;
|
|
33
|
+
readonly startedAt: number;
|
|
34
|
+
readonly completedAt?: number;
|
|
35
|
+
readonly error?: string;
|
|
36
|
+
readonly lifetimeUsage?: Readonly<LifetimeUsage>;
|
|
37
|
+
readonly compactionCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read-only activity snapshot for widget rendering. */
|
|
41
|
+
export interface WidgetActivity {
|
|
42
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
43
|
+
readonly responseText: string;
|
|
44
|
+
readonly turnCount: number;
|
|
45
|
+
readonly maxTurns?: number;
|
|
46
|
+
readonly session?: SessionLike;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Per-agent rendering ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Render a single finished agent line (no tree connector prefix). */
|
|
52
|
+
export function renderFinishedLine(
|
|
53
|
+
agent: WidgetAgent,
|
|
54
|
+
activity: WidgetActivity | undefined,
|
|
55
|
+
registry: AgentConfigLookup,
|
|
56
|
+
theme: Theme,
|
|
57
|
+
): string {
|
|
58
|
+
const name = getDisplayName(agent.type, registry);
|
|
59
|
+
const modeLabel = getPromptModeLabel(agent.type, registry);
|
|
60
|
+
const duration = formatMs((agent.completedAt ?? Date.now()) - agent.startedAt);
|
|
61
|
+
|
|
62
|
+
let icon: string;
|
|
63
|
+
let statusText: string;
|
|
64
|
+
if (agent.status === "completed") {
|
|
65
|
+
icon = theme.fg("success", "✓");
|
|
66
|
+
statusText = "";
|
|
67
|
+
} else if (agent.status === "steered") {
|
|
68
|
+
icon = theme.fg("warning", "✓");
|
|
69
|
+
statusText = theme.fg("warning", " (turn limit)");
|
|
70
|
+
} else if (agent.status === "stopped") {
|
|
71
|
+
icon = theme.fg("dim", "■");
|
|
72
|
+
statusText = theme.fg("dim", " stopped");
|
|
73
|
+
} else if (agent.status === "error") {
|
|
74
|
+
icon = theme.fg("error", "✗");
|
|
75
|
+
const errMsg = agent.error ? `: ${agent.error.slice(0, 60)}` : "";
|
|
76
|
+
statusText = theme.fg("error", ` error${errMsg}`);
|
|
77
|
+
} else {
|
|
78
|
+
// aborted
|
|
79
|
+
icon = theme.fg("error", "✗");
|
|
80
|
+
statusText = theme.fg("warning", " aborted");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
85
|
+
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
86
|
+
parts.push(duration);
|
|
87
|
+
|
|
88
|
+
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
89
|
+
return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render a single running agent as header + activity line pair (no tree connector prefix). */
|
|
93
|
+
export function renderRunningLines(
|
|
94
|
+
agent: WidgetAgent,
|
|
95
|
+
activity: WidgetActivity | undefined,
|
|
96
|
+
registry: AgentConfigLookup,
|
|
97
|
+
spinnerFrame: number,
|
|
98
|
+
theme: Theme,
|
|
99
|
+
): [header: string, activity: string] {
|
|
100
|
+
const name = getDisplayName(agent.type, registry);
|
|
101
|
+
const modeLabel = getPromptModeLabel(agent.type, registry);
|
|
102
|
+
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
103
|
+
const elapsed = formatMs(Date.now() - agent.startedAt);
|
|
104
|
+
|
|
105
|
+
const tokens = getLifetimeTotal(agent.lifetimeUsage);
|
|
106
|
+
const contextPercent = activity?.session ? getSessionContextPercent(activity.session) : null;
|
|
107
|
+
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, agent.compactionCount) : "";
|
|
108
|
+
|
|
109
|
+
const parts: string[] = [];
|
|
110
|
+
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
111
|
+
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
112
|
+
if (tokenText) parts.push(tokenText);
|
|
113
|
+
parts.push(elapsed);
|
|
114
|
+
const statsText = parts.join(" · ");
|
|
115
|
+
|
|
116
|
+
const frame = SPINNER[spinnerFrame % SPINNER.length];
|
|
117
|
+
const activityText = activity ? describeActivity(activity.activeTools, activity.responseText) : "thinking\u2026";
|
|
118
|
+
|
|
119
|
+
const header = `${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`;
|
|
120
|
+
const activityLine = theme.fg("dim", ` \u23BF ${activityText}`);
|
|
121
|
+
|
|
122
|
+
return [header, activityLine];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Full widget rendering ────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
128
|
+
const MAX_WIDGET_LINES = 12;
|
|
129
|
+
|
|
130
|
+
/** Pure rendering of the widget body. Returns lines to display. */
|
|
131
|
+
export function renderWidgetLines(params: {
|
|
132
|
+
agents: readonly WidgetAgent[];
|
|
133
|
+
activityMap: ReadonlyMap<string, WidgetActivity>;
|
|
134
|
+
registry: AgentConfigLookup;
|
|
135
|
+
spinnerFrame: number;
|
|
136
|
+
terminalWidth: number;
|
|
137
|
+
theme: Theme;
|
|
138
|
+
shouldShowFinished: (agentId: string, status: string) => boolean;
|
|
139
|
+
}): string[] {
|
|
140
|
+
const { agents, activityMap, registry, spinnerFrame, terminalWidth, theme, shouldShowFinished } = params;
|
|
141
|
+
|
|
142
|
+
const running = agents.filter(a => a.status === "running");
|
|
143
|
+
const queued = agents.filter(a => a.status === "queued");
|
|
144
|
+
const finished = agents.filter(a =>
|
|
145
|
+
a.status !== "running" && a.status !== "queued" && a.completedAt
|
|
146
|
+
&& shouldShowFinished(a.id, a.status),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const hasActive = running.length > 0 || queued.length > 0;
|
|
150
|
+
const hasFinished = finished.length > 0;
|
|
151
|
+
|
|
152
|
+
if (!hasActive && !hasFinished) return [];
|
|
153
|
+
|
|
154
|
+
const truncate = (line: string) => truncateToWidth(line, terminalWidth);
|
|
155
|
+
const headingColor = hasActive ? "accent" : "dim";
|
|
156
|
+
const headingIcon = hasActive ? "\u25CF" : "\u25CB";
|
|
157
|
+
|
|
158
|
+
// Build sections separately for overflow-aware assembly.
|
|
159
|
+
const finishedLines: string[] = [];
|
|
160
|
+
for (const a of finished) {
|
|
161
|
+
finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, activityMap.get(a.id), registry, theme)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const runningLines: [string, string][] = [];
|
|
165
|
+
for (const a of running) {
|
|
166
|
+
const [header, act] = renderRunningLines(a, activityMap.get(a.id), registry, spinnerFrame, theme);
|
|
167
|
+
runningLines.push([
|
|
168
|
+
truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
|
|
169
|
+
truncate(theme.fg("dim", "\u2502 ") + act),
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const queuedLine = queued.length > 0
|
|
174
|
+
? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", "\u25E6")} ${theme.fg("dim", `${queued.length} queued`)}`)
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
// Assemble with overflow cap (heading takes 1 line).
|
|
178
|
+
const maxBody = MAX_WIDGET_LINES - 1;
|
|
179
|
+
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
|
|
180
|
+
|
|
181
|
+
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
|
|
182
|
+
|
|
183
|
+
if (totalBody <= maxBody) {
|
|
184
|
+
lines.push(...finishedLines);
|
|
185
|
+
for (const pair of runningLines) lines.push(...pair);
|
|
186
|
+
if (queuedLine) lines.push(queuedLine);
|
|
187
|
+
|
|
188
|
+
// Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500 and \u2502 \u2192 space for activity lines.
|
|
189
|
+
if (lines.length > 1) {
|
|
190
|
+
const last = lines.length - 1;
|
|
191
|
+
lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
|
|
192
|
+
if (runningLines.length > 0 && !queuedLine) {
|
|
193
|
+
if (last >= 2) {
|
|
194
|
+
lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
|
|
195
|
+
lines[last] = lines[last].replace("\u2502 ", " ");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// Overflow — prioritize: running > queued > finished.
|
|
201
|
+
let budget = maxBody - 1;
|
|
202
|
+
let hiddenRunning = 0;
|
|
203
|
+
let hiddenFinished = 0;
|
|
204
|
+
|
|
205
|
+
for (const pair of runningLines) {
|
|
206
|
+
if (budget >= 2) {
|
|
207
|
+
lines.push(...pair);
|
|
208
|
+
budget -= 2;
|
|
209
|
+
} else {
|
|
210
|
+
hiddenRunning++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (queuedLine && budget >= 1) {
|
|
215
|
+
lines.push(queuedLine);
|
|
216
|
+
budget--;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const fl of finishedLines) {
|
|
220
|
+
if (budget >= 1) {
|
|
221
|
+
lines.push(fl);
|
|
222
|
+
budget--;
|
|
223
|
+
} else {
|
|
224
|
+
hiddenFinished++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const overflowParts: string[] = [];
|
|
229
|
+
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
|
|
230
|
+
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
|
|
231
|
+
const overflowText = overflowParts.join(", ");
|
|
232
|
+
lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return lines;
|
|
236
|
+
}
|