@giladbarnea/pi-user-agents 0.0.6 → 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/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
- This extension maps the two extremes onto the `-s` flag: the default posts the result card via `appendEntry` (main agent structurally oblivious), and `-s` posts a visible custom message with `{ triggerTurn: true }` (steered mid-turn or answered immediately). Both channels share one card renderer in `transcript.ts`.
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. Its `display` option prevents a second visible result card. Both paths use the same selected conversation and `{ triggerTurn: true }` delivery. For a run launched without `-s`, the context message stays hidden at squash time because the TUI-only result card is already visible.
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 (result cards, detach 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.
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
@@ -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">
@@ -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; every completed turn posts its own result card.
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
- Each completed turn posts a result card into your transcript — metadata, a collapsed preview, expandable to the full Markdown response. The card is TUI-only: you read it, expand it, copy it, and the main agent's context is untouched. Ask an agent ten questions and your main agent's token budget doesn't move.
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
- Result cards persist across session reloads; the widget doesn't. After a reload you keep every card, and every agent's session is still on disk — but the steerable rows are gone. Reach them with `/resume`.
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
 
@@ -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
@@ -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 normalized = description.text.replace(/\s+/g, " ").trim();
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
@@ -144,13 +151,17 @@ function truncateMiddle(
144
151
  const VIEWER_CHROME_LINES = 6;
145
152
  const VIEWER_MIN_VIEWPORT = 3;
146
153
  const VIEWER_HEIGHT_PCT = 85;
154
+ const COPY_FEEDBACK_MS = 1500;
155
+
156
+ type CopyTarget = "response" | "session-id";
147
157
 
148
158
  /** Focus-capturing overlay that shows an agent's live or finished response. */
149
159
  export class AgentViewer implements Component {
150
160
  private scrollOffset = 0;
151
161
  private autoScroll = true;
152
162
  private lastWidth = 0;
153
- private justCopied = false;
163
+ private copied: CopyTarget | undefined;
164
+ private copiedTimer: ReturnType<typeof setTimeout> | undefined;
154
165
  private rebaseWarning: string | undefined;
155
166
  private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
156
167
  /** One confirmation slot for both destructive overlay actions. */
@@ -158,7 +169,7 @@ export class AgentViewer implements Component {
158
169
  this.tui.requestRender(),
159
170
  );
160
171
  private composer: Input | undefined;
161
- /** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
172
+ /** Rich tool rendering is far too costly to repeat on every widget tick. */
162
173
  private renderedContent: { key: string; lines: string[] } | undefined;
163
174
 
164
175
  constructor(
@@ -205,6 +216,10 @@ export class AgentViewer implements Component {
205
216
  this.copyResponse();
206
217
  return;
207
218
  }
219
+ if (matchesKey(data, "i")) {
220
+ this.copyToClipboard(this.agent.sessionId, "session-id");
221
+ return;
222
+ }
208
223
  if (matchesKey(data, "s") && this.canSquashMainContext()) {
209
224
  this.squashMainContext();
210
225
  this.tui.requestRender();
@@ -344,7 +359,11 @@ export class AgentViewer implements Component {
344
359
  th.fg("dim", `${content.length} lines`),
345
360
  th.fg("dim", scrollPct),
346
361
  this.canSteer() ? th.fg("dim", "Enter steer") : "",
347
- 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"),
348
367
  th.fg("dim", "↑↓ scroll"),
349
368
  th.fg("dim", "PgUp/PgDn"),
350
369
  this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
@@ -364,7 +383,9 @@ export class AgentViewer implements Component {
364
383
  return lines;
365
384
  }
366
385
 
367
- invalidate(): void {}
386
+ invalidate(): void {
387
+ this.renderedContent = undefined;
388
+ }
368
389
 
369
390
  dispose(): void {}
370
391
 
@@ -392,14 +413,20 @@ export class AgentViewer implements Component {
392
413
  isRunningAgent(this.agent) || this.agent.ok
393
414
  ? this.agent.responseText
394
415
  : (this.agent.error ?? "");
416
+ this.copyToClipboard(text, "response");
417
+ }
418
+
419
+ private copyToClipboard(text: string, target: CopyTarget): void {
395
420
  const child = childProcess.execFile("pbcopy", (error) => {
396
421
  if (error) return;
397
- this.justCopied = true;
422
+ this.copied = target;
423
+ if (this.copiedTimer) clearTimeout(this.copiedTimer);
398
424
  this.tui.requestRender();
399
- setTimeout(() => {
400
- this.justCopied = false;
425
+ this.copiedTimer = setTimeout(() => {
426
+ this.copied = undefined;
427
+ this.copiedTimer = undefined;
401
428
  this.tui.requestRender();
402
- }, 1500);
429
+ }, COPY_FEEDBACK_MS);
403
430
  });
404
431
  child.stdin?.end(text);
405
432
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
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
- if (notifyMainAgent) {
197
- // Steers into a streaming turn, or triggers an immediate response when idle.
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
- /** Settle a failed lifecycle: record the error and post the error card, unless the user aborted. */
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: runningAgent.notifyMainAgent },
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: runningAgent.notifyMainAgent },
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, "&amp;")
package/transcript.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
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
- pi.registerMessageRenderer<AgentCommandDetails>(
199
- MESSAGE_TYPE,
200
- (message, { expanded }, theme) =>
201
- liveAgentCard(
202
- () => customMessageContentText(message.content),
203
- () => message.details,
204
- expanded,
205
- theme,
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
@@ -1,3 +1,4 @@
1
+ import * as childProcess from "node:child_process";
1
2
  import {
2
3
  isKeyRelease,
3
4
  matchesKey,
@@ -17,6 +18,7 @@ import {
17
18
  import { renderAgentContextMeter } from "./context-meter.js";
18
19
  import { selectRebaseMessages } from "./rebase.js";
19
20
  import type {
21
+ AgentMessage,
20
22
  AgentResultMessage,
21
23
  CompletedAgent,
22
24
  RebaseDelivery,
@@ -25,6 +27,7 @@ import type {
25
27
  UIContext,
26
28
  } from "./shared.js";
27
29
  import {
30
+ CONFIRMATION_WINDOW_MS,
28
31
  contextLabel,
29
32
  formatMs,
30
33
  TimedConfirmation,
@@ -42,6 +45,19 @@ type UserAgentWidgetEntry =
42
45
  | { kind: "running"; startedAt: number; agent: RunningAgent }
43
46
  | { kind: "completed"; startedAt: number; agent: CompletedAgent };
44
47
 
48
+ type CachedActivityPreview = {
49
+ source: AgentMessage | string | undefined;
50
+ status: RunningAgent["status"] | boolean;
51
+ error: string | undefined;
52
+ width: number;
53
+ theme: Theme;
54
+ text: string;
55
+ };
56
+
57
+ type CopyTarget = "response" | "session-id";
58
+
59
+ const COPY_FEEDBACK_MS = 1500;
60
+
45
61
  export class UserAgentWidget {
46
62
  private ui: UIContext | undefined;
47
63
  private frame = 0;
@@ -52,8 +68,13 @@ export class UserAgentWidget {
52
68
  private active = false;
53
69
  private selectedIndex = 0;
54
70
  private viewerOpen = false;
55
- /** One confirmation slot for detaching the selected agent. */
56
- private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
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;
75
+ private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
76
+ /** One confirmation slot for both destructive selected-row actions. */
77
+ private readonly confirmation = new TimedConfirmation<string>(() => this.update());
57
78
  private readonly completedAgents: CompletedAgent[] = [];
58
79
 
59
80
  constructor(
@@ -67,9 +88,10 @@ export class UserAgentWidget {
67
88
  if (ui === this.ui) return;
68
89
  this.inputUnsub?.();
69
90
  this.ui = ui;
91
+ this.activityPreviews = new WeakMap();
70
92
  this.widgetRegistered = false;
71
93
  this.tui = undefined;
72
- this.detachConfirmation.cancel();
94
+ this.confirmation.cancel();
73
95
  this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
74
96
  }
75
97
 
@@ -101,12 +123,18 @@ export class UserAgentWidget {
101
123
  }
102
124
 
103
125
  ensureTimer(): void {
104
- if (!this.ui || this.interval) return;
105
- this.interval = setInterval(() => this.update(), 100);
126
+ const active = [...this.runningAgents].some(isLiveAgent);
127
+ if (!active && this.interval) {
128
+ clearInterval(this.interval);
129
+ this.interval = undefined;
130
+ }
131
+ if (!this.ui || !active || this.interval) return;
132
+ this.interval = setInterval(() => this.update(), 200);
106
133
  }
107
134
 
108
135
  update(): void {
109
136
  if (!this.ui) return;
137
+ this.ensureTimer();
110
138
  const entries = this.entries();
111
139
  if (entries.length === 0) {
112
140
  this.clear();
@@ -123,8 +151,7 @@ export class UserAgentWidget {
123
151
  return {
124
152
  render: (width: number) => this.renderWidget(width, theme),
125
153
  invalidate: () => {
126
- this.widgetRegistered = false;
127
- this.tui = undefined;
154
+ this.activityPreviews = new WeakMap();
128
155
  },
129
156
  };
130
157
  },
@@ -148,7 +175,8 @@ export class UserAgentWidget {
148
175
  this.widgetRegistered = false;
149
176
  this.tui = undefined;
150
177
  this.active = false;
151
- this.detachConfirmation.cancel();
178
+ this.activityPreviews = new WeakMap();
179
+ this.confirmation.cancel();
152
180
  }
153
181
 
154
182
  private clear(): void {
@@ -163,7 +191,7 @@ export class UserAgentWidget {
163
191
  }
164
192
  this.active = false;
165
193
  this.selectedIndex = 0;
166
- this.detachConfirmation.cancel();
194
+ this.confirmation.cancel();
167
195
  }
168
196
 
169
197
  private runningAgentsForWidget(): RunningAgent[] {
@@ -207,13 +235,13 @@ export class UserAgentWidget {
207
235
  }
208
236
 
209
237
  if (matchesKey(data, "down")) {
210
- this.detachConfirmation.cancel();
238
+ this.confirmation.cancel();
211
239
  this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
212
240
  this.update();
213
241
  return { consume: true };
214
242
  }
215
243
  if (matchesKey(data, "up")) {
216
- this.detachConfirmation.cancel();
244
+ this.confirmation.cancel();
217
245
  if (this.selectedIndex === 0) {
218
246
  this.deactivate();
219
247
  return { consume: true };
@@ -228,20 +256,40 @@ export class UserAgentWidget {
228
256
  }
229
257
 
230
258
  const selected = entries[this.selectedIndex];
259
+ if (!matchesKey(data, "d") && !matchesKey(data, "r")) this.confirmation.cancel();
231
260
  if (
232
261
  matchesKey(data, "ctrl+x") &&
233
262
  selected?.kind === "running" &&
234
263
  isLiveAgent(selected.agent)
235
264
  ) {
236
- this.detachConfirmation.cancel();
237
265
  this.interruptRunning(selected.agent);
238
266
  return { consume: true };
239
267
  }
240
268
  if (matchesKey(data, "enter")) {
241
- this.detachConfirmation.cancel();
242
269
  if (selected) this.openSelected(selected.agent);
243
270
  return { consume: true };
244
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
+ }
245
293
  if (matchesKey(data, "d") && selected) {
246
294
  if (!this.confirmDetach(selected.agent)) return { consume: true };
247
295
  if (selected.kind === "running") this.closeRunning(selected.agent);
@@ -254,15 +302,64 @@ export class UserAgentWidget {
254
302
  }
255
303
 
256
304
  private confirmDetach(target: ViewableAgent): boolean {
257
- const confirmed = this.detachConfirmation.press(target);
305
+ const confirmed = this.confirmation.press(`detach:${target.id}`);
258
306
  if (!confirmed) this.update();
259
307
  return confirmed;
260
308
  }
261
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
+
262
359
  private deactivate(): void {
263
360
  this.active = false;
264
361
  this.selectedIndex = 0;
265
- this.detachConfirmation.cancel();
362
+ this.confirmation.cancel();
266
363
  this.update();
267
364
  }
268
365
 
@@ -493,24 +590,60 @@ export class UserAgentWidget {
493
590
  } else {
494
591
  const selected = entries[this.selectedIndex];
495
592
  const confirmingDetach =
496
- selected !== undefined && this.detachConfirmation.isArmedOn(selected.agent);
497
- const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
498
- if (
499
- !confirmingDetach &&
500
- selected?.kind === "running" &&
501
- isLiveAgent(selected.agent)
502
- )
503
- segments.push(theme.fg("accent", "Ctrl+x interrupt"));
504
- segments.push(
505
- theme.fg(
506
- confirmingDetach ? "error" : "accent",
507
- confirmingDetach ? "d again to confirm" : "d detach",
508
- ),
509
- theme.fg("accent", "Esc back"),
510
- );
511
- lines.push(
512
- truncateToWidth(` ${segments.join(theme.fg("accent", " · "))}`, width),
513
- );
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
+ }
514
647
  }
515
648
 
516
649
  const maxEntries = Math.max(1, Math.floor((MAX_WIDGET_LINES - lines.length) / 2));
@@ -550,15 +683,17 @@ export class UserAgentWidget {
550
683
  const running = isRunningAgent(agent);
551
684
  const inFlight = isLiveAgent(agent);
552
685
  const ok = running ? agent.error === undefined : agent.ok;
686
+ const status = inFlight ? "running" : running ? "idle" : ok ? "completed" : "failed";
553
687
  const marker = selected ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
554
688
  const icon = inFlight
555
689
  ? theme.fg("accent", SPINNER[this.frame % SPINNER.length])
556
- : ok
557
- ? theme.fg("success", "✓")
558
- : theme.fg("error", "✗");
559
- const parts = inFlight
560
- ? [agent.modelLabel, contextLabel(agent.inheritedContext)]
561
- : [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));
562
697
  const mainContext = mainContextLabel(agent.mainContextState);
563
698
  if (mainContext) parts.push(mainContext);
564
699
  if (agent.turnCount > 0) parts.push(formatTurns(agent.turnCount));
@@ -574,6 +709,7 @@ export class UserAgentWidget {
574
709
  marker,
575
710
  icon,
576
711
  theme.bold(`/${agent.command}`),
712
+ selected ? theme.fg("dim", `ID ${agent.sessionId.slice(0, 8)}`) : "",
577
713
  renderAgentContextMeter(agent, theme),
578
714
  theme.fg("muted", truncatePlain(agent.task, 52)),
579
715
  theme.fg("dim", "·"),
@@ -583,16 +719,30 @@ export class UserAgentWidget {
583
719
  .join(" ");
584
720
  const activityPrefix = `${indent} ⎿ `;
585
721
  const activityWidth = Math.max(0, width - visibleWidth(activityPrefix));
722
+ const preview = this.renderActivityPreview(agent, activityWidth, theme);
723
+ const activityText = ok ? preview : theme.fg("error", preview);
724
+ const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
725
+ return [truncateToWidth(header, width), truncateToWidth(activity, width)];
726
+ }
727
+
728
+ private renderActivityPreview(agent: ViewableAgent, width: number, theme: Theme): string {
729
+ const running = isRunningAgent(agent);
730
+ const source = isLiveAgent(agent) ? agent.latestFinalizedMessage : agent.responseText;
731
+ const status = running ? agent.status : agent.ok;
732
+ const cached = this.activityPreviews.get(agent);
733
+ if (
734
+ cached && cached.source === source && cached.status === status &&
735
+ cached.error === agent.error && cached.width === width && cached.theme === theme
736
+ ) return cached.text;
586
737
  const description = running
587
738
  ? describeActivity(agent)
588
739
  : {
589
740
  text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
590
741
  truncation: "tail" as const,
591
742
  };
592
- const preview = renderActivity(description, activityWidth, theme);
593
- const activityText = ok ? preview : theme.fg("error", preview);
594
- const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
595
- return [truncateToWidth(header, width), truncateToWidth(activity, width)];
743
+ const text = renderActivity(description, width, theme);
744
+ this.activityPreviews.set(agent, { source, status, error: agent.error, width, theme, text });
745
+ return text;
596
746
  }
597
747
  }
598
748