@pi-unipi/fusion 2.19.2 → 2.20.0

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
@@ -92,14 +92,19 @@ persist across handoffs. The sidekick's context compacts independently of the
92
92
  lead's (it is its own pi session). `sidekick({message, block:true})` waits by
93
93
  default; `block:false` returns immediately and delivers a
94
94
  `<subagent_completion_notification>`. Calling it while busy steers the same
95
- handoff. Background tasks keep that handoff open through their completion
95
+ handoff. A handoff whose lead turn has ended started with `block:false`,
96
+ abandoned by a user message, or timed out — still wakes the lead with that same
97
+ completion notification when its report lands, exactly once. Background tasks
98
+ keep that handoff open through their completion
96
99
  notification and any follow-up turn, so the report is not released at an
97
100
  intermediate checkpoint. If a prompt arrives while the child is processing,
98
101
  Fusion retries it once with pi's `followUp` streaming behavior. Sidekick work
99
102
  renders as ordinary tool activity — handoff ids, `agent_id`, and
100
- `read_subagent` protocol text never reach the terminal. Detached
101
- handoffs stay visible in a live sidekick widget above the editor until their
102
- completion card arrives.
103
+ `read_subagent` protocol text never reach the terminal. Sidekick output keeps
104
+ that same format on the main surface, marked as sidekick-origin by a `▍` rail.
105
+ While the sidekick works and the lead's turn is over, an animated
106
+ `sidekick working` line above the editor shows the live tool count and elapsed
107
+ time until the report lands.
103
108
  `read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
104
109
 
105
110
  The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.19.2",
3
+ "version": "2.20.0",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.19.2",
36
- "@pi-unipi/subagents": "2.19.2"
35
+ "@pi-unipi/core": "2.20.0",
36
+ "@pi-unipi/subagents": "2.20.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import type { Api, Model } from "@earendil-works/pi-ai";
18
18
  import type { AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
19
- import { setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
19
+ import { createSpinnerLine, setSharedFusionStatus, UNIPI_PREFIX } from "@pi-unipi/core";
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import {
@@ -40,8 +40,7 @@ import { estimateSavings } from "./savings.js";
40
40
  import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
41
41
  import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
42
42
  import { registerFusionTools } from "./tools.js";
43
- import { shouldShowSidekickWidget } from "./sidekick-widget.js";
44
- import { frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader, type ThemeLike } from "./transcript.js";
43
+ import { duration } from "./transcript.js";
45
44
 
46
45
  export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
47
46
  export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
@@ -50,6 +49,73 @@ export function sidekickSessionPath(leadSessionId?: string): string {
50
49
  return join(homedir(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51
50
  }
52
51
 
52
+ export const SIDEKICK_WAKE_WIDGET_KEY = "fusion-sidekick-wake";
53
+
54
+ /**
55
+ * The lead is idle when pi says so. Any failure (including a context without
56
+ * `isIdle`) counts as idle — same contract as the background-tasks wake line.
57
+ */
58
+ export function isLeadIdle(ctx: { isIdle(): boolean }): boolean {
59
+ try {
60
+ return ctx.isIdle();
61
+ } catch {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /** Body of the live line. `undefined` collapses it without disposing the widget. */
67
+ export function sidekickWakeText(progress: { toolCalls: number; startedAt: number } | undefined): string | undefined {
68
+ if (!progress) return undefined;
69
+ return `sidekick working · ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)} — resumes automatically when done`;
70
+ }
71
+
72
+ export interface SidekickWakeLine {
73
+ publish(ctx: ExtensionContext | undefined): void;
74
+ clear(ctx: ExtensionContext | undefined): void;
75
+ }
76
+
77
+ /**
78
+ * Self-animating "sidekick still working" line, shown while the sidekick is
79
+ * busy but the lead's turn has already ended (pi stopped its own loader).
80
+ *
81
+ * The widget owns an 80 ms frame timer, so it is installed ONCE while the
82
+ * condition holds and removed when it stops — never re-installed per progress
83
+ * tick.
84
+ */
85
+ export function createSidekickWakeLine(deps: {
86
+ isBusy: () => boolean;
87
+ progress: () => { toolCalls: number; startedAt: number } | undefined;
88
+ }): SidekickWakeLine {
89
+ let installed = false;
90
+
91
+ const removable = (ctx: ExtensionContext | undefined): ctx is ExtensionContext =>
92
+ Boolean(ctx?.hasUI) && typeof ctx?.ui.setWidget === "function";
93
+
94
+ return {
95
+ publish(ctx) {
96
+ if (!removable(ctx)) return;
97
+ const want = deps.isBusy() && isLeadIdle(ctx);
98
+ if (want && !installed) {
99
+ ctx.ui.setWidget(
100
+ SIDEKICK_WAKE_WIDGET_KEY,
101
+ createSpinnerLine({ text: () => sidekickWakeText(deps.progress()) }),
102
+ { placement: "aboveEditor" },
103
+ );
104
+ installed = true;
105
+ } else if (!want && installed) {
106
+ ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
107
+ installed = false;
108
+ }
109
+ },
110
+ clear(ctx) {
111
+ if (!installed) return;
112
+ installed = false;
113
+ if (!removable(ctx)) return;
114
+ ctx.ui.setWidget(SIDEKICK_WAKE_WIDGET_KEY, undefined);
115
+ },
116
+ };
117
+ }
118
+
53
119
  type Registry = { getAvailable(): Model<Api>[]; find(provider: string, id: string): Model<Api> | undefined };
54
120
 
55
121
  function registryOf(ctx: ExtensionContext): Registry | undefined {
@@ -120,10 +186,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
120
186
  let leadToolCalls = 0;
121
187
  let editNudgedThisTurn = false;
122
188
  let bashStreak = 0;
123
- let attached = false;
124
- let widgetTimer: NodeJS.Timeout | undefined;
125
- let widgetTimerKind: "publish" | "clear" | undefined;
126
- let widgetLastAt = 0;
189
+
190
+ const wakeLine = createSidekickWakeLine({
191
+ isBusy: () => runtime?.isBusy() === true,
192
+ progress: () => runtime?.progress(),
193
+ });
127
194
 
128
195
  function identity(ctx: ExtensionContext): FusionIdentity {
129
196
  const reg = registryOf(ctx);
@@ -167,78 +234,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
167
234
  }
168
235
  }
169
236
 
170
- function clearSidekickWidget(): void {
171
- if (widgetTimer !== undefined) {
172
- clearTimeout(widgetTimer);
173
- widgetTimer = undefined;
174
- widgetTimerKind = undefined;
175
- }
176
- const ctx = lastCtx;
177
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
178
- const clear = () => {
179
- widgetTimer = undefined;
180
- widgetTimerKind = undefined;
181
- widgetLastAt = Date.now();
182
- ctx.ui.setWidget("fusion-sidekick", undefined);
183
- };
184
- const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
185
- if (wait === 0) clear();
186
- else {
187
- widgetTimerKind = "clear";
188
- widgetTimer = setTimeout(clear, wait);
189
- widgetTimer.unref();
190
- }
191
- }
192
-
193
- function publishSidekickWidget(): void {
194
- const ctx = lastCtx;
195
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
196
- const progress = runtime?.progress();
197
- if (!runtime || !shouldShowSidekickWidget(runtime.isBusy(), attached) || !progress) {
198
- ctx.ui.setWidget("fusion-sidekick", undefined);
199
- return;
200
- }
201
- ctx.ui.setWidget("fusion-sidekick", (_tui, theme) => {
202
- const themeLike = theme as unknown as ThemeLike & { bg: (color: string, text: string) => string };
203
- return frameSidekick(themeLike, "working", renderSidekickTranscript(themeLike, {
204
- events: progress.events,
205
- droppedEvents: progress.droppedEvents,
206
- header: sidekickWorkingHeader(themeLike, progress),
207
- expanded: false,
208
- isPartial: true,
209
- renderText: markdownText,
210
- }));
211
- }, { placement: "aboveEditor" });
212
- }
213
-
214
- function publishSidekickWidgetLater(): void {
215
- const ctx = lastCtx;
216
- if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
217
- if (widgetTimer !== undefined && widgetTimerKind === "publish") return;
218
- if (widgetTimer !== undefined) {
219
- clearTimeout(widgetTimer);
220
- widgetTimer = undefined;
221
- widgetTimerKind = undefined;
222
- }
223
- const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
224
- const publish = () => {
225
- widgetTimer = undefined;
226
- widgetTimerKind = undefined;
227
- widgetLastAt = Date.now();
228
- publishSidekickWidget();
229
- };
230
- if (wait === 0) publish();
231
- else {
232
- widgetTimerKind = "publish";
233
- widgetTimer = setTimeout(publish, wait);
234
- widgetTimer.unref();
235
- }
236
- }
237
-
238
- function publishStatusLater(): void {
239
- if (lastCtx) {
240
- publishStatus(lastCtx);
241
- publishSidekickWidgetLater();
237
+ function publishStatusLater(ctx?: ExtensionContext): void {
238
+ const target = ctx ?? lastCtx;
239
+ if (target) {
240
+ publishStatus(target);
241
+ wakeLine.publish(target);
242
242
  }
243
243
  }
244
244
 
@@ -263,8 +263,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
263
263
  }
264
264
 
265
265
  function stopRuntime(): void {
266
- clearSidekickWidget();
267
- attached = false;
266
+ wakeLine.clear(lastCtx);
268
267
  runtime?.kill();
269
268
  runtime = undefined;
270
269
  leadToolCalls = 0;
@@ -285,20 +284,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
285
284
 
286
285
  registerFusionTools(pi, {
287
286
  getRuntime,
288
- onReport: (ctx) => {
289
- attached = false;
290
- clearSidekickWidget();
291
- publishStatus(ctx);
292
- },
287
+ onReport: (ctx) => publishStatusLater(ctx),
293
288
  onHandoffStart: (ctx) => publishStatus(ctx),
294
- onAttach: () => {
295
- attached = true;
296
- clearSidekickWidget();
297
- },
298
- onDetach: () => {
299
- attached = false;
300
- publishSidekickWidgetLater();
301
- },
289
+ onAttach: (ctx) => publishStatusLater(ctx),
290
+ onDetach: (ctx) => publishStatusLater(ctx),
302
291
  });
303
292
  pi.registerCommand("unipi:fusion-stats", {
304
293
  description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
@@ -308,6 +297,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
308
297
  pi.on("turn_start", () => {
309
298
  editNudgedThisTurn = false;
310
299
  });
300
+ // The wake line's condition flips when the LEAD goes idle, not when the
301
+ // sidekick reports progress, so re-evaluate on both turn end and settle.
302
+ pi.on("turn_end", (_event, ctx) => publishStatusLater(ctx));
303
+ pi.on("agent_settled", (_event, ctx) => publishStatusLater(ctx));
311
304
  pi.on("tool_result", (event) => {
312
305
  if (active?.kind !== "fusion") return;
313
306
  const toolName: string = event.toolName;
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
2
2
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
5
- import { duration, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
5
+ import { duration, frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
6
6
 
7
7
  const SidekickParams = Type.Object({
8
8
  message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
@@ -22,6 +22,46 @@ export interface FusionToolDeps {
22
22
  onDetach?: (ctx: ExtensionContext) => void;
23
23
  }
24
24
 
25
+ /**
26
+ * Exactly-once completion delivery for handoffs nobody is waiting on.
27
+ *
28
+ * `attach`/`detach` bracket every waiting period. The completion is sent only
29
+ * if the report lands (or has already landed) while no waiter is attached, and
30
+ * only once per handoff id.
31
+ */
32
+ export function createCompletionDelivery(send: (report: HandoffReport) => void): {
33
+ attach(id: string): void;
34
+ detach(id: string, done: Promise<HandoffReport>): void;
35
+ consume(id: string): void;
36
+ } {
37
+ const waiting = new Set<string>();
38
+ const armed = new Set<string>();
39
+ const delivered = new Set<string>();
40
+
41
+ return {
42
+ attach(id) {
43
+ waiting.add(id);
44
+ },
45
+ detach(id, done) {
46
+ waiting.delete(id);
47
+ // One continuation per handoff, however many times a waiter gives up.
48
+ if (armed.has(id)) return;
49
+ armed.add(id);
50
+ void done
51
+ .then((report) => {
52
+ if (waiting.has(id) || delivered.has(id)) return;
53
+ delivered.add(id);
54
+ send(report);
55
+ })
56
+ .catch(() => undefined);
57
+ },
58
+ consume(id) {
59
+ waiting.delete(id);
60
+ delivered.add(id);
61
+ },
62
+ };
63
+ }
64
+
25
65
  function firstLine(value: string): string {
26
66
  return value.split("\n", 1)[0] ?? "";
27
67
  }
@@ -101,6 +141,21 @@ type ThemeLike = {
101
141
  bold: (text: string) => string;
102
142
  };
103
143
 
144
+ type FramedTheme = ThemeLike & { bg: (color: string, text: string) => string };
145
+
146
+ function transcriptStatus(partial: boolean, report: HandoffReport | undefined): "working" | "completed" | "error" {
147
+ if (partial) return "working";
148
+ return report?.status === "completed" ? "completed" : "error";
149
+ }
150
+
151
+ /**
152
+ * Sidekick output on the main surface: the same markdown/tool format the lead
153
+ * uses, marked as sidekick-origin by the existing `▍` rail.
154
+ */
155
+ function frameTranscript(theme: ThemeLike, status: "working" | "completed" | "error", content: Component): Component {
156
+ return frameSidekick(theme as FramedTheme, status, content);
157
+ }
158
+
104
159
  // Model-facing result text carries handoff ids and protocol instructions the
105
160
  // lead needs; none of it may reach the terminal. Anything rendered as plain
106
161
  // content goes through this first.
@@ -147,7 +202,7 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
147
202
  const partial = progress !== undefined;
148
203
  if (!events) return new Text(displayText(contentText(result.content)), 0, 0);
149
204
  const header = partial ? sidekickWorkingHeader(theme, progress, label) : reportHeader(theme, label, report);
150
- return renderSidekickTranscript(theme, {
205
+ return frameTranscript(theme, transcriptStatus(partial, report), renderSidekickTranscript(theme, {
151
206
  events,
152
207
  droppedEvents: progress?.droppedEvents,
153
208
  header,
@@ -155,30 +210,34 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
155
210
  isPartial: partial,
156
211
  report,
157
212
  renderText: markdownText,
158
- });
213
+ }));
159
214
  }
160
215
 
161
216
  function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
162
217
  if (!report) return new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0);
163
- return renderSidekickTranscript(theme, {
218
+ return frameTranscript(theme, transcriptStatus(false, report), renderSidekickTranscript(theme, {
164
219
  events: report.events ?? [],
165
220
  header: reportHeader(theme, "sidekick", report),
166
221
  expanded: false,
167
222
  isPartial: false,
168
223
  report,
169
224
  renderText: markdownText,
170
- });
225
+ }));
171
226
  }
172
227
 
173
228
  export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
174
229
  pi.registerMessageRenderer("sidekick-completion", (message: { details?: HandoffReport }, _options, theme) => renderCompletionCard(theme as unknown as ThemeLike, message.details));
175
230
 
231
+ // One delivery mechanism for every handoff nobody is waiting on.
232
+ const completion = createCompletionDelivery((report) => {
233
+ pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
234
+ });
235
+
176
236
  pi.registerTool({
177
237
  name: "sidekick",
178
238
  label: "Sidekick",
179
239
  description: "Hand off work to your persistent sidekick subagent (one per session; context and shells persist across handoffs; runs on the same machine). block:true (default) waits and returns the report. block:false returns immediately and the report arrives later as a <subagent_completion_notification>. Calling again while a handoff is running injects the message as an interrupt rather than starting a second sidekick.",
180
240
  parameters: SidekickParams,
181
- renderShell: "self",
182
241
  renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0),
183
242
  renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
184
243
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -188,19 +247,20 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
188
247
  const handoff = runtime.handoff(params.message);
189
248
  if (!wasBusy) deps.onHandoffStart?.(ctx);
190
249
  if (params.block === false) {
191
- void handoff.done.then((report) => {
192
- deps.onReport?.(ctx, report);
193
- pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
194
- }).catch(() => undefined);
250
+ void handoff.done.then((report) => deps.onReport?.(ctx, report)).catch(() => undefined);
251
+ completion.detach(handoff.id, handoff.done);
195
252
  deps.onDetach?.(ctx);
196
253
  return result(`Handoff ${handoff.id} started in the background. You will receive a <subagent_completion_notification agent_id="${handoff.id}"> when it finishes; use read_subagent to wait.`, { background: true, id: handoff.id });
197
254
  }
198
255
  deps.onAttach?.(ctx);
256
+ completion.attach(handoff.id);
199
257
  const waited = await waitForReport(runtime, handoff.id, handoff.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined);
200
258
  if (waited.report) {
259
+ completion.consume(handoff.id);
201
260
  deps.onReport?.(ctx, waited.report);
202
261
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
203
262
  }
263
+ completion.detach(handoff.id, handoff.done);
204
264
  deps.onDetach?.(ctx);
205
265
  if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
206
266
  if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
@@ -214,7 +274,6 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
214
274
  label: "Read Sidekick",
215
275
  description: "Read a sidekick handoff report by agent_id (omit for the latest). block:true waits for completion (default timeout 2700s when omitted); block:false returns the current progress snapshot immediately.",
216
276
  parameters: ReadSubagentParams,
217
- renderShell: "self",
218
277
  renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.block === false ? "· snapshot" : "· waiting")}`, 0, 0),
