@giladbarnea/pi-user-agents 0.0.4 → 0.0.5

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/rebase.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { buildSessionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentMessage, ExtensionCommandContext } from "./shared.js";
3
+ import { MESSAGE_TYPE } from "./shared.js";
4
+
5
+ type MainSessionManager = ExtensionCommandContext["sessionManager"];
6
+
7
+ /**
8
+ * Fingerprint a context snapshot for the rebase fast-forward precondition: the main session's
9
+ * live context must still fingerprint-equal the base the child was dispatched from.
10
+ *
11
+ * @example conversationFingerprint([]) === conversationFingerprint([]) // true
12
+ */
13
+ export function conversationFingerprint(messages: readonly AgentMessage[]): string {
14
+ return JSON.stringify(messages);
15
+ }
16
+
17
+ /** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
18
+ const contextFingerprintCache = new WeakMap<object, { leafId: string | null; fingerprint: string }>();
19
+
20
+ /** Fingerprint of the main session's current LLM context, cached by branch leaf. */
21
+ export function mainContextFingerprint(sessionManager: MainSessionManager): string {
22
+ const leafId = sessionManager.getLeafId();
23
+ const cached = contextFingerprintCache.get(sessionManager);
24
+ if (cached && cached.leafId === leafId) return cached.fingerprint;
25
+ const fingerprint = conversationFingerprint(
26
+ buildSessionContext(sessionManager.getBranch()).messages,
27
+ );
28
+ contextFingerprintCache.set(sessionManager, { leafId, fingerprint });
29
+ return fingerprint;
30
+ }
31
+
32
+ /**
33
+ * Select the child conversation messages to rebase onto the main session, so the record reads
34
+ * as if the user prompted the main session directly. The child's history passes through
35
+ * verbatim — compactions included, so main inherits the child's context boundary — except the
36
+ * first user message, which carries the extension's internal background-process prefix and is
37
+ * replaced by the plain task, and this extension's own custom messages, which are the one
38
+ * trace of the user-agents machinery.
39
+ */
40
+ export function selectRebaseMessages(
41
+ task: string,
42
+ messages: readonly AgentMessage[],
43
+ ): AgentMessage[] {
44
+ let firstUserMessage = true;
45
+ const selected: AgentMessage[] = [];
46
+ for (const message of messages) {
47
+ if (message.role === "custom" && message.customType === MESSAGE_TYPE) continue;
48
+ if (message.role === "user" && firstUserMessage) {
49
+ firstUserMessage = false;
50
+ selected.push({ ...message, content: task });
51
+ continue;
52
+ }
53
+ selected.push(message);
54
+ }
55
+ return selected;
56
+ }
package/runner.ts CHANGED
@@ -5,29 +5,41 @@ import {
5
5
  type AgentSessionServices,
6
6
  type Args,
7
7
  buildSessionContext,
8
+ estimateTokens,
8
9
  createAgentSessionFromServices,
9
10
  createAgentSessionServices,
10
11
  getAgentDir,
11
12
  type ModelRuntime,
12
13
  parseArgs,
13
14
  resolveCliModel,
15
+ sessionEntryToContextMessages,
14
16
  SessionManager,
15
17
  SettingsManager,
16
18
  } from "@earendil-works/pi-coding-agent";
17
19
  import { type AgentValueValidator, parseAgentCommand } from "./command-line.js";
20
+ import { conversationFingerprint, mainContextFingerprint } from "./rebase.js";
18
21
  import type {
19
22
  AgentCommandName,
20
23
  AgentMessage,
21
24
  AgentResultMessage,
22
25
  AgentSession,
26
+ ChildCompactionMessage,
23
27
  ExtensionAPI,
24
28
  ExtensionCommandContext,
25
29
  Model,
26
30
  ParsedAgentCommand,
31
+ RebaseDelivery,
32
+ RebasedEntryData,
27
33
  RunningAgent,
28
34
  ThinkingLevel,
29
35
  } from "./shared.js";
