@jopqior/pi-subagents 1.0.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/CHANGELOG.md +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
|
|
8
|
+
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
9
|
+
import type { Subagent } from "#src/lifecycle/subagent";
|
|
10
|
+
import type { SubagentManager, SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
|
|
11
|
+
import type { CompactionInfo } from "#src/types";
|
|
12
|
+
import { ERROR_STATUSES, type Theme } from "#src/ui/display";
|
|
13
|
+
import { renderWidgetLines, type WidgetAgent } from "#src/ui/widget-renderer";
|
|
14
|
+
|
|
15
|
+
// ---- Types ----
|
|
16
|
+
|
|
17
|
+
/** Minimal agent shape needed for widget lifecycle decisions. */
|
|
18
|
+
interface AgentSummary {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly status: string;
|
|
21
|
+
readonly completedAt?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lightweight state snapshot used by AgentWidget.update() to decide what to show. */
|
|
25
|
+
export interface WidgetState {
|
|
26
|
+
readonly runningCount: number;
|
|
27
|
+
readonly queuedCount: number;
|
|
28
|
+
readonly hasFinished: boolean;
|
|
29
|
+
/** True when runningCount > 0 || queuedCount > 0. Included for call-site readability. */
|
|
30
|
+
readonly hasActive: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Count agents by status and return a lightweight state snapshot.
|
|
35
|
+
* Pure function — no IO, no side effects. Exported for direct unit testing.
|
|
36
|
+
*/
|
|
37
|
+
export function assembleWidgetState(
|
|
38
|
+
agents: readonly AgentSummary[],
|
|
39
|
+
shouldShowFinished: (agentId: string, status: string) => boolean,
|
|
40
|
+
): WidgetState {
|
|
41
|
+
let runningCount = 0;
|
|
42
|
+
let queuedCount = 0;
|
|
43
|
+
let hasFinished = false;
|
|
44
|
+
for (const a of agents) {
|
|
45
|
+
if (a.status === "running") { runningCount++; }
|
|
46
|
+
else if (a.status === "queued") { queuedCount++; }
|
|
47
|
+
else if (a.completedAt && shouldShowFinished(a.id, a.status)) { hasFinished = true; }
|
|
48
|
+
}
|
|
49
|
+
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
50
|
+
return { runningCount, queuedCount, hasFinished, hasActive };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The slice of the TUI the widget factory callback touches. */
|
|
54
|
+
export interface TuiSurface {
|
|
55
|
+
readonly terminal: { readonly columns: number };
|
|
56
|
+
requestRender(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type UICtx = {
|
|
60
|
+
setStatus(key: string, text: string | undefined): void;
|
|
61
|
+
setWidget(
|
|
62
|
+
key: string,
|
|
63
|
+
content: undefined | ((tui: TuiSurface, theme: Theme) => { render(): string[]; invalidate(): void }),
|
|
64
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
65
|
+
): void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ---- Widget manager ----
|
|
69
|
+
|
|
70
|
+
export class AgentWidget implements SubagentManagerObserver {
|
|
71
|
+
private uiCtx: UICtx | undefined;
|
|
72
|
+
private widgetFrame = 0;
|
|
73
|
+
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
74
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
75
|
+
private finishedTurnAge = new Map<string, number>();
|
|
76
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
77
|
+
private static readonly ERROR_LINGER_TURNS = 2;
|
|
78
|
+
|
|
79
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
80
|
+
private widgetRegistered = false;
|
|
81
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
82
|
+
private tui: TuiSurface | undefined;
|
|
83
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
84
|
+
private lastStatusText: string | undefined;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
private manager: SubagentManager,
|
|
88
|
+
private registry: AgentTypeRegistry,
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
/** Set the UI context (captured at session_start). */
|
|
92
|
+
setUICtx(ctx: UICtx) {
|
|
93
|
+
if (ctx !== this.uiCtx) {
|
|
94
|
+
// UICtx changed — the widget registered on the old context is gone.
|
|
95
|
+
// Force re-registration on next update().
|
|
96
|
+
this.uiCtx = ctx;
|
|
97
|
+
this.widgetRegistered = false;
|
|
98
|
+
this.tui = undefined;
|
|
99
|
+
this.lastStatusText = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Called on each new turn (turn_start).
|
|
105
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
106
|
+
*/
|
|
107
|
+
onTurnStart() {
|
|
108
|
+
// Age all finished agents
|
|
109
|
+
for (const [id, age] of this.finishedTurnAge) {
|
|
110
|
+
this.finishedTurnAge.set(id, age + 1);
|
|
111
|
+
}
|
|
112
|
+
// Trigger a widget refresh (will filter out expired agents)
|
|
113
|
+
this.update();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- SubagentManagerObserver: react to lifecycle, self-drive the timer ----
|
|
117
|
+
|
|
118
|
+
/** A subagent started running — ensure the update loop is live and render. */
|
|
119
|
+
onSubagentStarted(_record: Subagent) {
|
|
120
|
+
this.startLoop();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A background subagent was created (queued) — ensure the loop is live and render. */
|
|
124
|
+
onSubagentCreated(_record: Subagent) {
|
|
125
|
+
this.startLoop();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A subagent completed — render so the finished state is seeded and shown. */
|
|
129
|
+
onSubagentCompleted(_record: Subagent) {
|
|
130
|
+
this.update();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A subagent went back to running — ensure the loop is live and render.
|
|
135
|
+
* `startLoop` rather than `update`: the timer stops once nothing is running,
|
|
136
|
+
* and a resumed agent is running again.
|
|
137
|
+
*/
|
|
138
|
+
onSubagentResuming(_record: Subagent) {
|
|
139
|
+
this.startLoop();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A subagent finished a resume — render so the refreshed result is shown. */
|
|
143
|
+
onSubagentResumed(_record: Subagent) {
|
|
144
|
+
this.update();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A subagent's session compacted — render to refresh the compaction count. */
|
|
148
|
+
onSubagentCompacted(_record: Subagent, _info: CompactionInfo) {
|
|
149
|
+
this.update();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Start the update timer (if not already running) and render immediately. */
|
|
153
|
+
private startLoop() {
|
|
154
|
+
this.ensureTimer();
|
|
155
|
+
this.update();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Ensure the widget update timer is running. */
|
|
159
|
+
private ensureTimer() {
|
|
160
|
+
this.widgetInterval ??= setInterval(() => this.update(), 80);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
164
|
+
private shouldShowFinished(agentId: string, status: string): boolean {
|
|
165
|
+
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
166
|
+
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
|
|
167
|
+
return age < maxAge;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Background agents only — the widget's sole audience (ADR-0004 Decision A).
|
|
172
|
+
* Foreground runs are rendered by the `subagent` tool's inline `onUpdate` stream,
|
|
173
|
+
* so funneling both `listAgents()` call sites through this accessor applies the
|
|
174
|
+
* background predicate exactly once at the source.
|
|
175
|
+
*
|
|
176
|
+
* The predicate reads the record's own resolved mode. It formerly re-derived
|
|
177
|
+
* it from a per-call display snapshot only the tool door ever built — so every
|
|
178
|
+
* SDK-spawned agent was filtered out permanently (#724).
|
|
179
|
+
*/
|
|
180
|
+
private listBackgroundAgents(): Subagent[] {
|
|
181
|
+
return this.manager.listAgents().filter(record => record.isBackground);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Project a live Subagent record onto a pure-data WidgetAgent snapshot. */
|
|
185
|
+
private toWidgetAgent(record: Subagent): WidgetAgent {
|
|
186
|
+
return {
|
|
187
|
+
id: record.id,
|
|
188
|
+
type: record.type,
|
|
189
|
+
status: record.status,
|
|
190
|
+
description: record.description,
|
|
191
|
+
toolUses: record.toolUses,
|
|
192
|
+
startedAt: record.startedAt,
|
|
193
|
+
completedAt: record.completedAt,
|
|
194
|
+
error: record.error,
|
|
195
|
+
lifetimeUsage: record.lifetimeUsage,
|
|
196
|
+
compactionCount: record.compactionCount,
|
|
197
|
+
turnCount: record.turnCount,
|
|
198
|
+
maxTurns: record.maxTurns,
|
|
199
|
+
activeTools: record.activeTools,
|
|
200
|
+
responseText: record.responseText,
|
|
201
|
+
awaitingSelection: record.awaitingSelection,
|
|
202
|
+
contextPercent: record.getContextPercent(),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Delegate rendering to the pure widget-renderer module. */
|
|
207
|
+
private renderWidget(tui: TuiSurface, theme: Theme): string[] {
|
|
208
|
+
return renderWidgetLines({
|
|
209
|
+
agents: this.listBackgroundAgents().map(r => this.toWidgetAgent(r)),
|
|
210
|
+
registry: this.registry,
|
|
211
|
+
spinnerFrame: this.widgetFrame,
|
|
212
|
+
terminalWidth: tui.terminal.columns,
|
|
213
|
+
theme,
|
|
214
|
+
shouldShowFinished: (id, status) => this.shouldShowFinished(id, status),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Unregister the widget, clear the status bar, stop the interval timer, and
|
|
220
|
+
* purge stale `finishedTurnAge` entries for agents no longer in `backgroundAgents`.
|
|
221
|
+
* Called only from `update`'s idle path — not from `dispose`.
|
|
222
|
+
*/
|
|
223
|
+
private clearWidget(backgroundAgents: readonly AgentSummary[]): void {
|
|
224
|
+
if (this.widgetRegistered) {
|
|
225
|
+
this.uiCtx!.setWidget("agents", undefined);
|
|
226
|
+
this.widgetRegistered = false;
|
|
227
|
+
this.tui = undefined;
|
|
228
|
+
}
|
|
229
|
+
if (this.lastStatusText !== undefined) {
|
|
230
|
+
this.uiCtx!.setStatus("subagents", undefined);
|
|
231
|
+
this.lastStatusText = undefined;
|
|
232
|
+
}
|
|
233
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
234
|
+
for (const [id] of this.finishedTurnAge) {
|
|
235
|
+
if (!backgroundAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Compute the status bar text from the current widget state and call
|
|
241
|
+
* `setStatus` only when it differs from the last cached value.
|
|
242
|
+
*/
|
|
243
|
+
private updateStatusBar(state: WidgetState): void {
|
|
244
|
+
let newStatusText: string | undefined;
|
|
245
|
+
if (state.hasActive) {
|
|
246
|
+
const statusParts: string[] = [];
|
|
247
|
+
if (state.runningCount > 0) statusParts.push(`${state.runningCount} running`);
|
|
248
|
+
if (state.queuedCount > 0) statusParts.push(`${state.queuedCount} queued`);
|
|
249
|
+
const total = state.runningCount + state.queuedCount;
|
|
250
|
+
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
|
|
251
|
+
}
|
|
252
|
+
if (newStatusText !== this.lastStatusText) {
|
|
253
|
+
this.uiCtx!.setStatus("subagents", newStatusText);
|
|
254
|
+
this.lastStatusText = newStatusText;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Seed linger tracking for any newly-observed finished agent.
|
|
260
|
+
* The widget owns detection of completions it observes via `listAgents()`,
|
|
261
|
+
* so no external bookkeeping call is needed.
|
|
262
|
+
* Idempotent — only seeds when an entry is absent, so repeated updates within
|
|
263
|
+
* a turn neither reset nor advance the age.
|
|
264
|
+
*/
|
|
265
|
+
private seedFinishedAgents(agents: readonly AgentSummary[]): void {
|
|
266
|
+
for (const a of agents) {
|
|
267
|
+
if (a.completedAt && !this.finishedTurnAge.has(a.id)) {
|
|
268
|
+
this.finishedTurnAge.set(a.id, 0);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Force an immediate widget update. */
|
|
274
|
+
update() {
|
|
275
|
+
if (!this.uiCtx) return;
|
|
276
|
+
|
|
277
|
+
const backgroundAgents = this.listBackgroundAgents();
|
|
278
|
+
this.seedFinishedAgents(backgroundAgents);
|
|
279
|
+
const state = assembleWidgetState(backgroundAgents, (id, status) => this.shouldShowFinished(id, status));
|
|
280
|
+
|
|
281
|
+
if (!state.hasActive && !state.hasFinished) {
|
|
282
|
+
this.clearWidget(backgroundAgents);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.updateStatusBar(state);
|
|
287
|
+
this.widgetFrame++;
|
|
288
|
+
|
|
289
|
+
// Register widget callback once; subsequent updates use requestRender()
|
|
290
|
+
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
291
|
+
if (!this.widgetRegistered) {
|
|
292
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
293
|
+
this.tui = tui;
|
|
294
|
+
return {
|
|
295
|
+
render: () => this.renderWidget(tui, theme),
|
|
296
|
+
invalidate: () => {
|
|
297
|
+
// Theme changed — force re-registration so factory captures fresh theme.
|
|
298
|
+
this.widgetRegistered = false;
|
|
299
|
+
this.tui = undefined;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}, { placement: "aboveEditor" });
|
|
303
|
+
this.widgetRegistered = true;
|
|
304
|
+
} else {
|
|
305
|
+
// Widget already registered — just request a re-render of existing components.
|
|
306
|
+
this.tui?.requestRender();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Release everything the widget acquired: the update interval and both
|
|
312
|
+
* registrations on the session's `UICtx`.
|
|
313
|
+
*
|
|
314
|
+
* Disposal is final. Dropping the `UICtx` makes `update()` return at its
|
|
315
|
+
* first line, so a notification arriving afterwards — the terminal transition
|
|
316
|
+
* an abort drives synchronously — cannot re-register what this released.
|
|
317
|
+
* `setUICtx()` re-arms the widget if a context ever arrives again.
|
|
318
|
+
*/
|
|
319
|
+
dispose() {
|
|
320
|
+
if (this.widgetInterval) {
|
|
321
|
+
clearInterval(this.widgetInterval);
|
|
322
|
+
this.widgetInterval = undefined;
|
|
323
|
+
}
|
|
324
|
+
if (this.uiCtx) {
|
|
325
|
+
this.uiCtx.setWidget("agents", undefined);
|
|
326
|
+
this.uiCtx.setStatus("subagents", undefined);
|
|
327
|
+
}
|
|
328
|
+
this.uiCtx = undefined;
|
|
329
|
+
this.widgetRegistered = false;
|
|
330
|
+
this.tui = undefined;
|
|
331
|
+
this.lastStatusText = undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bounded-lines.ts — a TUI component that spends exactly one terminal row per line.
|
|
3
|
+
*
|
|
4
|
+
* Pi's `Text` word-wraps, so a single long line becomes as many rows as it needs
|
|
5
|
+
* and a line-count cap does not bound the height of a result. This component
|
|
6
|
+
* clips instead of wrapping, which makes the row count equal to the line count
|
|
7
|
+
* at every width — the bound `get_subagent_result` renders under (#636).
|
|
8
|
+
*
|
|
9
|
+
* Clipping is by display columns via pi-tui's `truncateToWidth`, so a wide glyph
|
|
10
|
+
* costs the two columns it actually occupies rather than the one code unit it
|
|
11
|
+
* is stored in.
|
|
12
|
+
*
|
|
13
|
+
* A line carrying its own line break is cut at the first one, because the terminal
|
|
14
|
+
* would otherwise spend a row on each segment and the bound would rest on every
|
|
15
|
+
* caller's care rather than on this component. Pi's own `TruncatedText` cuts at
|
|
16
|
+
* the first newline for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
20
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
/** Renders pre-themed lines one-to-one onto terminal rows, clipped to the viewport. */
|
|
23
|
+
export class BoundedLines implements Component {
|
|
24
|
+
constructor(private readonly lines: readonly string[]) {}
|
|
25
|
+
|
|
26
|
+
render(width: number): string[] {
|
|
27
|
+
return this.lines.map((line) => truncateToWidth(firstLineOf(line), width));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Required by `Component`; nothing is cached between renders, so there is nothing to discard. */
|
|
31
|
+
// fallow-ignore-next-line unused-class-member
|
|
32
|
+
invalidate(): void {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The text up to the first line break of any flavour.
|
|
37
|
+
*
|
|
38
|
+
* Vertical tab and form feed count: a VT100-class terminal moves the cursor down
|
|
39
|
+
* a row for each exactly as it does for a line feed, and both are zero-width, so
|
|
40
|
+
* a width-based clip would pass them through to cost a row of their own.
|
|
41
|
+
*/
|
|
42
|
+
function firstLineOf(line: string): string {
|
|
43
|
+
const breakAt = line.search(/[\r\n\v\f]/);
|
|
44
|
+
return breakAt === -1 ? line : line.slice(0, breakAt);
|
|
45
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* display.ts — Pure formatting helpers and display utilities for agent UI.
|
|
3
|
+
*
|
|
4
|
+
* All functions are stateless and dependency-free (no SDK, no widget lifecycle).
|
|
5
|
+
* Consumed by the widget, the menu, tool modules, and the notification renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
9
|
+
import type { AgentInvocation, SubagentType } from "#src/types";
|
|
10
|
+
import { GLYPHS } from "#src/ui/glyphs";
|
|
11
|
+
|
|
12
|
+
// ---- Types ----
|
|
13
|
+
|
|
14
|
+
export type Theme = {
|
|
15
|
+
fg(color: string, text: string): string;
|
|
16
|
+
bold(text: string): string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Metadata attached to Agent tool results for custom rendering. */
|
|
20
|
+
export interface AgentDetails {
|
|
21
|
+
displayName: string;
|
|
22
|
+
description: string;
|
|
23
|
+
subagentType: string;
|
|
24
|
+
toolUses: number;
|
|
25
|
+
tokens: string;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
|
|
28
|
+
/** Human-readable description of what the agent is currently doing. */
|
|
29
|
+
activity?: string;
|
|
30
|
+
/** Current spinner frame index (for animated running indicator). */
|
|
31
|
+
spinnerFrame?: number;
|
|
32
|
+
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
|
|
33
|
+
modelName?: string;
|
|
34
|
+
/** Notable config tags (e.g. ["thinking: high", "inherit context"]). */
|
|
35
|
+
tags?: string[];
|
|
36
|
+
/** Current turn count. */
|
|
37
|
+
turnCount?: number;
|
|
38
|
+
/** Effective max turns (undefined = unlimited). */
|
|
39
|
+
maxTurns?: number;
|
|
40
|
+
agentId?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- Constants ----
|
|
45
|
+
|
|
46
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
47
|
+
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
48
|
+
|
|
49
|
+
/** Private pending-selection activity shown while public status stays `running`. */
|
|
50
|
+
export const PENDING_SELECTION_ACTIVITY = "Awaiting model/thinking selection";
|
|
51
|
+
|
|
52
|
+
/** Tool name → human-readable action for activity descriptions. */
|
|
53
|
+
const TOOL_DISPLAY: Record<string, string> = {
|
|
54
|
+
read: "reading",
|
|
55
|
+
bash: "running command",
|
|
56
|
+
edit: "editing",
|
|
57
|
+
write: "writing",
|
|
58
|
+
grep: "searching",
|
|
59
|
+
find: "finding files",
|
|
60
|
+
ls: "listing",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---- Pure formatters ----
|
|
64
|
+
|
|
65
|
+
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
66
|
+
export function formatTokens(count: number): string {
|
|
67
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
|
|
68
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
|
|
69
|
+
return `${count} token`;
|
|
70
|
+
}
|
|
71
|
+
|
|
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 (see `glyphs.ts`).
|
|
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 function formatSessionTokens(
|
|
83
|
+
tokens: number,
|
|
84
|
+
percent: number | null,
|
|
85
|
+
theme: Theme,
|
|
86
|
+
compactions = 0,
|
|
87
|
+
): string {
|
|
88
|
+
const tokenStr = formatTokens(tokens);
|
|
89
|
+
const annot: string[] = [];
|
|
90
|
+
if (percent !== null) {
|
|
91
|
+
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
|
92
|
+
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
93
|
+
}
|
|
94
|
+
if (compactions > 0) {
|
|
95
|
+
annot.push(theme.fg("dim", `${GLYPHS.compactions}${compactions}`));
|
|
96
|
+
}
|
|
97
|
+
if (annot.length === 0) return tokenStr;
|
|
98
|
+
const sep = theme.fg("dim", " · ");
|
|
99
|
+
return `${tokenStr} ${theme.fg("dim", "(")}${annot.join(sep)}${theme.fg("dim", ")")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
103
|
+
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
104
|
+
return maxTurns != null
|
|
105
|
+
? `${GLYPHS.turns}${turnCount}≤${maxTurns}`
|
|
106
|
+
: `${GLYPHS.turns}${turnCount}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Format milliseconds as human-readable duration. */
|
|
110
|
+
export function formatMs(ms: number): string {
|
|
111
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Format duration from start/completed timestamps. */
|
|
115
|
+
export function formatDuration(startedAt: number, completedAt?: number): string {
|
|
116
|
+
if (completedAt) return formatMs(completedAt - startedAt);
|
|
117
|
+
return `${formatMs(Date.now() - startedAt)} (running)`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Display helpers ----
|
|
121
|
+
|
|
122
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
123
|
+
export function getDisplayName(type: SubagentType, registry: AgentConfigLookup): string {
|
|
124
|
+
const config = registry.resolveAgentConfig(type);
|
|
125
|
+
return config.displayName ?? config.name;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
|
129
|
+
export function getPromptModeLabel(type: SubagentType, registry: AgentConfigLookup): string | undefined {
|
|
130
|
+
const config = registry.resolveAgentConfig(type);
|
|
131
|
+
return config.promptMode === "append" ? "twin" : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
135
|
+
export function buildInvocationTags(
|
|
136
|
+
invocation: AgentInvocation | undefined,
|
|
137
|
+
): { modelName?: string; tags: string[] } {
|
|
138
|
+
const tags: string[] = [];
|
|
139
|
+
if (!invocation) return { tags };
|
|
140
|
+
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
|
|
141
|
+
if (invocation.inheritContext) tags.push("inherit context");
|
|
142
|
+
if (invocation.runInBackground) tags.push("background");
|
|
143
|
+
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
144
|
+
return { modelName: invocation.modelName, tags };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Truncate text to a single line, max `len` chars. */
|
|
148
|
+
function truncateLine(text: string, len = 60): string {
|
|
149
|
+
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
150
|
+
if (line.length <= len) return line;
|
|
151
|
+
return line.slice(0, len) + "…";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
155
|
+
export function describeActivity(activeTools: ReadonlyMap<string, string>, responseText?: string): string {
|
|
156
|
+
if (activeTools.size > 0) {
|
|
157
|
+
const groups = new Map<string, number>();
|
|
158
|
+
for (const toolName of activeTools.values()) {
|
|
159
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
160
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const parts: string[] = [];
|
|
164
|
+
for (const [action, count] of groups) {
|
|
165
|
+
if (count > 1) {
|
|
166
|
+
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
|
|
167
|
+
} else {
|
|
168
|
+
parts.push(action);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return parts.join(", ") + "…";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No tools active — show truncated response text if available
|
|
175
|
+
if (responseText && responseText.trim().length > 0) {
|
|
176
|
+
return truncateLine(responseText);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return "thinking…";
|
|
180
|
+
}
|
package/src/ui/glyphs.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glyphs.ts — the semantic display-glyph vocabulary for the agent UI.
|
|
3
|
+
*
|
|
4
|
+
* Pi's TUI sizes each terminal cell with `get-east-asian-width`, which reports
|
|
5
|
+
* one cell for every glyph below. A glyph that no monospace font covers is
|
|
6
|
+
* therefore drawn by a proportional fallback font whose advance overruns that
|
|
7
|
+
* cell and collides with the next column — the defect in #669. East Asian Width
|
|
8
|
+
* does not detect this: the offending glyph and its replacement are both width 1.
|
|
9
|
+
*
|
|
10
|
+
* Before adding or changing a glyph, measure its monospace coverage:
|
|
11
|
+
*
|
|
12
|
+
* fc-list ":charset=<codepoint>:spacing=100" family | cut -d, -f1 | sort -u | grep -v LastResort
|
|
13
|
+
*
|
|
14
|
+
* Coverage on macOS 15, counted in families:
|
|
15
|
+
*
|
|
16
|
+
* ↻ U+21BB turns 1 (Menlo)
|
|
17
|
+
* ⇊ U+21CA compactions 1 (Menlo)
|
|
18
|
+
* ✓ U+2713 success 6
|
|
19
|
+
* ✗ U+2717 failure 6
|
|
20
|
+
* ▸ U+25B8 tool call 6
|
|
21
|
+
* ■ U+25A0 stopped 9
|
|
22
|
+
* ● U+25CF agents active 9
|
|
23
|
+
* ○ U+25CB agents idle 9
|
|
24
|
+
* ◦ U+25E6 queued 9
|
|
25
|
+
* ⎿ U+23BF sub-line 0 — pi house style, tracked in #683
|
|
26
|
+
* ◍ U+25CD streaming 1 (Menlo), tracked in #683
|
|
27
|
+
* ⠋…⠏ spinner frames 0 — pi house style, tracked in #683
|
|
28
|
+
*
|
|
29
|
+
* Glyphs are written here as literal characters rather than `\uXXXX` escapes, so
|
|
30
|
+
* a non-ASCII scan of `src/` (`rg -n '[^\x00-\x7f]' src --glob '*.ts'`) surfaces
|
|
31
|
+
* this file plus only layout and punctuation. Box-drawing characters stay at
|
|
32
|
+
* their render sites: they are layout, not vocabulary.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Semantic indicator glyphs rendered in the widget, inline results, and notifications. */
|
|
36
|
+
export const GLYPHS = {
|
|
37
|
+
/** Turn count, as `↻5≤30`. */
|
|
38
|
+
turns: "↻",
|
|
39
|
+
/** Session compaction count, annotating the token field. */
|
|
40
|
+
compactions: "⇊",
|
|
41
|
+
/** Completed outcome, also used dim/warning for a wrapped-up agent. */
|
|
42
|
+
success: "✓",
|
|
43
|
+
/** Error or aborted outcome. */
|
|
44
|
+
failure: "✗",
|
|
45
|
+
/** Stopped outcome. */
|
|
46
|
+
stopped: "■",
|
|
47
|
+
/** Continuation line beneath a result or activity line. */
|
|
48
|
+
subLine: "⎿",
|
|
49
|
+
/** Inline tool-call heading marker. */
|
|
50
|
+
toolCall: "▸",
|
|
51
|
+
/** Live streaming activity in the session transcript. */
|
|
52
|
+
streaming: "◍",
|
|
53
|
+
/** Queued-agents marker in the widget. */
|
|
54
|
+
queued: "◦",
|
|
55
|
+
/** Widget heading while agents are active. */
|
|
56
|
+
agentsActive: "●",
|
|
57
|
+
/** Widget heading while no agents are active. */
|
|
58
|
+
agentsIdle: "○",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/** Braille spinner frames for the animated running indicator. */
|
|
62
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|