219
278
  renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
220
279
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -224,16 +283,22 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
224
283
  if (!latest) return result("No sidekick handoff has run yet.", undefined, true);
225
284
  const id = params.agent_id ?? latest.id;
226
285
  const selected = runtime.reports.get(id);
227
- if (selected) return result(reportText(selected), selected, selected.status !== "completed");
286
+ if (selected) {
287
+ completion.consume(id);
288
+ return result(reportText(selected), selected, selected.status !== "completed");
289
+ }
228
290
  if (id !== latest.id) return result(`No sidekick handoff found for ${id}.`, undefined, true);
229
291
  if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
230
292
  deps.onAttach?.(ctx);
293
+ completion.attach(id);
231
294
  const timeoutMs = (params.timeout ?? 2700) * 1000;
232
295
  const waited = await waitForReport(runtime, id, latest.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined, timeoutMs);
233
296
  if (waited.report) {
297
+ completion.consume(id);
234
298
  deps.onReport?.(ctx, waited.report);
235
299
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
236
300
  }
301
+ completion.detach(id, latest.done);
237
302
  deps.onDetach?.(ctx);
238
303
  if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
239
304
  if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
package/src/transcript.ts CHANGED
@@ -53,6 +53,13 @@ export interface TranscriptOptions {
53
53
  renderText: (markdown: string) => Component;
54
54
  }