30
- import { errorMessage, formatModel, formatModelLabel, logSteering } from "./shared.js";
36
+ import {
37
+ errorMessage,
38
+ formatModel,
39
+ formatModelLabel,
40
+ logSteering,
41
+ REBASED_ENTRY_TYPE,
42
+ } from "./shared.js";
31
43
  import {
32
44
  buildAgentResultMessage,
33
45
  formatStartNotification,
@@ -107,6 +119,7 @@ async function startUserAgent(
107
119
  model,
108
120
  parsed,
109
121
  invocation,
122
+ conversationFingerprint(inheritedMessages),
110
123
  );
111
124
 
112
125
  runningAgents.add(runningAgent);
@@ -146,6 +159,7 @@ function createRunningAgent(
146
159
  model: Model,
147
160
  parsed: ParsedAgentCommand,
148
161
  invocation: string,
162
+ dispatchBaseFingerprint: string,
149
163
  ): RunningAgent {
150
164
  return {
151
165
  id: `user-${sequenceNumber.toString(36)}`,
@@ -156,8 +170,9 @@ function createRunningAgent(
156
170
  modelLabel: formatModelLabel(model),
157
171
  task: parsed.task,
158
172
  invocation,
159
- notifyMainAgent: parsed.context,
160
- mainContextState: parsed.context ? "will-join" : "separate",
173
+ notifyMainAgent: parsed.squash,
174
+ dispatchBaseFingerprint,
175
+ mainContextState: parsed.squash ? "will-squash" : "separate",
161
176
  status: "starting",
162
177
  startedAt: Date.now(),
163
178
  turnStartedAt: Date.now(),
@@ -187,6 +202,66 @@ function postUserAgentResult(
187
202
  return true;
188
203
  }
189
204
 
205
+ /**
206
+ * The rebase mechanism: fast-forward a child conversation onto the session that dispatched it.
207
+ *
208
+ * Delivery appends the messages to the main session file, drops a persisted breadcrumb naming
209
+ * the child session, and asks the host to switch to that same file — the one path that rebuilds
210
+ * both the live LLM context and the TUI transcript (see INTERNALS.md).
211
+ */
212
+ export function createRebaseDelivery(
213
+ pi: ExtensionAPI,
214
+ getSessionContext: () => ExtensionCommandContext | undefined,
215
+ ): RebaseDelivery {
216
+ return {
217
+ canDeliver: (agent) => {
218
+ const ctx = getSessionContext();
219
+ return (
220
+ ctx !== undefined &&
221
+ mainContextFingerprint(ctx.sessionManager) === agent.dispatchBaseFingerprint
222
+ );
223
+ },
224
+ deliver: (agent, messages) => {
225
+ const ctx = getSessionContext();
226
+ if (!ctx) throw new Error("No dispatching session context to rebase onto");
227
+ // The runtime object behind the extension's readonly facade is the writable SessionManager.
228
+ const sessionManager = ctx.sessionManager as unknown as SessionManager;
229
+ // A replayed compaction's kept tail may reach back into main's existing context.
230
+ const priorContextEntryIds = sessionManager
231
+ .buildContextEntries()
232
+ .flatMap((entry) => sessionEntryToContextMessages(entry).map(() => entry.id));
233
+ persistMessages(sessionManager, messages, priorContextEntryIds);
234
+ pi.appendEntry<RebasedEntryData>(REBASED_ENTRY_TYPE, {
235
+ sessionId: agent.sessionId,
236
+ stats: {
237
+ messageCount: messages.length,
238
+ tokenEstimate: messages.reduce((total, message) => total + estimateTokens(message), 0),
239
+ compactionCount: messages.filter((message) => message.role === "compactionSummary")
240
+ .length,
241
+ },
242
+ });
243
+ const sessionFile = sessionManager.getSessionFile();
244
+ if (!sessionFile) throw new Error("The main session has no file to switch to");
245
+ void ctx.switchSession(sessionFile).then(
246
+ (result) => {
247
+ if (result.cancelled && ctx.hasUI)
248
+ ctx.ui.notify(
249
+ "The rebase is saved to the session file, but the session switch was cancelled — /resume this session to see it",
250
+ "warning",
251
+ );
252
+ },
253
+ (error) => {
254
+ if (ctx.hasUI)
255
+ ctx.ui.notify(
256
+ `The rebase is saved to the session file, but the session switch failed (${errorMessage(error)}) — /resume this session to see it`,
257
+ "error",
258
+ );
259
+ },
260
+ );
261
+ },
262
+ };
263
+ }
264
+
190
265
  /** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
191
266
  type ChildResourceLoaderOptions = {
192
267
  systemPrompt?: string;
@@ -394,7 +469,7 @@ async function runAgentLifecycle(
394
469
  { ok: false, error: message },
395
470
  { display: runningAgent.notifyMainAgent },
396
471
  );
397
- widget.addCompleted(runningAgent, resultMessage, { joinable: !runningAgent.notifyMainAgent });
472
+ widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
398
473
  if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
399
474
  runningAgent.status = "posted";
400
475
  } finally {
@@ -438,7 +513,7 @@ export async function runChildTurns(
438
513
  { display: false },
439
514
  );
440
515
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
441
- runningAgent.pendingJoinMessage = runningAgent.notifyMainAgent
516
+ runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
442
517
  ? undefined
443
518
  : resultMessage;
444
519
  if (isShuttingDown()) return;
@@ -470,13 +545,13 @@ export async function runChildTurns(
470
545
  resultMessage.details.mainContextState = "separate";
471
546
  return;
472
547
  }
473
- widget.addCompleted(runningAgent, resultMessage, { joinable: false });
548
+ widget.addCompleted(runningAgent, resultMessage, { squashable: false });
474
549
  const posted = postUserAgentResult(pi, isShuttingDown, true, resultMessage);
475
550
  runningAgent.status = posted ? "posted" : runningAgent.status;
476
551
  return;
477
552
  }
478
553
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
479
- runningAgent.pendingJoinMessage = resultMessage;
554
+ runningAgent.pendingSquashMessage = resultMessage;
480
555
  if (isShuttingDown()) return;
481
556
  runningAgent.status = "idle";
482
557
  logSteering(runningAgent.id, "agent-idle", { turnCount: runningAgent.turnCount });
@@ -528,7 +603,7 @@ async function createChildSession(
528
603
  await session.bindExtensions({ mode: "print" });
529
604
  logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
530
605
  if (inheritedMessages.length > 0) {
531
- persistInheritedMessages(childSessionManager, inheritedMessages);
606
+ persistMessages(childSessionManager, inheritedMessages, []);
532
607
  session.agent.state.messages = inheritedMessages;
533
608
  logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
534
609
  }
@@ -536,27 +611,42 @@ async function createChildSession(
536
611
  }
537
612
 
538
613
  /**
539
- * Mirror the inherited snapshot into the child's session file so a resumed child sees the
540
- * same conversation the live one saw. Each role goes through its own append method: the
541
- * snapshot is already compaction-resolved, so its summary leads the child's own branch.
614
+ * Mirror a message stream into a session file, each role through its own append method.
615
+ * Seeds a child with its inherited snapshot (leading summary), and replays a rebase onto the
616
+ * main session. A compaction with a keptTailCount maps its kept-tail boundary onto this file's
617
+ * own entry ids, so the rebuilt context keeps the same messages verbatim the child kept — the
618
+ * tail may reach back into the pre-replay context, whose per-message ids arrive as
619
+ * priorContextEntryIds (empty when seeding a child).
542
620
  */
543
- export function persistInheritedMessages(
621
+ export function persistMessages(
544
622
  sessionManager: SessionManager,
545
623
  messages: readonly AgentMessage[],
624
+ priorContextEntryIds: readonly string[],
546
625
  ): void {
626
+ // One id per context message, prior context first; every appended entry projects to one message.
627
+ const entryIdsPerMessage: string[] = [...priorContextEntryIds];
547
628
  for (const message of messages) {
548
- if (message.role === "compactionSummary")
549
- sessionManager.appendCompaction(message.summary, "", message.tokensBefore);
550
- else if (message.role === "branchSummary")
551
- sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary);
629
+ if (message.role === "compactionSummary") {
630
+ const keptTailCount = (message as ChildCompactionMessage).keptTailCount ?? 0;
631
+ const firstKeptEntryId =
632
+ keptTailCount > 0 ? (entryIdsPerMessage[entryIdsPerMessage.length - keptTailCount] ?? "") : "";
633
+ entryIdsPerMessage.push(
634
+ sessionManager.appendCompaction(message.summary, firstKeptEntryId, message.tokensBefore),
635
+ );
636
+ } else if (message.role === "branchSummary")
637
+ entryIdsPerMessage.push(
638
+ sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary),
639
+ );
552
640
  else if (message.role === "custom")
553
- sessionManager.appendCustomMessageEntry(
554
- message.customType,
555
- message.content,
556
- message.display,
557
- message.details,
641
+ entryIdsPerMessage.push(
642
+ sessionManager.appendCustomMessageEntry(
643
+ message.customType,
644
+ message.content,
645
+ message.display,
646
+ message.details,
647
+ ),
558
648
  );
559
- else sessionManager.appendMessage(message);
649
+ else entryIdsPerMessage.push(sessionManager.appendMessage(message));
560
650
  }
561
651
  }
562
652
 
@@ -573,7 +663,7 @@ export function waitForInstruction(
573
663
  };
574
664
  agent.resume = (instruction) => {
575
665
  logSteering(agent.id, "agent-resumed", { instructionLength: instruction.length });
576
- agent.mainContextState = agent.notifyMainAgent ? "will-join" : "separate";
666
+ agent.mainContextState = agent.notifyMainAgent ? "will-squash" : "separate";
577
667
  agent.status = "running";
578
668
  agent.turnStartedAt = Date.now();
579
669
  agent.completedAt = undefined;
@@ -587,12 +677,26 @@ export function waitForInstruction(
587
677
  });
588
678
  }
589
679
 
590
- function subscribeToChildSession(
680
+ export function subscribeToChildSession(
591
681
  session: AgentSession,
592
682
  runningAgent: RunningAgent,
593
683
  widget: UserAgentWidget,
594
684
  ): () => void {
595
685
  return session.subscribe((event: AgentSessionEvent) => {
686
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
687
+ // Compaction never emits message events; capture it here or the rebase stream misses it.
688
+ // At this instant the live context is exactly [summary, kept tail], so the tail is countable.
689
+ const keptTailCount = Math.max(0, session.agent.state.messages.length - 1);
690
+ const compactionMessage: ChildCompactionMessage = {
691
+ role: "compactionSummary",
692
+ summary: event.result.summary,
693
+ tokensBefore: event.result.tokensBefore,
694
+ timestamp: Date.now(),
695
+ keptTailCount,
696
+ };
697
+ runningAgent.conversationMessages.push(compactionMessage);
698
+ logSteering(runningAgent.id, "child-compacted", { keptTailCount });
699
+ }
596
700
  if (event.type === "agent_start")
597
701
  logSteering(runningAgent.id, "child-agent-started", { streaming: session.isStreaming });
598
702
  if (event.type === "agent_end")
package/shared.ts CHANGED
@@ -16,7 +16,7 @@ export type Theme = ExtensionCommandContext["ui"]["theme"];
16
16
  export type UIContext = ExtensionCommandContext["ui"];
17
17
 
18
18
  export type AgentCommandName = "agent";
19
- export type MainContextState = "separate" | "will-join" | "joined";
19
+ export type MainContextState = "separate" | "will-squash" | "squashed" | "rebased";
20
20
  export type AgentStatus =
21
21
  | "starting"
22
22
  | "running"
@@ -27,7 +27,7 @@ export type AgentStatus =
27
27
 
28
28
  export type ParsedAgentCommand = {
29
29
  isolate: boolean;
30
- context: boolean;
30
+ squash: boolean;
31
31
  /** Leading pi CLI tokens (minus the extension's own options) to forward to the child, e.g. ["--thinking", "high"]. */
32
32
  forwardedArgs: string[];
33
33
  task: string;
@@ -44,6 +44,26 @@ export type DetachedEntryData = {
44
44
  sessionId: string;
45
45
  };
46
46
 
47
+ export type RebaseStats = {
48
+ messageCount: number;
49
+ tokenEstimate: number;
50
+ compactionCount: number;
51
+ };
52
+
53
+ export type RebasedEntryData = {
54
+ sessionId: string;
55
+ /** Absent on entries persisted before stats existed. */
56
+ stats?: RebaseStats;
57
+ };
58
+
59
+ /**
60
+ * A child compaction captured from its compaction_end event. keptTailCount records how many
61
+ * messages the compaction kept verbatim, so a rebase replay can restore the exact boundary.
62
+ */
63
+ export type ChildCompactionMessage = Extract<AgentMessage, { role: "compactionSummary" }> & {
64
+ keptTailCount?: number;
65
+ };
66
+
47
67
  export type AgentResultMessage = {
48
68
  customType: string;
49
69
  content: string;
@@ -78,10 +98,12 @@ export type RunningAgent = {
78
98
  model: string;
79
99
  modelLabel: string;
80
100
  task: string;
81
- /** The slash command line as the user typed it, e.g. `/agent -j fix the bug`. */
101
+ /** The slash command line as the user typed it, e.g. `/agent -s fix the bug`. */
82
102
  invocation: string;
83
- /** -j/--join: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
103
+ /** -s/--squash: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
84
104
  notifyMainAgent: boolean;
105
+ /** Fingerprint of the main context the child was dispatched from ("[]" for -i); the rebase fast-forward base. */
106
+ dispatchBaseFingerprint: string;
85
107
  mainContextState: MainContextState;
86
108
  status: AgentStatus;
87
109
  startedAt: number;
@@ -97,8 +119,8 @@ export type RunningAgent = {
97
119
  latestFinalizedMessage?: AgentMessage;
98
120
  error?: string;
99
121
  session?: AgentSession;
100
- /** Latest completed turn's result message, deliverable to the main agent via the overlay's join action. */
101
- pendingJoinMessage?: AgentResultMessage;
122
+ /** Latest completed turn's result message, deliverable to the main agent via the overlay's squash action. */
123
+ pendingSquashMessage?: AgentResultMessage;
102
124
  /** Starts another turn on an idle agent; assigned while the lifecycle loop awaits the next instruction. */
103
125
  resume?: (instruction: string) => void;
104
126
  /** Ends an idle agent's lifecycle without another turn; assigned alongside resume. */
@@ -116,8 +138,9 @@ export type CompletedAgent = {
116
138
  command: AgentCommandName;
117
139
  modelLabel: string;
118
140
  task: string;
141
+ dispatchBaseFingerprint: string;
119
142
  mainContextState: MainContextState;
120
- pendingJoinMessage?: AgentResultMessage;
143
+ pendingSquashMessage?: AgentResultMessage;
121
144
  ok: boolean;
122
145
  responseText: string;
123
146
  messages: AgentMessage[];
@@ -129,8 +152,61 @@ export type CompletedAgent = {
129
152
  contextPercent?: number;
130
153
  };
131
154
 
155
+ /** The mechanism that fast-forwards a child conversation onto the main session, injected into the widget. */
156
+ export type RebaseDelivery = {
157
+ /** Whether the main session's live context still equals this agent's dispatch base. */
158
+ canDeliver(agent: RunningAgent | CompletedAgent): boolean;
159
+ /** Append the processed child conversation onto the main session. */
160
+ deliver(agent: RunningAgent | CompletedAgent, messages: AgentMessage[]): void;
161
+ };
162
+
163
+ /** How long a confirmation stays armed, and how long a transient footer notice shows. */
164
+ export const CONFIRMATION_WINDOW_MS = 4000;
165
+
166
+ /**
167
+ * A two-press confirmation with a timeout: the first press arms it, a repeat press on the
168
+ * same target within the window confirms. A press on another target re-arms, cancel or
169
+ * expiry disarms. One instance serves every confirmable action on a surface.
170
+ */
171
+ export class TimedConfirmation<Target> {
172
+ private armedOn: Target | undefined;
173
+ private expiry: ReturnType<typeof setTimeout> | undefined;
174
+
175
+ constructor(
176
+ private readonly onExpire: () => void,
177
+ private readonly windowMs: number = CONFIRMATION_WINDOW_MS,
178
+ ) {}
179
+
180
+ /** One press of the action key: true means confirmed, false means armed and waiting. */
181
+ press(target: Target): boolean {
182
+ if (this.armedOn === target) {
183
+ this.cancel();
184
+ return true;
185
+ }
186
+ this.armedOn = target;
187
+ if (this.expiry) clearTimeout(this.expiry);
188
+ this.expiry = setTimeout(() => {
189
+ this.armedOn = undefined;
190
+ this.expiry = undefined;
191
+ this.onExpire();
192
+ }, this.windowMs);
193
+ return false;
194
+ }
195
+
196
+ cancel(): void {
197
+ this.armedOn = undefined;
198
+ if (this.expiry) clearTimeout(this.expiry);
199
+ this.expiry = undefined;
200
+ }
201
+
202
+ isArmedOn(target: Target): boolean {
203
+ return this.armedOn !== undefined && this.armedOn === target;
204
+ }
205
+ }
206
+
132
207
  export const MESSAGE_TYPE = "pi-user-agents";
133
208
  export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
209
+ export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
134
210
  export const WIDGET_KEY = "pi-user-agents";
135
211
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
136
212
 
@@ -170,8 +246,9 @@ export function contextLabel(inheritedContext: boolean): string {
170
246
  }
171
247
 
172
248
  export function mainContextLabel(state: MainContextState | undefined): string | undefined {
173
- if (state === "will-join") return "will join context";
174
- if (state === "joined") return "joined context";
249
+ if (state === "will-squash") return "will squash into context";
250
+ if (state === "squashed") return "squashed messages into context";
251
+ if (state === "rebased") return "rebased into context";
175
252
  return undefined;
176
253
  }
177
254
 
package/transcript.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  DetachedEntryData,
10
10
  ExtensionAPI,
11
11
  ExtensionCommandContext,
12
+ RebasedEntryData,
12
13
  RunningAgent,
13
14
  Theme,
14
15
  } from "./shared.js";
@@ -22,6 +23,7 @@ import {
22
23
  formatTurns,
23
24
  mainContextLabel,
24
25
  MESSAGE_TYPE,
26
+ REBASED_ENTRY_TYPE,
25
27
  truncatePlain,
26
28
  } from "./shared.js";
27
29
 
@@ -53,14 +55,14 @@ export function reportCommandError(
53
55
  }
54
56
 
55
57
  /**
56
- * Select the joined role messages from a child transcript.
58
+ * Select the squashed role messages from a child transcript.
57
59
  *
58
- * @example selectJoinedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
60
+ * @example selectSquashedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
59
61
  */
60
- type JoinedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
62
+ type SquashedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
61
63
 
62
- export function selectJoinedMessages(messages: readonly AgentMessage[]): JoinedMessage[] {
63
- const selected: JoinedMessage[] = [];
64
+ export function selectSquashedMessages(messages: readonly AgentMessage[]): SquashedMessage[] {
65
+ const selected: SquashedMessage[] = [];
64
66
  let latestAssistant: Extract<AgentMessage, { role: "assistant" }> | undefined;
65
67
  for (const message of messages) {
66
68
  if (message.role === "assistant" && roleMessageText(message).trim()) {
@@ -110,13 +112,13 @@ export function buildAgentResultMessage(
110
112
  };
111
113
  }
112
114
 
113
- const JOINED_CONVERSATION_PREFACE =
115
+ const SQUASHED_CONVERSATION_PREFACE =
114
116
  "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
117
 
116
118
  export function formatResultMessage(agent: RunningAgent): string {
117
- const messages = selectJoinedMessages(agent.conversationMessages);
119
+ const messages = selectSquashedMessages(agent.conversationMessages);
118
120
  const lines = [
119
- JOINED_CONVERSATION_PREFACE,
121
+ SQUASHED_CONVERSATION_PREFACE,
120
122
  `<user_agent model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
121
123
  ];
122
124
  let firstUserMessage = true;
@@ -135,7 +137,7 @@ export function formatResultMessage(agent: RunningAgent): string {
135
137
  return lines.join("\n");
136
138
  }
137
139
 
138
- function roleMessageText(message: JoinedMessage): string {
140
+ function roleMessageText(message: SquashedMessage): string {
139
141
  if (typeof message.content === "string") return message.content;
140
142
  return message.content
141
143
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
@@ -214,11 +216,37 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
214
216
  )
215
217
  : undefined,
216
218
  );
219
+ // Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
217
220
  pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
218
221
  entry.data
219
- ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 0, 0)
222
+ ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
220
223
  : undefined,
221
224
  );
225
+ pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
226
+ entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
227
+ );
228
+ }
229
+
230
+ function formatRebasedLine(data: RebasedEntryData): string {
231
+ const base = `Rebased session ${data.sessionId} into this conversation`;
232
+ if (!data.stats) return base;
233
+ const parts = [
234
+ `added ${data.stats.messageCount} message${data.stats.messageCount === 1 ? "" : "s"}`,
235
+ `~${formatTokenCount(data.stats.tokenEstimate)} tokens`,
236
+ ];
237
+ if (data.stats.compactionCount > 0)
238
+ parts.push(
239
+ `${data.stats.compactionCount} compaction event${data.stats.compactionCount === 1 ? "" : "s"}`,
240
+ );
241
+ return `${base} (${parts.join(", ")})`;
242
+ }
243
+
244
+ /**
245
+ * @example formatTokenCount(135_264) // "135K"
246
+ * @example formatTokenCount(12) // "12"
247
+ */
248
+ function formatTokenCount(tokens: number): string {
249
+ return tokens < 1000 ? `${tokens}` : `${Math.round(tokens / 1000)}K`;
222
250
  }
223
251
 
224
252
  function liveAgentCard(
@@ -251,7 +279,7 @@ function renderAgentCard(
251
279
  if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
252
280
  if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
253
281
  const card = new Container();
254
- card.addChild(new Text(text, 0, 0));
282
+ card.addChild(new Text(text, 1, 0));
255
283
  card.addChild(body);
256
284
  return card;
257
285
  }
@@ -270,12 +298,12 @@ function renderAgentCardBody(
270
298
  "No output.";
271
299
  if (expanded)
272
300
  return ok
273
- ? new Markdown(output, 2, 0, getMarkdownTheme())
274
- : new Text(theme.fg("error", output), 2, 0);
301
+ ? new Markdown(output, 3, 0, getMarkdownTheme())
302
+ : new Text(theme.fg("error", output), 3, 0);
275
303
 
276
304
  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(), {
305
+ if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
306
+ return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
279
307
  color: (value) => theme.fg("dim", value),
280
308
  });
281
309
  }