@giladbarnea/pi-user-agents 0.0.4 → 0.0.6

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/transcript.ts CHANGED
@@ -6,13 +6,16 @@ import type {
6
6
  AgentEntryData,
7
7
  AgentMessage,
8
8
  AgentResultMessage,
9
+ AttachedEntryData,
9
10
  DetachedEntryData,
10
11
  ExtensionAPI,
11
12
  ExtensionCommandContext,
13
+ RebasedEntryData,
12
14
  RunningAgent,
13
15
  Theme,
14
16
  } from "./shared.js";
15
17
  import {
18
+ ATTACHED_ENTRY_TYPE,
16
19
  contextLabel,
17
20
  DETACHED_ENTRY_TYPE,
18
21
  escapeAttribute,
@@ -22,6 +25,7 @@ import {
22
25
  formatTurns,
23
26
  mainContextLabel,
24
27
  MESSAGE_TYPE,
28
+ REBASED_ENTRY_TYPE,
25
29
  truncatePlain,
26
30
  } from "./shared.js";
27
31
 
@@ -42,7 +46,6 @@ export function reportCommandError(
42
46
  display: true,
43
47
  details: {
44
48
  command,
45
- inheritedContext: command === "agent",
46
49
  model: "",
47
50
  modelLabel: "",
48
51
  task: args.trim(),
@@ -53,14 +56,14 @@ export function reportCommandError(
53
56
  }
54
57
 
55
58
  /**
56
- * Select the joined role messages from a child transcript.
59
+ * Select the squashed role messages from a child transcript.
57
60
  *
58
- * @example selectJoinedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
61
+ * @example selectSquashedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
59
62
  */
60
- type JoinedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
63
+ type SquashedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
61
64
 
62
- export function selectJoinedMessages(messages: readonly AgentMessage[]): JoinedMessage[] {
63
- const selected: JoinedMessage[] = [];
65
+ export function selectSquashedMessages(messages: readonly AgentMessage[]): SquashedMessage[] {
66
+ const selected: SquashedMessage[] = [];
64
67
  let latestAssistant: Extract<AgentMessage, { role: "assistant" }> | undefined;
65
68
  for (const message of messages) {
66
69
  if (message.role === "assistant" && roleMessageText(message).trim()) {
@@ -110,13 +113,13 @@ export function buildAgentResultMessage(
110
113
  };
111
114
  }
112
115
 
113
- const JOINED_CONVERSATION_PREFACE =
116
+ const SQUASHED_CONVERSATION_PREFACE =
114
117
  "The user has dispatched a background sub-agent with a task. The sub-agent is done. The following is the back and forth between them:";
115
118
 
116
119
  export function formatResultMessage(agent: RunningAgent): string {
117
- const messages = selectJoinedMessages(agent.conversationMessages);
120
+ const messages = selectSquashedMessages(agent.conversationMessages);
118
121
  const lines = [
119
- JOINED_CONVERSATION_PREFACE,
122
+ SQUASHED_CONVERSATION_PREFACE,
120
123
  `<user_agent model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
121
124
  ];
122
125
  let firstUserMessage = true;
@@ -135,7 +138,7 @@ export function formatResultMessage(agent: RunningAgent): string {
135
138
  return lines.join("\n");
136
139
  }
137
140
 
138
- function roleMessageText(message: JoinedMessage): string {
141
+ function roleMessageText(message: SquashedMessage): string {
139
142
  if (typeof message.content === "string") return message.content;
140
143
  return message.content
141
144
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
@@ -214,11 +217,42 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
214
217
  )
215
218
  : undefined,
216
219
  );
220
+ // Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
217
221
  pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
218
222
  entry.data
219
- ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 0, 0)
223
+ ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
220
224
  : undefined,
221
225
  );
226
+ pi.registerEntryRenderer<AttachedEntryData>(ATTACHED_ENTRY_TYPE, (entry, _state, theme) =>
227
+ entry.data
228
+ ? new Text(theme.fg("dim", `Attached session ${entry.data.sessionId}`), 1, 0)
229
+ : undefined,
230
+ );
231
+ pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
232
+ entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
233
+ );
234
+ }
235
+
236
+ function formatRebasedLine(data: RebasedEntryData): string {
237
+ const base = `Rebased session ${data.sessionId} into this conversation`;
238
+ if (!data.stats) return base;
239
+ const parts = [
240
+ `added ${data.stats.messageCount} message${data.stats.messageCount === 1 ? "" : "s"}`,
241
+ `~${formatTokenCount(data.stats.tokenEstimate)} tokens`,
242
+ ];
243
+ if (data.stats.compactionCount > 0)
244
+ parts.push(
245
+ `${data.stats.compactionCount} compaction event${data.stats.compactionCount === 1 ? "" : "s"}`,
246
+ );
247
+ return `${base} (${parts.join(", ")})`;
248
+ }
249
+
250
+ /**
251
+ * @example formatTokenCount(135_264) // "135K"
252
+ * @example formatTokenCount(12) // "12"
253
+ */
254
+ function formatTokenCount(tokens: number): string {
255
+ return tokens < 1000 ? `${tokens}` : `${Math.round(tokens / 1000)}K`;
222
256
  }
223
257
 
224
258
  function liveAgentCard(
@@ -251,7 +285,7 @@ function renderAgentCard(
251
285
  if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
252
286
  if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
253
287
  const card = new Container();
254
- card.addChild(new Text(text, 0, 0));
288
+ card.addChild(new Text(text, 1, 0));
255
289
  card.addChild(body);
256
290
  return card;
257
291
  }
@@ -270,12 +304,12 @@ function renderAgentCardBody(
270
304
  "No output.";
271
305
  if (expanded)
272
306
  return ok
273
- ? new Markdown(output, 2, 0, getMarkdownTheme())
274
- : new Text(theme.fg("error", output), 2, 0);
307
+ ? new Markdown(output, 3, 0, getMarkdownTheme())
308
+ : new Text(theme.fg("error", output), 3, 0);
275
309
 
276
310
  const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
277
- if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 0, 0);
278
- return new Markdown(previewText, 2, 0, getMarkdownTheme(), {
311
+ if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
312
+ return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
279
313
  color: (value) => theme.fg("dim", value),
280
314
  });
281
315
  }
@@ -296,7 +330,7 @@ function buildRendererParts(content: string, details: AgentCommandDetails | unde
296
330
  const parts: string[] = [];
297
331
  if (details?.modelLabel) parts.push(details.modelLabel);
298
332
  else if (details?.model) parts.push(details.model);
299
- if (details) parts.push(contextLabel(details.inheritedContext));
333
+ if (details?.inheritedContext !== undefined) parts.push(contextLabel(details.inheritedContext));
300
334
  const mainContext = mainContextLabel(details?.mainContextState);
301
335
  if (mainContext) parts.push(mainContext);
302
336
  if (details?.turnCount) parts.push(formatTurns(details.turnCount));
package/widget.ts CHANGED
@@ -15,9 +15,11 @@ import {
15
15
  type ViewableAgent,
16
16
  } from "./agent-viewer.js";
17
17
  import { renderAgentContextMeter } from "./context-meter.js";
18
+ import { selectRebaseMessages } from "./rebase.js";
18
19
  import type {
19
20
  AgentResultMessage,
20
21
  CompletedAgent,
22
+ RebaseDelivery,
21
23
  RunningAgent,
22
24
  Theme,
23
25
  UIContext,
@@ -25,6 +27,7 @@ import type {
25
27
  import {
26
28
  contextLabel,
27
29
  formatMs,
30
+ TimedConfirmation,
28
31
  formatToolUses,
29
32
  formatTurns,
30
33
  logSteering,
@@ -49,13 +52,15 @@ export class UserAgentWidget {
49
52
  private active = false;
50
53
  private selectedIndex = 0;
51
54
  private viewerOpen = false;
52
- private detachConfirmationTarget: ViewableAgent | undefined;
55
+ /** One confirmation slot for detaching the selected agent. */
56
+ private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
53
57
  private readonly completedAgents: CompletedAgent[] = [];
54
58
 
55
59
  constructor(
56
60
  private readonly runningAgents: Set<RunningAgent>,
57
- private readonly sendJoinedResult: (message: AgentResultMessage) => void,
61
+ private readonly sendSquashedResult: (message: AgentResultMessage) => void,
58
62
  private readonly announceDetached: (sessionId: string) => void,
63
+ private readonly rebaseDelivery: RebaseDelivery,
59
64
  ) {}
60
65
 
61
66
  setUI(ui: UIContext): void {
@@ -64,14 +69,14 @@ export class UserAgentWidget {
64
69
  this.ui = ui;
65
70
  this.widgetRegistered = false;
66
71
  this.tui = undefined;
67
- this.detachConfirmationTarget = undefined;
72
+ this.detachConfirmation.cancel();
68
73
  this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
69
74
  }
70
75
 
71
76
  addCompleted(
72
77
  agent: RunningAgent,
73
78
  resultMessage: AgentResultMessage,
74
- options: { joinable: boolean },
79
+ options: { squashable: boolean },
75
80
  ): void {
76
81
  this.completedAgents.push({
77
82
  id: agent.id,
@@ -79,8 +84,9 @@ export class UserAgentWidget {
79
84
  command: agent.command,
80
85
  modelLabel: agent.modelLabel,
81
86
  task: agent.task,
87
+ dispatchBaseFingerprint: agent.dispatchBaseFingerprint,
82
88
  mainContextState: agent.mainContextState,
83
- pendingJoinMessage: options.joinable ? resultMessage : undefined,
89
+ pendingSquashMessage: options.squashable ? resultMessage : undefined,
84
90
  ok: resultMessage.details.ok,
85
91
  responseText: agent.responseText,
86
92
  messages: structuredClone(transcriptMessages(agent)),
@@ -142,7 +148,7 @@ export class UserAgentWidget {
142
148
  this.widgetRegistered = false;
143
149
  this.tui = undefined;
144
150
  this.active = false;
145
- this.detachConfirmationTarget = undefined;
151
+ this.detachConfirmation.cancel();
146
152
  }
147
153
 
148
154
  private clear(): void {
@@ -157,7 +163,7 @@ export class UserAgentWidget {
157
163
  }
158
164
  this.active = false;
159
165
  this.selectedIndex = 0;
160
- this.detachConfirmationTarget = undefined;
166
+ this.detachConfirmation.cancel();
161
167
  }
162
168
 
163
169
  private runningAgentsForWidget(): RunningAgent[] {
@@ -201,13 +207,13 @@ export class UserAgentWidget {
201
207
  }
202
208
 
203
209
  if (matchesKey(data, "down")) {
204
- this.detachConfirmationTarget = undefined;
210
+ this.detachConfirmation.cancel();
205
211
  this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
206
212
  this.update();
207
213
  return { consume: true };
208
214
  }
209
215
  if (matchesKey(data, "up")) {
210
- this.detachConfirmationTarget = undefined;
216
+ this.detachConfirmation.cancel();
211
217
  if (this.selectedIndex === 0) {
212
218
  this.deactivate();
213
219
  return { consume: true };
@@ -227,12 +233,12 @@ export class UserAgentWidget {
227
233
  selected?.kind === "running" &&
228
234
  isLiveAgent(selected.agent)
229
235
  ) {
230
- this.detachConfirmationTarget = undefined;
236
+ this.detachConfirmation.cancel();
231
237
  this.interruptRunning(selected.agent);
232
238
  return { consume: true };
233
239
  }
234
240
  if (matchesKey(data, "enter")) {
235
- this.detachConfirmationTarget = undefined;
241
+ this.detachConfirmation.cancel();
236
242
  if (selected) this.openSelected(selected.agent);
237
243
  return { consume: true };
238
244
  }
@@ -248,22 +254,33 @@ export class UserAgentWidget {
248
254
  }
249
255
 
250
256
  private confirmDetach(target: ViewableAgent): boolean {
251
- if (this.detachConfirmationTarget === target) {
252
- this.detachConfirmationTarget = undefined;
253
- return true;
254
- }
255
- this.detachConfirmationTarget = target;
256
- this.update();
257
- return false;
257
+ const confirmed = this.detachConfirmation.press(target);
258
+ if (!confirmed) this.update();
259
+ return confirmed;
258
260
  }
259
261
 
260
262
  private deactivate(): void {
261
263
  this.active = false;
262
264
  this.selectedIndex = 0;
263
- this.detachConfirmationTarget = undefined;
265
+ this.detachConfirmation.cancel();
264
266
  this.update();
265
267
  }
266
268
 
269
+ /** Session ids with a live row or completed card, matched by exact id or prefix. */
270
+ matchAttachedSessionIds(query: string): string[] {
271
+ return this.entries()
272
+ .map((entry) => entry.agent.sessionId)
273
+ .filter((sessionId) => sessionId.startsWith(query));
274
+ }
275
+
276
+ /** Open the overlay of the row holding this session, as if the user selected it. */
277
+ openViewer(sessionId: string): boolean {
278
+ const entry = this.entries().find((candidate) => candidate.agent.sessionId === sessionId);
279
+ if (!entry) return false;
280
+ this.openSelected(entry.agent);
281
+ return true;
282
+ }
283
+
267
284
  private clampSelection(): void {
268
285
  const total = this.entries().length;
269
286
  if (total === 0) {
@@ -286,8 +303,12 @@ export class UserAgentWidget {
286
303
  agent,
287
304
  theme,
288
305
  done,
289
- () => this.canJoinMainContext(agent.id),
290
- () => this.joinMainContext(agent.id),
306
+ () => this.canSquashMainContext(agent.id),
307
+ () => this.squashMainContext(agent.id),
308
+ () => this.canRebaseMainContext(agent.id),
309
+ () => this.rebaseMainContext(agent.id),
310
+ () => this.rebaseBlockReason(agent.id),
311
+ () => this.rebaseDetachCount(agent.id),
291
312
  () => {
292
313
  if (isRunningAgent(agent)) this.interruptRunning(agent);
293
314
  },
@@ -319,54 +340,107 @@ export class UserAgentWidget {
319
340
  return [...this.runningAgents].find((agent) => agent.id === agentId && agent.status === "idle");
320
341
  }
321
342
 
322
- private canJoinMainContext(agentId: string): boolean {
323
- if (this.idleAgent(agentId)?.pendingJoinMessage) return true;
324
- return this.completedAgents.some(
325
- (agent) => agent.id === agentId && agent.pendingJoinMessage !== undefined,
326
- );
343
+ private canSquashMainContext(agentId: string): boolean {
344
+ return this.deliverableAgent(agentId) !== undefined;
327
345
  }
328
346
 
329
- confirmMainContextJoin(agentId: string): boolean {
347
+ confirmMainContextSquash(agentId: string): boolean {
330
348
  const agents = [
331
349
  ...[...this.runningAgents].filter((candidate) => candidate.id === agentId),
332
350
  ...this.completedAgents.filter((candidate) => candidate.id === agentId),
333
- ].filter((candidate) => candidate.mainContextState === "will-join");
351
+ ].filter((candidate) => candidate.mainContextState === "will-squash");
334
352
  if (agents.length === 0) return false;
335
- for (const agent of agents) agent.mainContextState = "joined";
353
+ for (const agent of agents) agent.mainContextState = "squashed";
336
354
  this.update();
337
355
  return true;
338
356
  }
339
357
 
340
- private joinMainContext(agentId: string): void {
358
+ private squashMainContext(agentId: string): void {
341
359
  const idleAgent = this.idleAgent(agentId);
342
360
  if (idleAgent) {
343
- this.joinIdleAgent(idleAgent);
361
+ this.squashIdleAgent(idleAgent);
344
362
  return;
345
363
  }
346
364
  const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
347
- const message = completedAgent?.pendingJoinMessage;
365
+ const message = completedAgent?.pendingSquashMessage;
348
366
  if (!completedAgent || !message) return;
349
367
 
350
- completedAgent.pendingJoinMessage = undefined;
351
- completedAgent.mainContextState = "will-join";
352
- message.details.mainContextState = "will-join";
353
- this.sendJoinedResult(message);
354
- logSteering(agentId, "joined-main-context", { display: message.display });
368
+ completedAgent.pendingSquashMessage = undefined;
369
+ completedAgent.mainContextState = "will-squash";
370
+ message.details.mainContextState = "will-squash";
371
+ this.sendSquashedResult(message);
372
+ logSteering(agentId, "squashed-main-context", { display: message.display });
373
+ this.update();
374
+ }
375
+
376
+ /** An agent whose latest turn result is still deliverable: idle live, or completed and squashable. */
377
+ private deliverableAgent(agentId: string): RunningAgent | CompletedAgent | undefined {
378
+ const idleAgent = this.idleAgent(agentId);
379
+ if (idleAgent?.pendingSquashMessage) return idleAgent;
380
+ return this.completedAgents.find(
381
+ (agent) => agent.id === agentId && agent.pendingSquashMessage !== undefined,
382
+ );
383
+ }
384
+
385
+ private canRebaseMainContext(agentId: string): boolean {
386
+ return this.deliverableAgent(agentId) !== undefined && this.rebaseBlockReason(agentId) === undefined;
387
+ }
388
+
389
+ /** How many other agent sessions the rebase cascade would detach. */
390
+ private rebaseDetachCount(agentId: string): number {
391
+ return (
392
+ [...this.runningAgents].filter((agent) => agent.id !== agentId).length +
393
+ this.completedAgents.filter((agent) => agent.id !== agentId).length
394
+ );
395
+ }
396
+
397
+ /** Why a deliverable result cannot rebase right now; undefined when it can, or when nothing is deliverable. */
398
+ private rebaseBlockReason(agentId: string): string | undefined {
399
+ const agent = this.deliverableAgent(agentId);
400
+ if (!agent) return undefined;
401
+ // The session switch tears the extension runtime down, which would abort a mid-turn sibling.
402
+ if ([...this.runningAgents].some(isLiveAgent)) return "another agent is mid-turn";
403
+ if (!this.rebaseDelivery.canDeliver(agent))
404
+ return "the main session has drifted since dispatch";
405
+ return undefined;
406
+ }
407
+
408
+ /** Fast-forward the raw child conversation onto the main session, then retire the agent. */
409
+ private rebaseMainContext(agentId: string): void {
410
+ if (!this.canRebaseMainContext(agentId)) return;
411
+ const agent = this.deliverableAgent(agentId);
412
+ if (!agent) return;
413
+ // The reload will not preserve live rows; give every sibling its resumable-session breadcrumb.
414
+ for (const other of [...this.runningAgents]) if (other !== agent) this.closeRunning(other);
415
+ for (const other of [...this.completedAgents]) if (other !== agent) this.removeCompleted(other);
416
+ const message = agent.pendingSquashMessage;
417
+ this.rebaseDelivery.deliver(
418
+ agent,
419
+ selectRebaseMessages(agent.task, structuredClone(transcriptMessages(agent))),
420
+ );
421
+ agent.pendingSquashMessage = undefined;
422
+ agent.mainContextState = "rebased";
423
+ if (isRunningAgent(agent)) {
424
+ agent.status = "posted";
425
+ if (message) this.addCompleted(agent, message, { squashable: false });
426
+ agent.retire?.();
427
+ }
428
+ logSteering(agentId, "rebased-main-context");
355
429
  this.update();
356
430
  }
357
431
 
358
- /** Joining is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
359
- private joinIdleAgent(agent: RunningAgent): void {
360
- const message = agent.pendingJoinMessage;
432
+ /** Squashing is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
433
+ private squashIdleAgent(agent: RunningAgent): void {
434
+ const message = agent.pendingSquashMessage;
361
435
  if (!message) return;
362
- agent.pendingJoinMessage = undefined;
363
- agent.mainContextState = "will-join";
364
- message.details.mainContextState = "will-join";
436
+ agent.pendingSquashMessage = undefined;
437
+ agent.mainContextState = "will-squash";
438
+ message.details.mainContextState = "will-squash";
365
439
  agent.status = "posted";
366
- this.addCompleted(agent, message, { joinable: false });
367
- this.sendJoinedResult(message);
440
+ this.addCompleted(agent, message, { squashable: false });
441
+ this.sendSquashedResult(message);
368
442
  agent.retire?.();
369
- logSteering(agent.id, "joined-main-context", { display: message.display, live: true });
443
+ logSteering(agent.id, "squashed-main-context", { display: message.display, live: true });
370
444
  this.update();
371
445
  }
372
446
 
@@ -418,7 +492,8 @@ export class UserAgentWidget {
418
492
  lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
419
493
  } else {
420
494
  const selected = entries[this.selectedIndex];
421
- const confirmingDetach = this.detachConfirmationTarget === selected?.agent;
495
+ const confirmingDetach =
496
+ selected !== undefined && this.detachConfirmation.isArmedOn(selected.agent);
422
497
  const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
423
498
  if (
424
499
  !confirmingDetach &&