@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 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, pick an agent, press `Enter`, and its overlay opens: the full conversation, streaming live, 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.
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
- | Overlay | `s` | Squash the conversation into the main context |
177
- | Overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
178
- | Overlay | `c` | Copy the latest response |
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` copies via `pbcopy`, so it's macOS-only for now.
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 justCopied = false;
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.justCopied ? th.fg("success", "✓ copied") : th.fg("dim", "c copy"),
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.justCopied = true;
422
+ this.copied = target;
423
+ if (this.copiedTimer) clearTimeout(this.copiedTimer);
407
424
  this.tui.requestRender();
408
- setTimeout(() => {
409
- this.justCopied = false;
425
+ this.copiedTimer = setTimeout(() => {
426
+ this.copied = undefined;
427
+ this.copiedTimer = undefined;
410
428
  this.tui.requestRender();
411
- }, 1500);
429
+ }, COPY_FEEDBACK_MS);
412
430
  });
413
431
  child.stdin?.end(text);
414
432
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
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 detaching the selected agent. */
67
- private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
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.detachConfirmation.cancel();
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.detachConfirmation.cancel();
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.detachConfirmation.cancel();
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.detachConfirmation.cancel();
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.detachConfirmation.cancel();
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.detachConfirmation.press(target);
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.detachConfirmation.cancel();
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 && this.detachConfirmation.isArmedOn(selected.agent);
515
- const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
516
- if (
517
- !confirmingDetach &&
518
- selected?.kind === "running" &&
519
- isLiveAgent(selected.agent)
520
- )
521
- segments.push(theme.fg("accent", "Ctrl+x interrupt"));
522
- segments.push(
523
- theme.fg(
524
- confirmingDetach ? "error" : "accent",
525
- confirmingDetach ? "d again to confirm" : "d detach",
526
- ),
527
- theme.fg("accent", "Esc back"),
528
- );
529
- lines.push(
530
- truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
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
- : ok
575
- ? theme.fg("success", "✓")
576
- : theme.fg("error", "✗");
577
- const parts = inFlight
578
- ? [agent.modelLabel, contextLabel(agent.inheritedContext)]
579
- : [agent.modelLabel];
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", "·"),