@ferris1225/pi-subagents 0.28.0 → 0.29.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/widget.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * session_start wiring: the persistent status widget above the editor, plus
3
+ * one-time feature announcements (a new configurable option is surfaced to the
4
+ * user once after an update; the marker persists in `announcedFeatures`).
5
+ */
6
+
7
+ import { stat } from "node:fs/promises";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { loadConfig, saveConfig } from "./config.ts";
11
+ import {
12
+ activityStateLabel,
13
+ compactLine,
14
+ deriveActivityState,
15
+ formatElapsed,
16
+ formatUsageCompact,
17
+ monitor,
18
+ statusIcon,
19
+ statusLabel,
20
+ } from "./monitor.ts";
21
+ import type { SubagentRuntime } from "./runtime.ts";
22
+
23
+ /** Features whose one-time announcement is still pending (keyed by config
24
+ * `announcedFeatures` entry). When the feature's precondition is unmet and the
25
+ * marker is absent, the user is told about it exactly once. */
26
+ const ANNOUNCEMENTS: Array<{
27
+ key: string;
28
+ condition: (config: Awaited<ReturnType<typeof loadConfig>>) => boolean;
29
+ message: string;
30
+ }> = [
31
+ {
32
+ key: "visionModel",
33
+ condition: (config) => config.visionModel === undefined,
34
+ message:
35
+ "pi-subagents: new — a vision-capable model can now handle image tasks (screenshots, mockups, designs). Run /subagents-setup to configure it; until set, vision tasks use the main session's current model.",
36
+ },
37
+ ];
38
+
39
+ /**
40
+ * One-time feature announcements: when an update introduces a new configurable
41
+ * feature, tell the user once (the marker persists in announcedFeatures) so they
42
+ * know it exists — e.g. the vision model, which is unset by default. Only runs
43
+ * when a config file already exists: on a fresh install there is nothing to
44
+ * announce (and writing the file here would make /subagents-setup skip its
45
+ * first-time wizard). A failed announcement must never break session startup.
46
+ */
47
+ async function announceNewFeatures(
48
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
49
+ runtime: SubagentRuntime,
50
+ ): Promise<void> {
51
+ try {
52
+ let configExists = true;
53
+ try {
54
+ await stat(runtime.configPath);
55
+ } catch {
56
+ configExists = false;
57
+ }
58
+ if (!configExists) return;
59
+
60
+ const config = await loadConfig(runtime.configPath);
61
+ const pending = ANNOUNCEMENTS.filter(
62
+ (announcement) =>
63
+ announcement.condition(config) && !config.announcedFeatures.includes(announcement.key),
64
+ );
65
+ if (pending.length === 0) return;
66
+ await saveConfig(
67
+ {
68
+ ...config,
69
+ announcedFeatures: [...config.announcedFeatures, ...pending.map((a) => a.key)],
70
+ },
71
+ runtime.configPath,
72
+ );
73
+ for (const announcement of pending) {
74
+ ctx.ui.notify(announcement.message, "info");
75
+ }
76
+ } catch {
77
+ /* announcement failures are non-fatal */
78
+ }
79
+ }
80
+
81
+ export function registerWidget(pi: ExtensionAPI, runtime: SubagentRuntime): void {
82
+ pi.on("session_start", async (_e, ctx) => {
83
+ if (ctx.mode !== "tui") return;
84
+ await announceNewFeatures(ctx, runtime);
85
+
86
+ ctx.ui.setWidget(
87
+ "pi-subagents",
88
+ (tui, theme) => {
89
+ const unsub = monitor.subscribe(() => tui.requestRender());
90
+ // Tick once a second so elapsed time stays live while runs are active.
91
+ const timer = setInterval(() => {
92
+ if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
93
+ tui.requestRender();
94
+ }
95
+ }, 1000);
96
+ return {
97
+ render(width: number): string[] {
98
+ const runs = monitor.getRuns();
99
+ if (runs.length === 0) return [];
100
+ const now = Date.now();
101
+ const lines: string[] = [];
102
+ // Tree layout: each top-level agent is a root whose title/activity hang
103
+ // off it as branches; auto-fix chain runs (groupId) become child nodes
104
+ // under their parent root, with a "│" continuation while more siblings
105
+ // follow. Blank lines separate agent blocks so parallel runs don't blur
106
+ // into one wall of text.
107
+ const dim = (t: string): string => theme.fg("dim", t);
108
+ for (let idx = 0; idx < runs.length; idx++) {
109
+ const r = runs[idx];
110
+ const isChain = Boolean(r.groupId);
111
+ const chainContinues = isChain && runs[idx + 1]?.groupId === r.groupId;
112
+ const activity =
113
+ r.activity && (r.status === "running" || r.status === "queued") ? r.activity : undefined;
114
+ const hasActivity = activity !== undefined;
115
+ const icon = statusIcon(r.status, theme);
116
+ // Chain-internal runs (auto-fix worker/reviewer) are child nodes under
117
+ // their parent reviewer. Their relationLabel ("fix round 1") is more
118
+ // distinguishing than the repeated worker/reviewer name.
119
+ const name = isChain ? (r.relationLabel ?? r.agent) : r.agent;
120
+ // Two lines per run: the header row (icon, run id, agent name) and the
121
+ // live activity branch below. The task summary is deliberately not
122
+ // shown — the task lives in the tool result, and the agent name plus
123
+ // what it is doing right now is enough to tell runs apart. The header
124
+ // stays exactly as it was (accent name, dim stats), matching the
125
+ // referenced sub-agent widgets (tintinweb): the running indicator
126
+ // uses the accent color, everything else is quiet.
127
+ if (!isChain && lines.length > 0) lines.push("");
128
+ const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
129
+ const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}`;
130
+
131
+ // Right side: full model ref (provider/model), token usage (in/out +
132
+ // cache read/write), tool count, elapsed, and the soft activity-state
133
+ // annotation (idle / long-running). Trailing the header with a single
134
+ // " · " chain keeps the row compact (no center gap); compactLine
135
+ // clips on overflow, never the right side on its own.
136
+ const model = r.model ?? "?";
137
+ const usage = formatUsageCompact(r.usage);
138
+ const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
139
+ const elapsed = formatElapsed(r, now);
140
+ // The round outcome summary leads the metadata so a finished chain
141
+ // row reads as what it did ("fail · src/index.ts · render()",
142
+ // "pass", "src/index.ts · tests/monitor.test.ts").
143
+ const metaParts = [r.summary, model, usage, tools, elapsed].filter(Boolean);
144
+ // Running is conveyed by the icon + elapsed; spell out the label only for
145
+ // the other states (ready / done / stopped) so they are unambiguous.
146
+ if (r.status !== "running") metaParts.push(statusLabel(r.status));
147
+ const state = deriveActivityState(r, now);
148
+ if (state) metaParts.push(activityStateLabel(state));
149
+ if (r.annotation) metaParts.push(r.annotation);
150
+ // Metadata trails the header in dim — quiet, never competing with the
151
+ // accent agent name (the same restraint the referenced widgets use).
152
+ // Trailing with a single " · " chain keeps the row compact (no center
153
+ // gap); compactLine clips on overflow, never the right side on its own.
154
+ const right = metaParts.length ? dim(` · ${metaParts.join(" · ")}`) : "";
155
+ lines.push(compactLine(left, right, width));
156
+
157
+ // Current activity ("read src/index.ts", "bash npm test") is the only
158
+ // branch: gray, so it never competes with the agent name or pi's own
159
+ // UI. Chain nodes that still have siblings carry a "│" continuation
160
+ // down to the last one.
161
+ if (hasActivity) {
162
+ const continuation = isChain ? (chainContinues ? "│ " : " ") : "";
163
+ lines.push(truncateToWidth(`${continuation}${dim("└─ ")}${dim(activity)}`, width));
164
+ }
165
+ }
166
+ return lines;
167
+ },
168
+ invalidate() {},
169
+ dispose() {
170
+ unsub();
171
+ clearInterval(timer);
172
+ },
173
+ };
174
+ },
175
+ { placement: "aboveEditor" },
176
+ );
177
+ });
178
+ }