55
55
 
56
+ /**
57
+ * Non-expanded transcripts show this many most recent events. Windowing is the
58
+ * only cap: a completed handoff renders its events, it is never collapsed into
59
+ * a "N steps" stub.
60
+ */
61
+ export const TRANSCRIPT_WINDOW = 8;
62
+
56
63
  function firstLine(value: string): string {
57
64
  return value.split("\n", 1)[0] ?? "";
58
65
  }
@@ -91,14 +98,8 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
91
98
  const box = new Container();
92
99
  box.addChild(new Text(opts.header, 0, 0));
93
100
 
94
- if (!opts.isPartial && !opts.expanded) {
95
- if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
96
- box.addChild(new Text(theme.fg("dim", `${String(opts.events.length)} steps · expand to see the transcript`), 0, 0));
97
- return box;
98
- }
99
-
100
101
  const dropped = opts.droppedEvents ?? 0;
101
- const start = opts.expanded ? 0 : Math.max(0, opts.events.length - 8);
102
+ const start = opts.expanded ? 0 : Math.max(0, opts.events.length - TRANSCRIPT_WINDOW);
102
103
  if (opts.expanded && dropped > 0) {
103
104
  box.addChild(new Text(theme.fg("dim", `… ${String(dropped)} earliest steps dropped`), 0, 0));
104
105
  } else if (start > 0) {
@@ -110,12 +111,13 @@ export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptio
110
111
  else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
111
112
  }
112
113
 
113
- if (!opts.isPartial && opts.expanded && opts.report?.text) {
114
+ if (!opts.isPartial && opts.report?.text) {
114
115
  // The final assistant message usually IS the report; don't print it twice.
115
116
  const last = opts.events.at(-1);
116
- if (last?.kind === "text" && last.text.trim() === opts.report.text.trim()) return box;
117
- box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
118
- box.addChild(opts.renderText(opts.report.text));
117
+ if (!(last?.kind === "text" && last.text.trim() === opts.report.text.trim())) {
118
+ box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
119
+ box.addChild(opts.renderText(opts.report.text));
120
+ }
119
121
  }
120
122
  return box;
121
123
  }
@@ -1,3 +0,0 @@
1
- export function shouldShowSidekickWidget(busy: boolean, attached: boolean): boolean {
2
- return busy && !attached;
3
- }