@giladbarnea/pi-user-agents 0.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/INTERNALS.md +65 -0
- package/PARSER_SPEC.md +264 -0
- package/README.md +127 -0
- package/agent-viewer.ts +653 -0
- package/autocomplete.ts +576 -0
- package/command-line.ts +746 -0
- package/context-meter.ts +41 -0
- package/editor-coloring.ts +160 -0
- package/index.ts +74 -0
- package/package.json +44 -0
- package/runner.ts +591 -0
- package/shared.ts +214 -0
- package/tool-render/FORK.md +108 -0
- package/tool-render/ansi.ts +77 -0
- package/tool-render/constants.ts +48 -0
- package/tool-render/diff.ts +957 -0
- package/tool-render/index.ts +137 -0
- package/tool-render/text.ts +212 -0
- package/tool-render/theme.ts +71 -0
- package/tool-render/tools.ts +502 -0
- package/transcript.ts +303 -0
- package/widget.ts +523 -0
package/widget.ts
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isKeyRelease,
|
|
3
|
+
matchesKey,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
AgentViewer,
|
|
9
|
+
type AgentViewerAction,
|
|
10
|
+
describeActivity,
|
|
11
|
+
isLiveAgent,
|
|
12
|
+
isRunningAgent,
|
|
13
|
+
renderActivity,
|
|
14
|
+
transcriptMessages,
|
|
15
|
+
type ViewableAgent,
|
|
16
|
+
} from "./agent-viewer.js";
|
|
17
|
+
import { renderAgentContextMeter } from "./context-meter.js";
|
|
18
|
+
import type {
|
|
19
|
+
AgentResultMessage,
|
|
20
|
+
CompletedAgent,
|
|
21
|
+
RunningAgent,
|
|
22
|
+
Theme,
|
|
23
|
+
UIContext,
|
|
24
|
+
} from "./shared.js";
|
|
25
|
+
import {
|
|
26
|
+
contextLabel,
|
|
27
|
+
formatMs,
|
|
28
|
+
formatToolUses,
|
|
29
|
+
formatTurns,
|
|
30
|
+
logSteering,
|
|
31
|
+
mainContextLabel,
|
|
32
|
+
MAX_WIDGET_LINES,
|
|
33
|
+
SPINNER,
|
|
34
|
+
truncatePlain,
|
|
35
|
+
WIDGET_KEY,
|
|
36
|
+
} from "./shared.js";
|
|
37
|
+
|
|
38
|
+
type UserAgentWidgetEntry =
|
|
39
|
+
| { kind: "running"; startedAt: number; agent: RunningAgent }
|
|
40
|
+
| { kind: "completed"; startedAt: number; agent: CompletedAgent };
|
|
41
|
+
|
|
42
|
+
export class UserAgentWidget {
|
|
43
|
+
private ui: UIContext | undefined;
|
|
44
|
+
private frame = 0;
|
|
45
|
+
private interval: ReturnType<typeof setInterval> | undefined;
|
|
46
|
+
private widgetRegistered = false;
|
|
47
|
+
private tui: { requestRender(): void } | undefined;
|
|
48
|
+
private inputUnsub: (() => void) | undefined;
|
|
49
|
+
private active = false;
|
|
50
|
+
private selectedIndex = 0;
|
|
51
|
+
private viewerOpen = false;
|
|
52
|
+
private disposeConfirmationTarget: ViewableAgent | undefined;
|
|
53
|
+
private readonly completedAgents: CompletedAgent[] = [];
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly runningAgents: Set<RunningAgent>,
|
|
57
|
+
private readonly sendJoinedResult: (message: AgentResultMessage) => void,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
setUI(ui: UIContext): void {
|
|
61
|
+
if (ui === this.ui) return;
|
|
62
|
+
this.inputUnsub?.();
|
|
63
|
+
this.ui = ui;
|
|
64
|
+
this.widgetRegistered = false;
|
|
65
|
+
this.tui = undefined;
|
|
66
|
+
this.disposeConfirmationTarget = undefined;
|
|
67
|
+
this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
addCompleted(
|
|
71
|
+
agent: RunningAgent,
|
|
72
|
+
resultMessage: AgentResultMessage,
|
|
73
|
+
options: { joinable: boolean },
|
|
74
|
+
): void {
|
|
75
|
+
this.completedAgents.push({
|
|
76
|
+
id: agent.id,
|
|
77
|
+
command: agent.command,
|
|
78
|
+
modelLabel: agent.modelLabel,
|
|
79
|
+
task: agent.task,
|
|
80
|
+
mainContextState: agent.mainContextState,
|
|
81
|
+
pendingJoinMessage: options.joinable ? resultMessage : undefined,
|
|
82
|
+
ok: resultMessage.details.ok,
|
|
83
|
+
responseText: agent.responseText,
|
|
84
|
+
messages: structuredClone(transcriptMessages(agent)),
|
|
85
|
+
error: agent.error,
|
|
86
|
+
startedAt: agent.startedAt,
|
|
87
|
+
durationMs: (agent.completedAt ?? Date.now()) - agent.turnStartedAt,
|
|
88
|
+
toolUses: agent.toolUses,
|
|
89
|
+
turnCount: agent.turnCount,
|
|
90
|
+
contextPercent: agent.session?.getContextUsage()?.percent ?? undefined,
|
|
91
|
+
});
|
|
92
|
+
this.update();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ensureTimer(): void {
|
|
96
|
+
if (!this.ui || this.interval) return;
|
|
97
|
+
this.interval = setInterval(() => this.update(), 100);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
update(): void {
|
|
101
|
+
if (!this.ui) return;
|
|
102
|
+
const entries = this.entries();
|
|
103
|
+
if (entries.length === 0) {
|
|
104
|
+
this.clear();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.frame += 1;
|
|
109
|
+
this.clampSelection();
|
|
110
|
+
if (!this.widgetRegistered) {
|
|
111
|
+
this.ui.setWidget(
|
|
112
|
+
WIDGET_KEY,
|
|
113
|
+
(tui, theme) => {
|
|
114
|
+
this.tui = tui;
|
|
115
|
+
return {
|
|
116
|
+
render: (width: number) => this.renderWidget(width, theme),
|
|
117
|
+
invalidate: () => {
|
|
118
|
+
this.widgetRegistered = false;
|
|
119
|
+
this.tui = undefined;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
{ placement: "belowEditor" },
|
|
124
|
+
);
|
|
125
|
+
this.widgetRegistered = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.tui?.requestRender();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
dispose(): void {
|
|
132
|
+
if (this.interval) {
|
|
133
|
+
clearInterval(this.interval);
|
|
134
|
+
this.interval = undefined;
|
|
135
|
+
}
|
|
136
|
+
this.inputUnsub?.();
|
|
137
|
+
this.inputUnsub = undefined;
|
|
138
|
+
if (this.ui) this.ui.setWidget(WIDGET_KEY, undefined);
|
|
139
|
+
this.ui = undefined;
|
|
140
|
+
this.widgetRegistered = false;
|
|
141
|
+
this.tui = undefined;
|
|
142
|
+
this.active = false;
|
|
143
|
+
this.disposeConfirmationTarget = undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private clear(): void {
|
|
147
|
+
if (this.widgetRegistered) {
|
|
148
|
+
this.ui?.setWidget(WIDGET_KEY, undefined);
|
|
149
|
+
this.widgetRegistered = false;
|
|
150
|
+
this.tui = undefined;
|
|
151
|
+
}
|
|
152
|
+
if (this.interval) {
|
|
153
|
+
clearInterval(this.interval);
|
|
154
|
+
this.interval = undefined;
|
|
155
|
+
}
|
|
156
|
+
this.active = false;
|
|
157
|
+
this.selectedIndex = 0;
|
|
158
|
+
this.disposeConfirmationTarget = undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private runningAgentsForWidget(): RunningAgent[] {
|
|
162
|
+
return [...this.runningAgents]
|
|
163
|
+
.filter(
|
|
164
|
+
(agent) =>
|
|
165
|
+
agent.status === "starting" || agent.status === "running" || agent.status === "idle",
|
|
166
|
+
)
|
|
167
|
+
.sort((left, right) => left.startedAt - right.startedAt);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private completedAgentsForWidget(): CompletedAgent[] {
|
|
171
|
+
return [...this.completedAgents].sort((left, right) => left.startedAt - right.startedAt);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private entries(): UserAgentWidgetEntry[] {
|
|
175
|
+
return [
|
|
176
|
+
...this.runningAgentsForWidget().map(
|
|
177
|
+
(agent): UserAgentWidgetEntry => ({ kind: "running", startedAt: agent.startedAt, agent }),
|
|
178
|
+
),
|
|
179
|
+
...this.completedAgentsForWidget().map(
|
|
180
|
+
(agent): UserAgentWidgetEntry => ({ kind: "completed", startedAt: agent.startedAt, agent }),
|
|
181
|
+
),
|
|
182
|
+
].sort((left, right) => left.startedAt - right.startedAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private handleKey(data: string): { consume?: boolean; data?: string } | undefined {
|
|
186
|
+
if (!this.ui || isKeyRelease(data) || this.viewerOpen) return undefined;
|
|
187
|
+
const entries = this.entries();
|
|
188
|
+
if (entries.length === 0) return undefined;
|
|
189
|
+
|
|
190
|
+
if (!this.active) {
|
|
191
|
+
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
192
|
+
if (isActivator && this.ui.getEditorText() === "") {
|
|
193
|
+
this.active = true;
|
|
194
|
+
this.selectedIndex = 0;
|
|
195
|
+
this.update();
|
|
196
|
+
return { consume: true };
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (matchesKey(data, "down")) {
|
|
202
|
+
this.disposeConfirmationTarget = undefined;
|
|
203
|
+
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
204
|
+
this.update();
|
|
205
|
+
return { consume: true };
|
|
206
|
+
}
|
|
207
|
+
if (matchesKey(data, "up")) {
|
|
208
|
+
this.disposeConfirmationTarget = undefined;
|
|
209
|
+
if (this.selectedIndex === 0) {
|
|
210
|
+
this.deactivate();
|
|
211
|
+
return { consume: true };
|
|
212
|
+
}
|
|
213
|
+
this.selectedIndex -= 1;
|
|
214
|
+
this.update();
|
|
215
|
+
return { consume: true };
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, "escape")) {
|
|
218
|
+
this.deactivate();
|
|
219
|
+
return { consume: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const selected = entries[this.selectedIndex];
|
|
223
|
+
if (
|
|
224
|
+
matchesKey(data, "ctrl+x") &&
|
|
225
|
+
selected?.kind === "running" &&
|
|
226
|
+
isLiveAgent(selected.agent)
|
|
227
|
+
) {
|
|
228
|
+
this.disposeConfirmationTarget = undefined;
|
|
229
|
+
this.interruptRunning(selected.agent);
|
|
230
|
+
return { consume: true };
|
|
231
|
+
}
|
|
232
|
+
if (matchesKey(data, "enter")) {
|
|
233
|
+
this.disposeConfirmationTarget = undefined;
|
|
234
|
+
if (selected) this.openSelected(selected.agent);
|
|
235
|
+
return { consume: true };
|
|
236
|
+
}
|
|
237
|
+
if (matchesKey(data, "x") && selected) {
|
|
238
|
+
if (!this.confirmDispose(selected.agent)) return { consume: true };
|
|
239
|
+
if (selected.kind === "running") this.closeRunning(selected.agent);
|
|
240
|
+
else this.dismissCompleted(selected.agent);
|
|
241
|
+
return { consume: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.deactivate();
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private confirmDispose(target: ViewableAgent): boolean {
|
|
249
|
+
if (this.disposeConfirmationTarget === target) {
|
|
250
|
+
this.disposeConfirmationTarget = undefined;
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
this.disposeConfirmationTarget = target;
|
|
254
|
+
this.update();
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private deactivate(): void {
|
|
259
|
+
this.active = false;
|
|
260
|
+
this.selectedIndex = 0;
|
|
261
|
+
this.disposeConfirmationTarget = undefined;
|
|
262
|
+
this.update();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private clampSelection(): void {
|
|
266
|
+
const total = this.entries().length;
|
|
267
|
+
if (total === 0) {
|
|
268
|
+
this.active = false;
|
|
269
|
+
this.selectedIndex = 0;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.selectedIndex = Math.min(Math.max(0, this.selectedIndex), total - 1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private openSelected(agent: ViewableAgent): void {
|
|
276
|
+
if (!this.ui) return;
|
|
277
|
+
const ui = this.ui;
|
|
278
|
+
this.viewerOpen = true;
|
|
279
|
+
void ui
|
|
280
|
+
.custom<AgentViewerAction>(
|
|
281
|
+
(tui, theme, _keybindings, done) =>
|
|
282
|
+
new AgentViewer(
|
|
283
|
+
tui,
|
|
284
|
+
agent,
|
|
285
|
+
theme,
|
|
286
|
+
done,
|
|
287
|
+
() => this.canJoinMainContext(agent.id),
|
|
288
|
+
() => this.joinMainContext(agent.id),
|
|
289
|
+
() => {
|
|
290
|
+
if (isRunningAgent(agent)) this.interruptRunning(agent);
|
|
291
|
+
},
|
|
292
|
+
),
|
|
293
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "70%" } },
|
|
294
|
+
)
|
|
295
|
+
.then(
|
|
296
|
+
(action) => this.onViewerClosed(agent, action),
|
|
297
|
+
() => this.onViewerClosed(agent, "hide"),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private closeRunning(agent: RunningAgent): void {
|
|
302
|
+
agent.aborted = true;
|
|
303
|
+
void agent.session?.abort();
|
|
304
|
+
agent.retire?.();
|
|
305
|
+
this.update();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private interruptRunning(agent: RunningAgent): void {
|
|
309
|
+
agent.interruptRequested = true;
|
|
310
|
+
void agent.session?.abort();
|
|
311
|
+
this.update();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private idleAgent(agentId: string): RunningAgent | undefined {
|
|
315
|
+
return [...this.runningAgents].find((agent) => agent.id === agentId && agent.status === "idle");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private canJoinMainContext(agentId: string): boolean {
|
|
319
|
+
if (this.idleAgent(agentId)?.pendingJoinMessage) return true;
|
|
320
|
+
return this.completedAgents.some(
|
|
321
|
+
(agent) => agent.id === agentId && agent.pendingJoinMessage !== undefined,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
confirmMainContextJoin(agentId: string): boolean {
|
|
326
|
+
const agents = [
|
|
327
|
+
...[...this.runningAgents].filter((candidate) => candidate.id === agentId),
|
|
328
|
+
...this.completedAgents.filter((candidate) => candidate.id === agentId),
|
|
329
|
+
].filter((candidate) => candidate.mainContextState === "will-join");
|
|
330
|
+
if (agents.length === 0) return false;
|
|
331
|
+
for (const agent of agents) agent.mainContextState = "joined";
|
|
332
|
+
this.update();
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private joinMainContext(agentId: string): void {
|
|
337
|
+
const idleAgent = this.idleAgent(agentId);
|
|
338
|
+
if (idleAgent) {
|
|
339
|
+
this.joinIdleAgent(idleAgent);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
|
|
343
|
+
const message = completedAgent?.pendingJoinMessage;
|
|
344
|
+
if (!completedAgent || !message) return;
|
|
345
|
+
|
|
346
|
+
completedAgent.pendingJoinMessage = undefined;
|
|
347
|
+
completedAgent.mainContextState = "will-join";
|
|
348
|
+
message.details.mainContextState = "will-join";
|
|
349
|
+
this.sendJoinedResult(message);
|
|
350
|
+
logSteering(agentId, "joined-main-context", { display: message.display });
|
|
351
|
+
this.update();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Joining is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
|
|
355
|
+
private joinIdleAgent(agent: RunningAgent): void {
|
|
356
|
+
const message = agent.pendingJoinMessage;
|
|
357
|
+
if (!message) return;
|
|
358
|
+
agent.pendingJoinMessage = undefined;
|
|
359
|
+
agent.mainContextState = "will-join";
|
|
360
|
+
message.details.mainContextState = "will-join";
|
|
361
|
+
agent.status = "posted";
|
|
362
|
+
this.addCompleted(agent, message, { joinable: false });
|
|
363
|
+
this.sendJoinedResult(message);
|
|
364
|
+
agent.retire?.();
|
|
365
|
+
logSteering(agent.id, "joined-main-context", { display: message.display, live: true });
|
|
366
|
+
this.update();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private onViewerClosed(agent: ViewableAgent, action: AgentViewerAction): void {
|
|
370
|
+
this.viewerOpen = false;
|
|
371
|
+
if (action === "dispose" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
|
|
372
|
+
this.closeRunning(agent);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const completedAgent = this.completedAgents.find((candidate) => candidate.id === agent.id);
|
|
376
|
+
if (action === "dispose" && completedAgent) this.removeCompleted(completedAgent);
|
|
377
|
+
else this.update();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private dismissCompleted(agent: CompletedAgent): void {
|
|
381
|
+
this.removeCompleted(agent);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private removeCompleted(agent: CompletedAgent): void {
|
|
385
|
+
const index = this.completedAgents.indexOf(agent);
|
|
386
|
+
if (index >= 0) this.completedAgents.splice(index, 1);
|
|
387
|
+
const total = this.entries().length;
|
|
388
|
+
if (total === 0) {
|
|
389
|
+
this.deactivate();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
this.selectedIndex = Math.min(this.selectedIndex, total - 1);
|
|
393
|
+
this.update();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private renderWidget(width: number, theme: Theme): string[] {
|
|
397
|
+
const entries = this.entries();
|
|
398
|
+
if (entries.length === 0) return [];
|
|
399
|
+
|
|
400
|
+
const anyRunning = entries.some(
|
|
401
|
+
(entry) => entry.kind === "running" && isLiveAgent(entry.agent),
|
|
402
|
+
);
|
|
403
|
+
const anyError = this.completedAgents.some((agent) => !agent.ok);
|
|
404
|
+
const headingColor = anyRunning ? "accent" : anyError ? "error" : "success";
|
|
405
|
+
const headingGlyph = anyRunning ? "●" : anyError ? "✗" : "✓";
|
|
406
|
+
const lines = [
|
|
407
|
+
truncateToWidth(
|
|
408
|
+
`${theme.fg(headingColor, headingGlyph)} ${theme.fg(headingColor, "User agents")}`,
|
|
409
|
+
width,
|
|
410
|
+
),
|
|
411
|
+
];
|
|
412
|
+
if (!this.active) {
|
|
413
|
+
lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
|
|
414
|
+
} else {
|
|
415
|
+
const selected = entries[this.selectedIndex];
|
|
416
|
+
const confirmingDispose = this.disposeConfirmationTarget === selected?.agent;
|
|
417
|
+
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
418
|
+
if (
|
|
419
|
+
!confirmingDispose &&
|
|
420
|
+
selected?.kind === "running" &&
|
|
421
|
+
isLiveAgent(selected.agent)
|
|
422
|
+
)
|
|
423
|
+
segments.push(theme.fg("accent", "Ctrl+x interrupt"));
|
|
424
|
+
segments.push(
|
|
425
|
+
theme.fg(
|
|
426
|
+
confirmingDispose ? "error" : "accent",
|
|
427
|
+
confirmingDispose ? "x again to confirm" : "x dispose",
|
|
428
|
+
),
|
|
429
|
+
theme.fg("accent", "Esc back"),
|
|
430
|
+
);
|
|
431
|
+
lines.push(
|
|
432
|
+
truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const maxEntries = Math.max(1, Math.floor((MAX_WIDGET_LINES - lines.length) / 2));
|
|
437
|
+
const start = this.active
|
|
438
|
+
? clampWindowStart(this.selectedIndex, maxEntries, entries.length)
|
|
439
|
+
: 0;
|
|
440
|
+
const renderedEntries = entries.slice(start, start + maxEntries);
|
|
441
|
+
for (const [visibleIndex, entry] of renderedEntries.entries()) {
|
|
442
|
+
const entryIndex = start + visibleIndex;
|
|
443
|
+
const isLastVisible = entryIndex === entries.length - 1;
|
|
444
|
+
const connector = isLastVisible ? "└─" : "├─";
|
|
445
|
+
const indent = isLastVisible ? " " : "│ ";
|
|
446
|
+
const selected = this.active && entryIndex === this.selectedIndex;
|
|
447
|
+
lines.push(...this.renderAgentEntry(entry.agent, connector, indent, selected, width, theme));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const hiddenCount = entries.length - (start + renderedEntries.length);
|
|
451
|
+
if (hiddenCount > 0) {
|
|
452
|
+
lines.push(
|
|
453
|
+
truncateToWidth(
|
|
454
|
+
`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hiddenCount} more`)}`,
|
|
455
|
+
width,
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return lines.slice(0, MAX_WIDGET_LINES);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private renderAgentEntry(
|
|
463
|
+
agent: ViewableAgent,
|
|
464
|
+
connector: string,
|
|
465
|
+
indent: string,
|
|
466
|
+
selected: boolean,
|
|
467
|
+
width: number,
|
|
468
|
+
theme: Theme,
|
|
469
|
+
): string[] {
|
|
470
|
+
const running = isRunningAgent(agent);
|
|
471
|
+
const inFlight = isLiveAgent(agent);
|
|
472
|
+
const ok = running ? agent.error === undefined : agent.ok;
|
|
473
|
+
const marker = selected ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
|
|
474
|
+
const icon = inFlight
|
|
475
|
+
? theme.fg("accent", SPINNER[this.frame % SPINNER.length])
|
|
476
|
+
: ok
|
|
477
|
+
? theme.fg("success", "✓")
|
|
478
|
+
: theme.fg("error", "✗");
|
|
479
|
+
const parts = inFlight
|
|
480
|
+
? [agent.modelLabel, contextLabel(agent.inheritedContext)]
|
|
481
|
+
: [agent.modelLabel];
|
|
482
|
+
const mainContext = mainContextLabel(agent.mainContextState);
|
|
483
|
+
if (mainContext) parts.push(mainContext);
|
|
484
|
+
if (agent.turnCount > 0) parts.push(formatTurns(agent.turnCount));
|
|
485
|
+
if (agent.toolUses > 0) parts.push(formatToolUses(agent.toolUses));
|
|
486
|
+
parts.push(
|
|
487
|
+
formatMs(
|
|
488
|
+
running ? (agent.completedAt ?? Date.now()) - agent.turnStartedAt : agent.durationMs,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const header = [
|
|
493
|
+
theme.fg("dim", connector),
|
|
494
|
+
marker,
|
|
495
|
+
icon,
|
|
496
|
+
theme.bold(`/${agent.command}`),
|
|
497
|
+
renderAgentContextMeter(agent, theme),
|
|
498
|
+
theme.fg("muted", truncatePlain(agent.task, 52)),
|
|
499
|
+
theme.fg("dim", "·"),
|
|
500
|
+
theme.fg("dim", parts.join(" · ")),
|
|
501
|
+
]
|
|
502
|
+
.filter(Boolean)
|
|
503
|
+
.join(" ");
|
|
504
|
+
const activityPrefix = `${indent} ⎿ `;
|
|
505
|
+
const activityWidth = Math.max(0, width - visibleWidth(activityPrefix));
|
|
506
|
+
const description = running
|
|
507
|
+
? describeActivity(agent)
|
|
508
|
+
: {
|
|
509
|
+
text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
|
|
510
|
+
truncation: "tail" as const,
|
|
511
|
+
};
|
|
512
|
+
const preview = renderActivity(description, activityWidth, theme);
|
|
513
|
+
const activityText = ok ? preview : theme.fg("error", preview);
|
|
514
|
+
const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
|
|
515
|
+
return [truncateToWidth(header, width), truncateToWidth(activity, width)];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function clampWindowStart(selected: number, visibleCount: number, total: number): number {
|
|
520
|
+
if (total <= visibleCount) return 0;
|
|
521
|
+
const half = Math.floor(visibleCount / 2);
|
|
522
|
+
return Math.min(Math.max(0, selected - half), total - visibleCount);
|
|
523
|
+
}
|