@giladbarnea/pi-user-agents 0.0.7 → 0.0.8
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/README.md +6 -5
- package/agent-viewer.ts +24 -6
- package/package.json +1 -1
- package/widget.ts +153 -35
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Every agent gets a row in a widget under the editor: status, task, model, turn c
|
|
|
69
69
|
|
|
70
70
|
<br>
|
|
71
71
|
|
|
72
|
-
Press `←` or `↓` from the editor to focus the widget
|
|
72
|
+
Press `←` or `↓` from the editor to focus the widget. The selected row shows a short session ID and offers the same agent actions as the overlay. Press `Enter` to open the full conversation, streaming live and following the tail as it grows. Tool calls render as proper views — `read`, `edit`, `write`, `grep`, `find`, `ls`, and `bash` each get a dedicated format, everything else a readable generic one.
|
|
73
73
|
|
|
74
74
|
<a href="screenshots/overlay.png">
|
|
75
75
|
<img src="screenshots/overlay.png" width="100%" alt="The agent overlay: a rendered bash call, its output, a todo update, and the final response, over the dimmed main conversation">
|
|
@@ -173,15 +173,16 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
|
|
|
173
173
|
| Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
|
|
174
174
|
| Widget / overlay | `Esc` | Back |
|
|
175
175
|
| Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
176
|
+
| Widget / overlay | `s` | Squash the conversation into the main context |
|
|
177
|
+
| Widget / overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
|
|
178
|
+
| Widget / overlay | `c` | Copy the latest response |
|
|
179
|
+
| Widget / overlay | `i` | Copy the agent's full session ID |
|
|
179
180
|
| Overlay | scroll · `End` | Pause tail-following · resume it |
|
|
180
181
|
|
|
181
182
|
## Good to know
|
|
182
183
|
|
|
183
184
|
- Some accepted `pi` options have no effect on a background run (the session, approval, offline, and API-key families). They parse; they just don't do anything yet.
|
|
184
|
-
- `c`
|
|
185
|
+
- `c` and `i` copy via `pbcopy`, so they are macOS-only for now.
|
|
185
186
|
- The overlay caps very large tool outputs and omits thinking entries.
|
|
186
187
|
|
|
187
188
|
## Roadmap
|
package/agent-viewer.ts
CHANGED
|
@@ -151,13 +151,17 @@ function truncateMiddle(
|
|
|
151
151
|
const VIEWER_CHROME_LINES = 6;
|
|
152
152
|
const VIEWER_MIN_VIEWPORT = 3;
|
|
153
153
|
const VIEWER_HEIGHT_PCT = 85;
|
|
154
|
+
const COPY_FEEDBACK_MS = 1500;
|
|
155
|
+
|
|
156
|
+
type CopyTarget = "response" | "session-id";
|
|
154
157
|
|
|
155
158
|
/** Focus-capturing overlay that shows an agent's live or finished response. */
|
|
156
159
|
export class AgentViewer implements Component {
|
|
157
160
|
private scrollOffset = 0;
|
|
158
161
|
private autoScroll = true;
|
|
159
162
|
private lastWidth = 0;
|
|
160
|
-
private
|
|
163
|
+
private copied: CopyTarget | undefined;
|
|
164
|
+
private copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
161
165
|
private rebaseWarning: string | undefined;
|
|
162
166
|
private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
|
|
163
167
|
/** One confirmation slot for both destructive overlay actions. */
|
|
@@ -212,6 +216,10 @@ export class AgentViewer implements Component {
|
|
|
212
216
|
this.copyResponse();
|
|
213
217
|
return;
|
|
214
218
|
}
|
|
219
|
+
if (matchesKey(data, "i")) {
|
|
220
|
+
this.copyToClipboard(this.agent.sessionId, "session-id");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
215
223
|
if (matchesKey(data, "s") && this.canSquashMainContext()) {
|
|
216
224
|
this.squashMainContext();
|
|
217
225
|
this.tui.requestRender();
|
|
@@ -351,7 +359,11 @@ export class AgentViewer implements Component {
|
|
|
351
359
|
th.fg("dim", `${content.length} lines`),
|
|
352
360
|
th.fg("dim", scrollPct),
|
|
353
361
|
this.canSteer() ? th.fg("dim", "Enter steer") : "",
|
|
354
|
-
this.
|
|
362
|
+
this.copied === "response"
|
|
363
|
+
? th.fg("success", "✓ copied")
|
|
364
|
+
: this.copied === "session-id"
|
|
365
|
+
? th.fg("success", "✓ copied ID")
|
|
366
|
+
: th.fg("dim", "c copy · i ID"),
|
|
355
367
|
th.fg("dim", "↑↓ scroll"),
|
|
356
368
|
th.fg("dim", "PgUp/PgDn"),
|
|
357
369
|
this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
|
|
@@ -401,14 +413,20 @@ export class AgentViewer implements Component {
|
|
|
401
413
|
isRunningAgent(this.agent) || this.agent.ok
|
|
402
414
|
? this.agent.responseText
|
|
403
415
|
: (this.agent.error ?? "");
|
|
416
|
+
this.copyToClipboard(text, "response");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private copyToClipboard(text: string, target: CopyTarget): void {
|
|
404
420
|
const child = childProcess.execFile("pbcopy", (error) => {
|
|
405
421
|
if (error) return;
|
|
406
|
-
this.
|
|
422
|
+
this.copied = target;
|
|
423
|
+
if (this.copiedTimer) clearTimeout(this.copiedTimer);
|
|
407
424
|
this.tui.requestRender();
|
|
408
|
-
setTimeout(() => {
|
|
409
|
-
this.
|
|
425
|
+
this.copiedTimer = setTimeout(() => {
|
|
426
|
+
this.copied = undefined;
|
|
427
|
+
this.copiedTimer = undefined;
|
|
410
428
|
this.tui.requestRender();
|
|
411
|
-
},
|
|
429
|
+
}, COPY_FEEDBACK_MS);
|
|
412
430
|
});
|
|
413
431
|
child.stdin?.end(text);
|
|
414
432
|
}
|
package/package.json
CHANGED
package/widget.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
1
2
|
import {
|
|
2
3
|
isKeyRelease,
|
|
3
4
|
matchesKey,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
UIContext,
|
|
27
28
|
} from "./shared.js";
|
|
28
29
|
import {
|
|
30
|
+
CONFIRMATION_WINDOW_MS,
|
|
29
31
|
contextLabel,
|
|
30
32
|
formatMs,
|
|
31
33
|
TimedConfirmation,
|
|
@@ -52,6 +54,10 @@ type CachedActivityPreview = {
|
|
|
52
54
|
text: string;
|
|
53
55
|
};
|
|
54
56
|
|
|
57
|
+
type CopyTarget = "response" | "session-id";
|
|
58
|
+
|
|
59
|
+
const COPY_FEEDBACK_MS = 1500;
|
|
60
|
+
|
|
55
61
|
export class UserAgentWidget {
|
|
56
62
|
private ui: UIContext | undefined;
|
|
57
63
|
private frame = 0;
|
|
@@ -62,9 +68,13 @@ export class UserAgentWidget {
|
|
|
62
68
|
private active = false;
|
|
63
69
|
private selectedIndex = 0;
|
|
64
70
|
private viewerOpen = false;
|
|
71
|
+
private copied: { agentId: string; target: CopyTarget } | undefined;
|
|
72
|
+
private copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
private rebaseWarning: { agentId: string; text: string } | undefined;
|
|
74
|
+
private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
|
|
65
75
|
private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
|
|
66
|
-
/** One confirmation slot for
|
|
67
|
-
private readonly
|
|
76
|
+
/** One confirmation slot for both destructive selected-row actions. */
|
|
77
|
+
private readonly confirmation = new TimedConfirmation<string>(() => this.update());
|
|
68
78
|
private readonly completedAgents: CompletedAgent[] = [];
|
|
69
79
|
|
|
70
80
|
constructor(
|
|
@@ -81,7 +91,7 @@ export class UserAgentWidget {
|
|
|
81
91
|
this.activityPreviews = new WeakMap();
|
|
82
92
|
this.widgetRegistered = false;
|
|
83
93
|
this.tui = undefined;
|
|
84
|
-
this.
|
|
94
|
+
this.confirmation.cancel();
|
|
85
95
|
this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -166,7 +176,7 @@ export class UserAgentWidget {
|
|
|
166
176
|
this.tui = undefined;
|
|
167
177
|
this.active = false;
|
|
168
178
|
this.activityPreviews = new WeakMap();
|
|
169
|
-
this.
|
|
179
|
+
this.confirmation.cancel();
|
|
170
180
|
}
|
|
171
181
|
|
|
172
182
|
private clear(): void {
|
|
@@ -181,7 +191,7 @@ export class UserAgentWidget {
|
|
|
181
191
|
}
|
|
182
192
|
this.active = false;
|
|
183
193
|
this.selectedIndex = 0;
|
|
184
|
-
this.
|
|
194
|
+
this.confirmation.cancel();
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
private runningAgentsForWidget(): RunningAgent[] {
|
|
@@ -225,13 +235,13 @@ export class UserAgentWidget {
|
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
if (matchesKey(data, "down")) {
|
|
228
|
-
this.
|
|
238
|
+
this.confirmation.cancel();
|
|
229
239
|
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
230
240
|
this.update();
|
|
231
241
|
return { consume: true };
|
|
232
242
|
}
|
|
233
243
|
if (matchesKey(data, "up")) {
|
|
234
|
-
this.
|
|
244
|
+
this.confirmation.cancel();
|
|
235
245
|
if (this.selectedIndex === 0) {
|
|
236
246
|
this.deactivate();
|
|
237
247
|
return { consume: true };
|
|
@@ -246,20 +256,40 @@ export class UserAgentWidget {
|
|
|
246
256
|
}
|
|
247
257
|
|
|
248
258
|
const selected = entries[this.selectedIndex];
|
|
259
|
+
if (!matchesKey(data, "d") && !matchesKey(data, "r")) this.confirmation.cancel();
|
|
249
260
|
if (
|
|
250
261
|
matchesKey(data, "ctrl+x") &&
|
|
251
262
|
selected?.kind === "running" &&
|
|
252
263
|
isLiveAgent(selected.agent)
|
|
253
264
|
) {
|
|
254
|
-
this.detachConfirmation.cancel();
|
|
255
265
|
this.interruptRunning(selected.agent);
|
|
256
266
|
return { consume: true };
|
|
257
267
|
}
|
|
258
268
|
if (matchesKey(data, "enter")) {
|
|
259
|
-
this.detachConfirmation.cancel();
|
|
260
269
|
if (selected) this.openSelected(selected.agent);
|
|
261
270
|
return { consume: true };
|
|
262
271
|
}
|
|
272
|
+
if (matchesKey(data, "c") && selected) {
|
|
273
|
+
const text =
|
|
274
|
+
isRunningAgent(selected.agent) || selected.agent.ok
|
|
275
|
+
? selected.agent.responseText
|
|
276
|
+
: (selected.agent.error ?? "");
|
|
277
|
+
this.copyToClipboard(selected.agent.id, text, "response");
|
|
278
|
+
return { consume: true };
|
|
279
|
+
}
|
|
280
|
+
if (matchesKey(data, "i") && selected) {
|
|
281
|
+
this.copyToClipboard(selected.agent.id, selected.agent.sessionId, "session-id");
|
|
282
|
+
return { consume: true };
|
|
283
|
+
}
|
|
284
|
+
if (matchesKey(data, "s")) {
|
|
285
|
+
if (selected && this.canSquashMainContext(selected.agent.id))
|
|
286
|
+
this.squashMainContext(selected.agent.id);
|
|
287
|
+
return { consume: true };
|
|
288
|
+
}
|
|
289
|
+
if (matchesKey(data, "r")) {
|
|
290
|
+
this.requestRebase(selected);
|
|
291
|
+
return { consume: true };
|
|
292
|
+
}
|
|
263
293
|
if (matchesKey(data, "d") && selected) {
|
|
264
294
|
if (!this.confirmDetach(selected.agent)) return { consume: true };
|
|
265
295
|
if (selected.kind === "running") this.closeRunning(selected.agent);
|
|
@@ -272,15 +302,64 @@ export class UserAgentWidget {
|
|
|
272
302
|
}
|
|
273
303
|
|
|
274
304
|
private confirmDetach(target: ViewableAgent): boolean {
|
|
275
|
-
const confirmed = this.
|
|
305
|
+
const confirmed = this.confirmation.press(`detach:${target.id}`);
|
|
276
306
|
if (!confirmed) this.update();
|
|
277
307
|
return confirmed;
|
|
278
308
|
}
|
|
279
309
|
|
|
310
|
+
private requestRebase(selected: UserAgentWidgetEntry | undefined): void {
|
|
311
|
+
if (!selected) return;
|
|
312
|
+
const agentId = selected.agent.id;
|
|
313
|
+
const reason = this.rebaseBlockReason(agentId);
|
|
314
|
+
if (reason) {
|
|
315
|
+
this.confirmation.cancel();
|
|
316
|
+
this.showRebaseWarning(agentId, `Can't rebase: ${reason}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!this.deliverableAgent(agentId)) {
|
|
320
|
+
this.confirmation.cancel();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (
|
|
324
|
+
this.rebaseDetachCount(agentId) > 0 &&
|
|
325
|
+
!this.confirmation.press(`rebase:${agentId}`)
|
|
326
|
+
) {
|
|
327
|
+
this.update();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.rebaseMainContext(agentId);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private copyToClipboard(agentId: string, text: string, target: CopyTarget): void {
|
|
334
|
+
const child = childProcess.execFile("pbcopy", (error) => {
|
|
335
|
+
if (error) return;
|
|
336
|
+
this.copied = { agentId, target };
|
|
337
|
+
if (this.copiedTimer) clearTimeout(this.copiedTimer);
|
|
338
|
+
this.update();
|
|
339
|
+
this.copiedTimer = setTimeout(() => {
|
|
340
|
+
this.copied = undefined;
|
|
341
|
+
this.copiedTimer = undefined;
|
|
342
|
+
this.update();
|
|
343
|
+
}, COPY_FEEDBACK_MS);
|
|
344
|
+
});
|
|
345
|
+
child.stdin?.end(text);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private showRebaseWarning(agentId: string, text: string): void {
|
|
349
|
+
this.rebaseWarning = { agentId, text };
|
|
350
|
+
if (this.rebaseWarningTimer) clearTimeout(this.rebaseWarningTimer);
|
|
351
|
+
this.rebaseWarningTimer = setTimeout(() => {
|
|
352
|
+
this.rebaseWarning = undefined;
|
|
353
|
+
this.rebaseWarningTimer = undefined;
|
|
354
|
+
this.update();
|
|
355
|
+
}, CONFIRMATION_WINDOW_MS);
|
|
356
|
+
this.update();
|
|
357
|
+
}
|
|
358
|
+
|
|
280
359
|
private deactivate(): void {
|
|
281
360
|
this.active = false;
|
|
282
361
|
this.selectedIndex = 0;
|
|
283
|
-
this.
|
|
362
|
+
this.confirmation.cancel();
|
|
284
363
|
this.update();
|
|
285
364
|
}
|
|
286
365
|
|
|
@@ -511,24 +590,60 @@ export class UserAgentWidget {
|
|
|
511
590
|
} else {
|
|
512
591
|
const selected = entries[this.selectedIndex];
|
|
513
592
|
const confirmingDetach =
|
|
514
|
-
selected !== undefined &&
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
selected
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
593
|
+
selected !== undefined &&
|
|
594
|
+
this.confirmation.isArmedOn(`detach:${selected.agent.id}`);
|
|
595
|
+
const confirmingRebase =
|
|
596
|
+
selected !== undefined &&
|
|
597
|
+
this.confirmation.isArmedOn(`rebase:${selected.agent.id}`);
|
|
598
|
+
if (confirmingRebase && selected) {
|
|
599
|
+
const detachCount = this.rebaseDetachCount(selected.agent.id);
|
|
600
|
+
lines.push(
|
|
601
|
+
truncateToWidth(
|
|
602
|
+
` ${theme.fg(
|
|
603
|
+
"warning",
|
|
604
|
+
`⚠ Rebase will detach ${detachCount} other agent session${detachCount === 1 ? "" : "s"}. r again to confirm`,
|
|
605
|
+
)}`,
|
|
606
|
+
width,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
} else if (selected && this.rebaseWarning?.agentId === selected.agent.id) {
|
|
610
|
+
lines.push(
|
|
611
|
+
truncateToWidth(` ${theme.fg("warning", `⚠ ${this.rebaseWarning.text}`)}`, width),
|
|
612
|
+
);
|
|
613
|
+
} else {
|
|
614
|
+
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
615
|
+
if (!confirmingDetach)
|
|
616
|
+
segments.push(
|
|
617
|
+
this.copied?.agentId === selected?.agent.id &&
|
|
618
|
+
this.copied.target === "response"
|
|
619
|
+
? theme.fg("success", "✓ copied")
|
|
620
|
+
: theme.fg("accent", "c copy"),
|
|
621
|
+
this.copied?.agentId === selected?.agent.id &&
|
|
622
|
+
this.copied.target === "session-id"
|
|
623
|
+
? theme.fg("success", "✓ copied ID")
|
|
624
|
+
: theme.fg("accent", "i ID"),
|
|
625
|
+
);
|
|
626
|
+
if (!confirmingDetach && selected && this.canSquashMainContext(selected.agent.id))
|
|
627
|
+
segments.push(theme.fg("accent", "s squash"));
|
|
628
|
+
if (!confirmingDetach && selected && this.canRebaseMainContext(selected.agent.id))
|
|
629
|
+
segments.push(theme.fg("accent", "r rebase"));
|
|
630
|
+
if (
|
|
631
|
+
!confirmingDetach &&
|
|
632
|
+
selected?.kind === "running" &&
|
|
633
|
+
isLiveAgent(selected.agent)
|
|
634
|
+
)
|
|
635
|
+
segments.push(theme.fg("accent", "Ctrl+x interrupt"));
|
|
636
|
+
segments.push(
|
|
637
|
+
theme.fg(
|
|
638
|
+
confirmingDetach ? "error" : "accent",
|
|
639
|
+
confirmingDetach ? "d again to confirm" : "d detach",
|
|
640
|
+
),
|
|
641
|
+
theme.fg("accent", "Esc back"),
|
|
642
|
+
);
|
|
643
|
+
lines.push(
|
|
644
|
+
truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
532
647
|
}
|
|
533
648
|
|
|
534
649
|
const maxEntries = Math.max(1, Math.floor((MAX_WIDGET_LINES - lines.length) / 2));
|
|
@@ -568,15 +683,17 @@ export class UserAgentWidget {
|
|
|
568
683
|
const running = isRunningAgent(agent);
|
|
569
684
|
const inFlight = isLiveAgent(agent);
|
|
570
685
|
const ok = running ? agent.error === undefined : agent.ok;
|
|
686
|
+
const status = inFlight ? "running" : running ? "idle" : ok ? "completed" : "failed";
|
|
571
687
|
const marker = selected ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
|
|
572
688
|
const icon = inFlight
|
|
573
689
|
? theme.fg("accent", SPINNER[this.frame % SPINNER.length])
|
|
574
|
-
:
|
|
575
|
-
? theme.fg("
|
|
576
|
-
:
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
690
|
+
: running
|
|
691
|
+
? theme.fg("muted", "◉")
|
|
692
|
+
: ok
|
|
693
|
+
? theme.fg("success", "✓")
|
|
694
|
+
: theme.fg("error", "✗");
|
|
695
|
+
const parts = [agent.modelLabel, status];
|
|
696
|
+
if (inFlight) parts.push(contextLabel(agent.inheritedContext));
|
|
580
697
|
const mainContext = mainContextLabel(agent.mainContextState);
|
|
581
698
|
if (mainContext) parts.push(mainContext);
|
|
582
699
|
if (agent.turnCount > 0) parts.push(formatTurns(agent.turnCount));
|
|
@@ -592,6 +709,7 @@ export class UserAgentWidget {
|
|
|
592
709
|
marker,
|
|
593
710
|
icon,
|
|
594
711
|
theme.bold(`/${agent.command}`),
|
|
712
|
+
selected ? theme.fg("dim", `ID ${agent.sessionId.slice(0, 8)}`) : "",
|
|
595
713
|
renderAgentContextMeter(agent, theme),
|
|
596
714
|
theme.fg("muted", truncatePlain(agent.task, 52)),
|
|
597
715
|
theme.fg("dim", "·"),
|