@giladbarnea/pi-user-agents 0.0.6 → 0.0.7
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/INTERNALS.md +3 -3
- package/README.md +4 -4
- package/agent-viewer.ts +12 -3
- package/package.json +1 -1
- package/runner.ts +6 -13
- package/shared.ts +0 -10
- package/transcript.ts +9 -117
- package/widget.ts +40 -8
package/INTERNALS.md
CHANGED
|
@@ -16,9 +16,9 @@ How a finished run reaches (or hides from) the main agent rests on two Pi SDK ch
|
|
|
16
16
|
|
|
17
17
|
- **`pi.appendEntry(customType, data)` + `pi.registerEntryRenderer`** — the only channel that renders in the transcript while staying out of LLM context *forever*. Entries are persisted in the session file and re-rendered on reload. The entry's `data` is optional in the type — renderers must guard it.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Default completion writes no entry or message to the parent session. The widget and agent overlay show the response. `-s` sends a hidden custom message with `{ triggerTurn: true }`, steered mid-turn or answered immediately.
|
|
20
20
|
|
|
21
|
-
Immediate `-s` delivery and late `s` squash delivery share one canonical message builder
|
|
21
|
+
Immediate `-s` delivery and late `s` squash delivery share one canonical message builder and use `display: false`. Both paths preserve the selected conversation and `{ triggerTurn: true }` delivery. `transcript.ts` renders old completion entries and messages as zero lines, preventing Pi's fallback renderer from showing them. Command errors and detach, attach, and rebase breadcrumbs remain visible.
|
|
22
22
|
|
|
23
23
|
## Squashed message format
|
|
24
24
|
|
|
@@ -46,7 +46,7 @@ A child compaction passes through deliberately and lands as a real `compaction`
|
|
|
46
46
|
|
|
47
47
|
Two mechanics make that exact. First, a live compaction emits no message event — only `compaction_end` — so the child subscription captures it there as a synthetic `compactionSummary` message, or the append-only conversation would never contain it. Second, the kept-tail boundary (`firstKeptEntryId`) is a child-session entry id that means nothing in the main session's file; but at `compaction_end` the child's live context is exactly `[summary, kept tail]`, so the capture records `keptTailCount`, and the rebase replay counts back that many context messages to pass the main-session id of the message that starts the tail. The tail may reach past the replayed messages into main's pre-existing context — a child that inherits a nearly full context compacts early — so the delivery prepends main's own per-message entry ids to the count.
|
|
48
48
|
|
|
49
|
-
The fast-forward precondition is fingerprint equality: at dispatch the agent stores `JSON.stringify` of the inherited context messages (`[]` for `-i`), and `r` is offered only while the main session's current `buildSessionContext().messages` fingerprint still matches. The live fingerprint is cached per session manager and recomputed only when the branch leaf moves, so TUI-only entries (
|
|
49
|
+
The fast-forward precondition is fingerprint equality: at dispatch the agent stores `JSON.stringify` of the inherited context messages (`[]` for `-i`), and `r` is offered only while the main session's current `buildSessionContext().messages` fingerprint still matches. The live fingerprint is cached per session manager and recomputed only when the branch leaf moves, so TUI-only entries (detach and attach lines) move the leaf, trigger one recompute, and correctly do not block the rebase. One rule covers isolated agents too: their base is empty, so they rebase only onto a still-empty session.
|
|
50
50
|
|
|
51
51
|
Because the session switch tears the extension down, live widget rows cannot survive it: `r` is withheld while any agent is mid-turn, and the remaining parked and completed agents are detached first through the ordinary detach paths, so every one of them leaves its `Detached session …` breadcrumb before the switch. The switch re-opens the same file, so the session id does not change.
|
|
52
52
|
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ Press `←` or `↓` from the editor to focus the widget, pick an agent, press `
|
|
|
81
81
|
|
|
82
82
|
A background agent is not fire-and-forget — it's a session you can talk to.
|
|
83
83
|
|
|
84
|
-
Press `Enter` in the overlay to open the steer composer. Mid-turn, your message queues in after the current tool batch, before the next model call — exactly like steering the main agent. After the turn completes, the same composer starts **another turn** on the same live session. Follow up as many times as you need
|
|
84
|
+
Press `Enter` in the overlay to open the steer composer. Mid-turn, your message queues in after the current tool batch, before the next model call — exactly like steering the main agent. After the turn completes, the same composer starts **another turn** on the same live session. Follow up as many times as you need. Read each response in the agent overlay.
|
|
85
85
|
|
|
86
86
|
`Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, squashing, or detaching. In fact, nothing ends an agent except you: pressing `d` twice, squashing or rebasing it, or ending the Pi session.
|
|
87
87
|
|
|
@@ -101,14 +101,14 @@ Or take it back as a background agent instead: `/agent-attach 0199c4f2` puts the
|
|
|
101
101
|
|
|
102
102
|
This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Completed responses stay in the agent overlay, accessible through the widget below the editor. Completion adds no transcript card. Ask an agent ten questions and your main agent's token budget does not move.
|
|
105
105
|
|
|
106
106
|
When a result does belong in the main conversation, squash it in:
|
|
107
107
|
|
|
108
108
|
- Dispatch with `-s`/`--squash` and the result is delivered automatically on completion.
|
|
109
109
|
- Or press `s` in the overlay of any completed agent, whenever you decide it earned its place.
|
|
110
110
|
|
|
111
|
-
A squash delivers a compact record, not a transcript dump: every message you sent the agent and the final answer to each — no thinking, no tool traffic. The record is rebuilt from the agent's full history at squash time, so early turns survive even after the agent compacts its own context. If the main agent is mid-turn, the record is steered in; if idle, it triggers a turn. A squashed agent retires; its overlay stays readable.
|
|
111
|
+
A squash delivers a compact record, not a transcript dump: every message you sent the agent and the final answer to each — no thinking, no tool traffic. The record is rebuilt from the agent's full history at squash time, so early turns survive even after the agent compacts its own context. If the main agent is mid-turn, the record is steered in; if idle, it triggers a turn. Squash adds no transcript card. A squashed agent retires; its overlay stays readable.
|
|
112
112
|
|
|
113
113
|
### 6. Or rebase — rewrite history
|
|
114
114
|
|
|
@@ -148,7 +148,7 @@ And what you do type by hand is checked live, as you type. Valid options and val
|
|
|
148
148
|
|
|
149
149
|
Every dispatch writes a real session file, exactly like the one you're sitting in, so dispatched agents show up in `/resume` and `pi -r` alongside your own sessions.
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
The widget does not survive a session reload. Each agent's session remains on disk, accessible through `/resume` or `/agent-attach`. Completion cards from older versions stay hidden.
|
|
152
152
|
|
|
153
153
|
### 🌡️ A context meter in one cell
|
|
154
154
|
|
package/agent-viewer.ts
CHANGED
|
@@ -102,7 +102,14 @@ function describeAssistantPart(part: AssistantContentPart): ActivityDescription
|
|
|
102
102
|
|
|
103
103
|
export function renderActivity(description: ActivityDescription, width: number, theme: Theme): string {
|
|
104
104
|
if (width <= 0) return "";
|
|
105
|
-
const
|
|
105
|
+
const limit = Math.min(4096, Math.max(128, width * 4));
|
|
106
|
+
const text = description.text;
|
|
107
|
+
const bounded = text.length <= limit
|
|
108
|
+
? text
|
|
109
|
+
: description.truncation === "tail"
|
|
110
|
+
? `${text.slice(0, limit - 1)}…`
|
|
111
|
+
: `${text.slice(0, Math.ceil((limit - 1) / 2))}…${text.slice(-Math.floor((limit - 1) / 2))}`;
|
|
112
|
+
const normalized = bounded.replace(/\s+/g, " ").trim();
|
|
106
113
|
const dim = (text: string) => theme.fg("dim", text);
|
|
107
114
|
const markdown = new Markdown(normalized, 0, 0, dimMarkdownThemeFromTheme(theme), { color: dim });
|
|
108
115
|
const rendered = markdown
|
|
@@ -158,7 +165,7 @@ export class AgentViewer implements Component {
|
|
|
158
165
|
this.tui.requestRender(),
|
|
159
166
|
);
|
|
160
167
|
private composer: Input | undefined;
|
|
161
|
-
/** Rich tool rendering is far too costly to repeat on every
|
|
168
|
+
/** Rich tool rendering is far too costly to repeat on every widget tick. */
|
|
162
169
|
private renderedContent: { key: string; lines: string[] } | undefined;
|
|
163
170
|
|
|
164
171
|
constructor(
|
|
@@ -364,7 +371,9 @@ export class AgentViewer implements Component {
|
|
|
364
371
|
return lines;
|
|
365
372
|
}
|
|
366
373
|
|
|
367
|
-
invalidate(): void {
|
|
374
|
+
invalidate(): void {
|
|
375
|
+
this.renderedContent = undefined;
|
|
376
|
+
}
|
|
368
377
|
|
|
369
378
|
dispose(): void {}
|
|
370
379
|
|
package/package.json
CHANGED
package/runner.ts
CHANGED
|
@@ -192,14 +192,9 @@ function postUserAgentResult(
|
|
|
192
192
|
notifyMainAgent: boolean,
|
|
193
193
|
message: AgentResultMessage,
|
|
194
194
|
): boolean {
|
|
195
|
-
if (isShuttingDown()) return false;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
pi.sendMessage(message, { triggerTurn: true });
|
|
199
|
-
} else {
|
|
200
|
-
// TUI-only session entry: renders in the transcript but never enters the main agent's context.
|
|
201
|
-
pi.appendEntry(message.customType, { content: message.content, details: message.details });
|
|
202
|
-
}
|
|
195
|
+
if (isShuttingDown() || !notifyMainAgent) return false;
|
|
196
|
+
// Steers into a streaming turn, or triggers an immediate response when idle.
|
|
197
|
+
pi.sendMessage(message, { triggerTurn: true });
|
|
203
198
|
return true;
|
|
204
199
|
}
|
|
205
200
|
|
|
@@ -490,7 +485,7 @@ async function runAgentLifecycle(
|
|
|
490
485
|
}
|
|
491
486
|
}
|
|
492
487
|
|
|
493
|
-
/**
|
|
488
|
+
/** Record a failed lifecycle in the widget and deliver it only when squash was requested. */
|
|
494
489
|
export function reportAgentFailure(
|
|
495
490
|
pi: ExtensionAPI,
|
|
496
491
|
isShuttingDown: () => boolean,
|
|
@@ -514,7 +509,7 @@ export function reportAgentFailure(
|
|
|
514
509
|
const resultMessage = buildAgentResultMessage(
|
|
515
510
|
runningAgent,
|
|
516
511
|
{ ok: false, error: message },
|
|
517
|
-
{ display:
|
|
512
|
+
{ display: false },
|
|
518
513
|
);
|
|
519
514
|
widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
|
|
520
515
|
if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
|
|
@@ -556,7 +551,6 @@ export async function runChildTurns(
|
|
|
556
551
|
{ ok: true, response },
|
|
557
552
|
{ display: false },
|
|
558
553
|
);
|
|
559
|
-
postUserAgentResult(pi, isShuttingDown, false, resultMessage);
|
|
560
554
|
runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
|
|
561
555
|
? undefined
|
|
562
556
|
: resultMessage;
|
|
@@ -581,7 +575,7 @@ export async function runChildTurns(
|
|
|
581
575
|
const resultMessage = buildAgentResultMessage(
|
|
582
576
|
runningAgent,
|
|
583
577
|
{ ok: true, response },
|
|
584
|
-
{ display:
|
|
578
|
+
{ display: false },
|
|
585
579
|
);
|
|
586
580
|
if (runningAgent.notifyMainAgent) {
|
|
587
581
|
if (isShuttingDown()) {
|
|
@@ -594,7 +588,6 @@ export async function runChildTurns(
|
|
|
594
588
|
runningAgent.status = posted ? "posted" : runningAgent.status;
|
|
595
589
|
return;
|
|
596
590
|
}
|
|
597
|
-
postUserAgentResult(pi, isShuttingDown, false, resultMessage);
|
|
598
591
|
runningAgent.pendingSquashMessage = resultMessage;
|
|
599
592
|
if (isShuttingDown()) return;
|
|
600
593
|
runningAgent.status = "idle";
|
package/shared.ts
CHANGED
|
@@ -98,8 +98,6 @@ export type AgentCommandDetails = {
|
|
|
98
98
|
toolUses?: number;
|
|
99
99
|
turnCount?: number;
|
|
100
100
|
responseText?: string;
|
|
101
|
-
/** Retained for completed entries written before responseText was persisted. */
|
|
102
|
-
responsePreview?: string;
|
|
103
101
|
error?: string;
|
|
104
102
|
};
|
|
105
103
|
|
|
@@ -294,14 +292,6 @@ export function truncatePlain(text: string, length: number): string {
|
|
|
294
292
|
return `${line.slice(0, Math.max(0, length - 1))}…`;
|
|
295
293
|
}
|
|
296
294
|
|
|
297
|
-
export function extractTag(
|
|
298
|
-
content: string,
|
|
299
|
-
tag: "task" | "response" | "error" | "duration_ms",
|
|
300
|
-
): string | undefined {
|
|
301
|
-
const match = content.match(new RegExp(`<${tag}>\\n?([\\s\\S]*?)\\n?</${tag}>`));
|
|
302
|
-
return match?.[1]?.trim();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
295
|
export function escapeAttribute(value: string): string {
|
|
306
296
|
return value
|
|
307
297
|
.replace(/&/g, "&")
|
package/transcript.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type Component, Container, Markdown, Text } from "@earendil-works/pi-tui";
|
|
1
|
+
import { type Component, Text } from "@earendil-works/pi-tui";
|
|
3
2
|
import type {
|
|
4
3
|
AgentCommandDetails,
|
|
5
4
|
AgentCommandName,
|
|
@@ -12,21 +11,15 @@ import type {
|
|
|
12
11
|
ExtensionCommandContext,
|
|
13
12
|
RebasedEntryData,
|
|
14
13
|
RunningAgent,
|
|
15
|
-
Theme,
|
|
16
14
|
} from "./shared.js";
|
|
17
15
|
import {
|
|
18
16
|
ATTACHED_ENTRY_TYPE,
|
|
19
17
|
contextLabel,
|
|
20
18
|
DETACHED_ENTRY_TYPE,
|
|
21
19
|
escapeAttribute,
|
|
22
|
-
extractTag,
|
|
23
|
-
formatMs,
|
|
24
|
-
formatToolUses,
|
|
25
|
-
formatTurns,
|
|
26
20
|
mainContextLabel,
|
|
27
21
|
MESSAGE_TYPE,
|
|
28
22
|
REBASED_ENTRY_TYPE,
|
|
29
|
-
truncatePlain,
|
|
30
23
|
} from "./shared.js";
|
|
31
24
|
|
|
32
25
|
export function reportCommandError(
|
|
@@ -195,28 +188,14 @@ export function formatStartNotification(agent: RunningAgent): string {
|
|
|
195
188
|
}
|
|
196
189
|
|
|
197
190
|
export function registerUserAgentRenderer(pi: ExtensionAPI): void {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
),
|
|
207
|
-
);
|
|
208
|
-
pi.registerEntryRenderer<AgentEntryData>(
|
|
209
|
-
MESSAGE_TYPE,
|
|
210
|
-
(entry, { expanded }, theme) =>
|
|
211
|
-
entry.data
|
|
212
|
-
? liveAgentCard(
|
|
213
|
-
() => entry.data!.content,
|
|
214
|
-
() => entry.data!.details,
|
|
215
|
-
expanded,
|
|
216
|
-
theme,
|
|
217
|
-
)
|
|
218
|
-
: undefined,
|
|
219
|
-
);
|
|
191
|
+
const hiddenResult: Component = { render: () => [], invalidate: () => undefined };
|
|
192
|
+
pi.registerMessageRenderer<AgentCommandDetails>(MESSAGE_TYPE, (message, _state, theme) => {
|
|
193
|
+
const details = message.details;
|
|
194
|
+
if (!details || details.agentId || details.inheritedContext !== undefined || details.ok)
|
|
195
|
+
return hiddenResult;
|
|
196
|
+
return new Text(theme.fg("error", `/${details.command}: ${details.error}`), 1, 0);
|
|
197
|
+
});
|
|
198
|
+
pi.registerEntryRenderer<AgentEntryData>(MESSAGE_TYPE, () => hiddenResult);
|
|
220
199
|
// Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
|
|
221
200
|
pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
|
|
222
201
|
entry.data
|
|
@@ -255,90 +234,3 @@ function formatTokenCount(tokens: number): string {
|
|
|
255
234
|
return tokens < 1000 ? `${tokens}` : `${Math.round(tokens / 1000)}K`;
|
|
256
235
|
}
|
|
257
236
|
|
|
258
|
-
function liveAgentCard(
|
|
259
|
-
content: () => string,
|
|
260
|
-
details: () => AgentCommandDetails | undefined,
|
|
261
|
-
expanded: boolean,
|
|
262
|
-
theme: Theme,
|
|
263
|
-
): Component {
|
|
264
|
-
const body = renderAgentCardBody(content(), details(), expanded, theme);
|
|
265
|
-
return {
|
|
266
|
-
render: (width) => renderAgentCard(content(), details(), body, theme).render(width),
|
|
267
|
-
invalidate: () => body.invalidate(),
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function renderAgentCard(
|
|
272
|
-
content: string,
|
|
273
|
-
details: AgentCommandDetails | undefined,
|
|
274
|
-
body: Component,
|
|
275
|
-
theme: Theme,
|
|
276
|
-
): Container {
|
|
277
|
-
const ok = details?.ok ?? !content.includes("<user_agent_error");
|
|
278
|
-
const command = details ? `/${details.command}` : "/agent";
|
|
279
|
-
const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
280
|
-
const statusText = ok ? theme.fg("dim", "completed") : theme.fg("error", "failed");
|
|
281
|
-
const parts = buildRendererParts(content, details);
|
|
282
|
-
const task = details?.task ?? extractTag(content, "task");
|
|
283
|
-
|
|
284
|
-
let text = `${icon} ${theme.bold(command)} ${statusText}`;
|
|
285
|
-
if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
|
|
286
|
-
if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
|
|
287
|
-
const card = new Container();
|
|
288
|
-
card.addChild(new Text(text, 1, 0));
|
|
289
|
-
card.addChild(body);
|
|
290
|
-
return card;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function renderAgentCardBody(
|
|
294
|
-
content: string,
|
|
295
|
-
details: AgentCommandDetails | undefined,
|
|
296
|
-
expanded: boolean,
|
|
297
|
-
theme: Theme,
|
|
298
|
-
): Component {
|
|
299
|
-
const ok = details?.ok ?? !content.includes("<user_agent_error");
|
|
300
|
-
const output =
|
|
301
|
-
details?.responseText ??
|
|
302
|
-
details?.error ??
|
|
303
|
-
extractTag(content, ok ? "response" : "error") ??
|
|
304
|
-
"No output.";
|
|
305
|
-
if (expanded)
|
|
306
|
-
return ok
|
|
307
|
-
? new Markdown(output, 3, 0, getMarkdownTheme())
|
|
308
|
-
: new Text(theme.fg("error", output), 3, 0);
|
|
309
|
-
|
|
310
|
-
const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
|
|
311
|
-
if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
|
|
312
|
-
return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
|
|
313
|
-
color: (value) => theme.fg("dim", value),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function customMessageContentText(content: unknown): string {
|
|
318
|
-
if (typeof content === "string") return content;
|
|
319
|
-
if (!Array.isArray(content)) return String(content ?? "");
|
|
320
|
-
return content
|
|
321
|
-
.filter(
|
|
322
|
-
(part): part is { type: "text"; text: string } =>
|
|
323
|
-
part?.type === "text" && typeof part.text === "string",
|
|
324
|
-
)
|
|
325
|
-
.map((part) => part.text)
|
|
326
|
-
.join("\n");
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function buildRendererParts(content: string, details: AgentCommandDetails | undefined): string[] {
|
|
330
|
-
const parts: string[] = [];
|
|
331
|
-
if (details?.modelLabel) parts.push(details.modelLabel);
|
|
332
|
-
else if (details?.model) parts.push(details.model);
|
|
333
|
-
if (details?.inheritedContext !== undefined) parts.push(contextLabel(details.inheritedContext));
|
|
334
|
-
const mainContext = mainContextLabel(details?.mainContextState);
|
|
335
|
-
if (mainContext) parts.push(mainContext);
|
|
336
|
-
if (details?.turnCount) parts.push(formatTurns(details.turnCount));
|
|
337
|
-
if (details?.toolUses) parts.push(formatToolUses(details.toolUses));
|
|
338
|
-
if (details?.durationMs !== undefined) parts.push(formatMs(details.durationMs));
|
|
339
|
-
else {
|
|
340
|
-
const duration = extractTag(content, "duration_ms");
|
|
341
|
-
if (duration) parts.push(formatMs(Number(duration)));
|
|
342
|
-
}
|
|
343
|
-
return parts;
|
|
344
|
-
}
|
package/widget.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { renderAgentContextMeter } from "./context-meter.js";
|
|
18
18
|
import { selectRebaseMessages } from "./rebase.js";
|
|
19
19
|
import type {
|
|
20
|
+
AgentMessage,
|
|
20
21
|
AgentResultMessage,
|
|
21
22
|
CompletedAgent,
|
|
22
23
|
RebaseDelivery,
|
|
@@ -42,6 +43,15 @@ type UserAgentWidgetEntry =
|
|
|
42
43
|
| { kind: "running"; startedAt: number; agent: RunningAgent }
|
|
43
44
|
| { kind: "completed"; startedAt: number; agent: CompletedAgent };
|
|
44
45
|
|
|
46
|
+
type CachedActivityPreview = {
|
|
47
|
+
source: AgentMessage | string | undefined;
|
|
48
|
+
status: RunningAgent["status"] | boolean;
|
|
49
|
+
error: string | undefined;
|
|
50
|
+
width: number;
|
|
51
|
+
theme: Theme;
|
|
52
|
+
text: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
45
55
|
export class UserAgentWidget {
|
|
46
56
|
private ui: UIContext | undefined;
|
|
47
57
|
private frame = 0;
|
|
@@ -52,6 +62,7 @@ export class UserAgentWidget {
|
|
|
52
62
|
private active = false;
|
|
53
63
|
private selectedIndex = 0;
|
|
54
64
|
private viewerOpen = false;
|
|
65
|
+
private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
|
|
55
66
|
/** One confirmation slot for detaching the selected agent. */
|
|
56
67
|
private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
|
|
57
68
|
private readonly completedAgents: CompletedAgent[] = [];
|
|
@@ -67,6 +78,7 @@ export class UserAgentWidget {
|
|
|
67
78
|
if (ui === this.ui) return;
|
|
68
79
|
this.inputUnsub?.();
|
|
69
80
|
this.ui = ui;
|
|
81
|
+
this.activityPreviews = new WeakMap();
|
|
70
82
|
this.widgetRegistered = false;
|
|
71
83
|
this.tui = undefined;
|
|
72
84
|
this.detachConfirmation.cancel();
|
|
@@ -101,12 +113,18 @@ export class UserAgentWidget {
|
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
ensureTimer(): void {
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
const active = [...this.runningAgents].some(isLiveAgent);
|
|
117
|
+
if (!active && this.interval) {
|
|
118
|
+
clearInterval(this.interval);
|
|
119
|
+
this.interval = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!this.ui || !active || this.interval) return;
|
|
122
|
+
this.interval = setInterval(() => this.update(), 200);
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
update(): void {
|
|
109
126
|
if (!this.ui) return;
|
|
127
|
+
this.ensureTimer();
|
|
110
128
|
const entries = this.entries();
|
|
111
129
|
if (entries.length === 0) {
|
|
112
130
|
this.clear();
|
|
@@ -123,8 +141,7 @@ export class UserAgentWidget {
|
|
|
123
141
|
return {
|
|
124
142
|
render: (width: number) => this.renderWidget(width, theme),
|
|
125
143
|
invalidate: () => {
|
|
126
|
-
this.
|
|
127
|
-
this.tui = undefined;
|
|
144
|
+
this.activityPreviews = new WeakMap();
|
|
128
145
|
},
|
|
129
146
|
};
|
|
130
147
|
},
|
|
@@ -148,6 +165,7 @@ export class UserAgentWidget {
|
|
|
148
165
|
this.widgetRegistered = false;
|
|
149
166
|
this.tui = undefined;
|
|
150
167
|
this.active = false;
|
|
168
|
+
this.activityPreviews = new WeakMap();
|
|
151
169
|
this.detachConfirmation.cancel();
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -583,16 +601,30 @@ export class UserAgentWidget {
|
|
|
583
601
|
.join(" ");
|
|
584
602
|
const activityPrefix = `${indent} ⎿ `;
|
|
585
603
|
const activityWidth = Math.max(0, width - visibleWidth(activityPrefix));
|
|
604
|
+
const preview = this.renderActivityPreview(agent, activityWidth, theme);
|
|
605
|
+
const activityText = ok ? preview : theme.fg("error", preview);
|
|
606
|
+
const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
|
|
607
|
+
return [truncateToWidth(header, width), truncateToWidth(activity, width)];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private renderActivityPreview(agent: ViewableAgent, width: number, theme: Theme): string {
|
|
611
|
+
const running = isRunningAgent(agent);
|
|
612
|
+
const source = isLiveAgent(agent) ? agent.latestFinalizedMessage : agent.responseText;
|
|
613
|
+
const status = running ? agent.status : agent.ok;
|
|
614
|
+
const cached = this.activityPreviews.get(agent);
|
|
615
|
+
if (
|
|
616
|
+
cached && cached.source === source && cached.status === status &&
|
|
617
|
+
cached.error === agent.error && cached.width === width && cached.theme === theme
|
|
618
|
+
) return cached.text;
|
|
586
619
|
const description = running
|
|
587
620
|
? describeActivity(agent)
|
|
588
621
|
: {
|
|
589
622
|
text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
|
|
590
623
|
truncation: "tail" as const,
|
|
591
624
|
};
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return [truncateToWidth(header, width), truncateToWidth(activity, width)];
|
|
625
|
+
const text = renderActivity(description, width, theme);
|
|
626
|
+
this.activityPreviews.set(agent, { source, status, error: agent.error, width, theme, text });
|
|
627
|
+
return text;
|
|
596
628
|
}
|
|
597
629
|
}
|
|
598
630
|
|