@tt-a1i/openpi 0.4.0 → 0.5.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.
Files changed (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -1,542 +1,45 @@
1
- /**
2
- * Transcript rendering for the takeover view: turns a SubagentSnapshot's
3
- * normalized transcript + live state into width-bounded TUI lines. The domain
4
- * stream stays normalized and bounded; this renderer only formats its previews.
5
- */
6
-
7
- import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolRenderer } from "../../../shared/agent-tool-renderer.ts";
8
2
  import {
9
- Markdown,
10
- truncateToWidth,
11
- visibleWidth,
12
- wrapTextWithAnsi,
13
- type DefaultTextStyle,
14
- type MarkdownOptions,
15
- } from "@earendil-works/pi-tui";
16
- import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
17
- import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
18
-
19
- const MAX_CACHED_WIDTHS_PER_ITEM = 2;
20
-
21
- // The spinner lives in shared/ so strips outside this extension animate in
22
- // step; the re-export keeps this module's historical import surface intact.
23
- import { spinnerFrame } from "../../../shared/spinner.ts";
24
- export {
25
- SPINNER_FRAMES,
26
- SPINNER_INTERVAL_MS,
27
- spinnerFrame,
28
- } from "../../../shared/spinner.ts";
29
-
30
- /**
31
- * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
32
- * tabs (and stray escapes) make lines wider than the width we declare to the
33
- * TUI, which desyncs the renderer and smears the overlay.
34
- */
35
- export function sanitizeText(text: string): string {
36
- return sanitizeTerminalText(text);
37
- }
38
-
39
- function singleLinePreview(text: string) {
40
- // Keep meaningful whitespace inside parsed commands and paths; only fold
41
- // physical line breaks so a preview remains one terminal row.
42
- return sanitizeText(text).replace(/\r?\n/g, " ↵ ");
43
- }
44
-
45
- function compactPreview(text: string) {
46
- return singleLinePreview(text).trim();
47
- }
48
-
49
- function stringField(value: Record<string, unknown>, field: string) {
50
- const candidate = value[field];
51
- return typeof candidate === "string" && candidate.length > 0
52
- ? singleLinePreview(candidate)
53
- : undefined;
54
- }
55
-
56
- function parsedArgs(preview: string) {
57
- try {
58
- const value: unknown = JSON.parse(preview);
59
- return value && typeof value === "object" && !Array.isArray(value)
60
- ? (value as Record<string, unknown>)
61
- : undefined;
62
- } catch {
63
- return undefined;
64
- }
65
- }
66
-
67
- /** Turn common tool arguments into a useful, bounded summary without retaining raw args. */
68
- export function summarizeToolArgs(
69
- name: string,
70
- argsPreview?: string,
71
- cwd?: string,
72
- ) {
73
- if (!argsPreview) return undefined;
74
-
75
- const fallback = compactPreview(argsPreview);
76
- if (!fallback || fallback === "{}") return undefined;
77
-
78
- const args = parsedArgs(fallback);
79
- if (!args) return fallback;
80
-
81
- const path = (field: string) => {
82
- const value = stringField(args, field);
83
- return value ? relativeToCwd(value, cwd) : undefined;
3
+ buildPairingIndex,
4
+ type AgentTranscriptDocument,
5
+ type PairingIndex,
6
+ } from "../../../shared/agent-transcript.ts";
7
+ import type { SubagentSnapshot } from "../domain.ts";
8
+
9
+ /** Cached items + pairing, keyed on snapshot identity and transcriptVersion. */
10
+ interface StableTranscriptParts {
11
+ readonly version: number;
12
+ readonly items: ReadonlyArray<SubagentSnapshot["transcript"][number]>;
13
+ readonly pairing: PairingIndex;
14
+ }
15
+
16
+ const stableCache = new WeakMap<SubagentSnapshot, StableTranscriptParts>();
17
+
18
+ function stableParts(snap: SubagentSnapshot): StableTranscriptParts {
19
+ const cached = stableCache.get(snap);
20
+ if (cached && cached.version === snap.transcriptVersion) return cached;
21
+ const parts: StableTranscriptParts = {
22
+ version: snap.transcriptVersion,
23
+ items: snap.transcript,
24
+ pairing: buildPairingIndex(snap.transcript),
84
25
  };
85
-
86
- const tool = name.toLowerCase();
87
- if (tool === "bash") return stringField(args, "command") ?? fallback;
88
- if (tool === "read" || tool === "write" || tool === "edit") {
89
- return path("path") ?? fallback;
90
- }
91
- if (tool === "rg" || tool === "fd") {
92
- const pattern = stringField(args, "pattern");
93
- const searchPath = path("path");
94
- if (pattern && searchPath) return `${pattern} · ${searchPath}`;
95
- return pattern ?? searchPath ?? fallback;
96
- }
97
- return fallback;
98
- }
99
-
100
- /** Absolute paths inside the child's own checkout read as noise; relativize. */
101
- function relativeToCwd(path: string, cwd?: string) {
102
- if (!cwd) return path;
103
- if (path === cwd) return ".";
104
- const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
105
- return path.startsWith(prefix) ? path.slice(prefix.length) : path;
106
- }
107
-
108
- function transcriptMarkdownTheme() {
109
- const theme = getMarkdownTheme();
110
- return {
111
- ...theme,
112
- // Markdown normalizes unordered lists to "- "; use a display bullet so
113
- // transcript list syntax is never confused with unrendered source.
114
- listBullet: (text: string) =>
115
- theme.listBullet(text.replace(/^(?:[-+*]) /, "• ")),
116
- };
117
- }
118
-
119
- function renderMarkdown(
120
- text: string,
121
- width: number,
122
- defaultTextStyle?: DefaultTextStyle,
123
- options?: MarkdownOptions,
124
- ) {
125
- const clean = sanitizeText(text).trim();
126
- if (!clean) return [];
127
- const markdown = new Markdown(
128
- clean,
129
- 0,
130
- 0,
131
- transcriptMarkdownTheme(),
132
- defaultTextStyle,
133
- options,
134
- );
135
- return markdown
136
- .render(Math.max(1, width))
137
- .map((line) => truncateToWidth(line, width));
138
- }
139
-
140
- function renderUserText(theme: Theme, text: string, width: number) {
141
- const lines = renderMarkdown(
142
- text,
143
- Math.max(1, width - 2),
144
- { color: (content: string) => theme.fg("userMessageText", content) },
145
- { preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
146
- );
147
- return lines.map((line, index) =>
148
- truncateToWidth(
149
- (index === 0 ? theme.fg("accent", "> ") : " ") + line,
150
- width,
151
- ),
152
- );
153
- }
154
-
155
- function renderThinking(theme: Theme, text: string, width: number) {
156
- const reasoning = sanitizeText(text).trim();
157
- if (!reasoning) return [];
158
- const out: string[] = [];
159
- const prefix = theme.fg("dim", "~ ");
160
- const defaultTextStyle = {
161
- color: (content: string) => theme.fg("muted", content),
162
- italic: true,
163
- } satisfies DefaultTextStyle;
164
- const lines = renderMarkdown(
165
- reasoning,
166
- Math.max(1, width - 2),
167
- defaultTextStyle,
168
- );
169
- for (let i = 0; i < lines.length; i++) {
170
- out.push(truncateToWidth((i === 0 ? prefix : " ") + lines[i], width));
171
- }
172
- return out;
173
- }
174
-
175
- function renderToolBody(
176
- theme: Theme,
177
- name: string,
178
- argsPreview?: string,
179
- cwd?: string,
180
- ) {
181
- const toolName = sanitizeText(name);
182
- const preview = summarizeToolArgs(toolName, argsPreview, cwd);
183
- // The `$` form only earns its prompt when there is a command to show; a bare
184
- // `$ ` would read as an empty shell line.
185
- if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`);
186
- return (
187
- theme.fg("toolTitle", toolName) +
188
- (preview ? theme.fg("dim", ` ${preview}`) : "")
189
- );
190
- }
191
-
192
- function firstOutputPreview(outputPreview?: string) {
193
- return (
194
- sanitizeText(outputPreview ?? "")
195
- .split("\n")
196
- .find((line) => line.trim()) ?? ""
197
- );
26
+ stableCache.set(snap, parts);
27
+ return parts;
198
28
  }
199
29
 
200
- /**
201
- * One execution owns exactly one glyph column, on its command line. Output
202
- * lines are plain indented text so a block keeps identical columns from the
203
- * moment the command starts to the moment it settles.
204
- */
205
- export type ToolPhase = "live" | "ok" | "error" | "pending";
206
-
207
- function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) {
208
- switch (phase) {
209
- case "live":
210
- return theme.fg("warning", spinnerFrame(now));
211
- case "ok":
212
- return theme.fg("success", "✓");
213
- case "error":
214
- return theme.fg("error", "✗");
215
- case "pending":
216
- return theme.fg("dim", "·");
217
- }
218
- }
219
-
220
- /** Command line: `<glyph> $ cmd` for bash, `<glyph> name args` otherwise. */
221
- function renderToolLine(
222
- theme: Theme,
223
- phase: ToolPhase,
224
- name: string,
225
- argsPreview: string | undefined,
226
- width: number,
227
- now: number,
228
- cwd?: string,
229
- ) {
230
- return truncateToWidth(
231
- `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview, cwd)}`,
232
- width,
233
- );
234
- }
235
-
236
- /** Output line: indented under the command, no second glyph. */
237
- function renderOutputLine(
238
- theme: Theme,
239
- isError: boolean,
240
- outputPreview: string,
241
- width: number,
242
- cwd?: string,
243
- ) {
244
- // Tool output echoes the absolute search path back (fd/rg print what they
245
- // were given); inside the child's own checkout the relative form is enough.
246
- const text = cwd ? outputPreview.split(`${cwd}/`).join("") : outputPreview;
247
- const preview = text || "(no output)";
248
- const content = isError
249
- ? theme.fg(text ? "error" : "dim", preview)
250
- : theme.fg("dim", preview);
251
- return truncateToWidth(` ${content}`, width);
252
- }
253
-
254
- function renderAssistantItem(
255
- theme: Theme,
256
- item: Extract<TranscriptItem, { kind: "assistant" }>,
257
- width: number,
258
- phases: ReadonlyMap<string, ToolPhase>,
259
- now: number,
260
- cwd?: string,
261
- ) {
262
- const out: string[] = [];
263
- for (const part of item.parts) {
264
- if (part.type === "text") {
265
- out.push(...renderMarkdown(part.text, width));
266
- } else if (part.type === "thinking") {
267
- out.push(
268
- ...renderThinking(
269
- theme,
270
- part.redacted ? "[redacted reasoning]" : part.text,
271
- width,
272
- ),
273
- );
274
- } else if (part.type === "toolCall") {
275
- const phase = phases.get(part.toolId) ?? "pending";
276
- // A live tool is rendered by the live block, which owns the spinner and
277
- // the streaming output; rendering the call here too would show the same
278
- // command twice and make the block reflow when the tool settles.
279
- if (phase === "live") continue;
280
- out.push(
281
- renderToolLine(
282
- theme,
283
- phase,
284
- part.name,
285
- part.argsPreview,
286
- width,
287
- now,
288
- cwd,
289
- ),
290
- );
291
- }
292
- }
293
- return out;
294
- }
295
-
296
- function renderToolResultItem(
297
- theme: Theme,
298
- item: Extract<TranscriptItem, { kind: "toolResult" }>,
299
- width: number,
300
- paired: boolean,
301
- now: number,
302
- cwd?: string,
303
- ) {
304
- const preview = firstOutputPreview(item.outputPreview);
305
- // An orphan result (its call is not the previous item) still needs a glyph:
306
- // there is no command line above it to carry one.
307
- if (!paired) {
308
- return [
309
- renderToolLine(
310
- theme,
311
- item.isError ? "error" : "ok",
312
- item.name,
313
- undefined,
314
- width,
315
- now,
316
- ),
317
- ...(preview
318
- ? [renderOutputLine(theme, item.isError, preview, width, cwd)]
319
- : []),
320
- ];
321
- }
322
- return [renderOutputLine(theme, item.isError, preview, width, cwd)];
323
- }
324
-
325
- function isPairedToolResult(
326
- previous: TranscriptItem | undefined,
327
- current: TranscriptItem,
328
- ) {
329
- if (
330
- !previous ||
331
- previous.kind !== "assistant" ||
332
- current.kind !== "toolResult"
333
- ) {
334
- return false;
335
- }
336
- const lastPart = previous.parts[previous.parts.length - 1];
337
- return lastPart?.type === "toolCall" && lastPart.toolId === current.toolId;
338
- }
339
-
340
- function renderTranscriptItem(
341
- theme: Theme,
342
- item: TranscriptItem,
343
- width: number,
344
- context: ItemContext,
345
- now: number,
346
- cwd?: string,
347
- ) {
348
- if (item.kind === "user") return renderUserText(theme, item.text, width);
349
- if (item.kind === "assistant") {
350
- return renderAssistantItem(theme, item, width, context.phases, now, cwd);
351
- }
352
- return renderToolResultItem(theme, item, width, context.paired, now, cwd);
353
- }
354
-
355
- interface ItemContext {
356
- readonly phases: ReadonlyMap<string, ToolPhase>;
357
- readonly paired: boolean;
358
- /** Cache discriminator: identity plus width is not enough on its own. */
359
- readonly token: string;
360
- }
361
-
362
- /**
363
- * An item's rendering depends on its neighbours (does a call have its result
364
- * yet?) and on live state (is the call still running?), so the cache key has to
365
- * carry that context or a stale glyph would outlive the phase it described.
366
- */
367
- function itemContext(
368
- transcript: ReadonlyArray<TranscriptItem>,
369
- index: number,
370
- liveIds: ReadonlySet<string>,
371
- ): ItemContext {
372
- const item = transcript[index]!;
373
- if (item.kind === "user")
374
- return { phases: new Map(), paired: false, token: "" };
375
- if (item.kind === "toolResult") {
376
- const paired = isPairedToolResult(transcript[index - 1], item);
377
- return { phases: new Map(), paired, token: paired ? "p" : "o" };
378
- }
379
-
380
- const phases = new Map<string, ToolPhase>();
381
- for (const part of item.parts) {
382
- if (part.type !== "toolCall") continue;
383
- if (liveIds.has(part.toolId)) {
384
- phases.set(part.toolId, "live");
385
- continue;
386
- }
387
- const result = findResult(transcript, index, part.toolId);
388
- phases.set(
389
- part.toolId,
390
- result ? (result.isError ? "error" : "ok") : "pending",
391
- );
392
- }
30
+ /** Thin projection from Direct Subagent state to the shared UI document. */
31
+ export function subagentTranscriptDocument(
32
+ snap: SubagentSnapshot,
33
+ toolRenderer?: AgentToolRenderer,
34
+ ): AgentTranscriptDocument {
35
+ const { items, pairing } = stableParts(snap);
393
36
  return {
394
- phases,
395
- paired: false,
396
- token: [...phases].map(([id, phase]) => `${id}:${phase}`).join(","),
37
+ items,
38
+ pairing,
39
+ cwd: snap.cwd,
40
+ ...(toolRenderer ? { toolRenderer } : {}),
41
+ ...(snap.liveAssistant ? { liveAssistant: snap.liveAssistant } : {}),
42
+ ...(snap.liveTools.length > 0 ? { liveTools: snap.liveTools } : {}),
43
+ ...(snap.queued.length > 0 ? { queued: snap.queued } : {}),
397
44
  };
398
45
  }
399
-
400
- /** The result for a call, if it has already landed later in the transcript. */
401
- function findResult(
402
- transcript: ReadonlyArray<TranscriptItem>,
403
- callIndex: number,
404
- toolId: string,
405
- ) {
406
- for (let index = callIndex + 1; index < transcript.length; index++) {
407
- const candidate = transcript[index];
408
- if (candidate?.kind === "toolResult" && candidate.toolId === toolId) {
409
- return candidate;
410
- }
411
- }
412
- return undefined;
413
- }
414
-
415
- /**
416
- * Caches finalized transcript items by identity and width. Live state remains
417
- * uncached because it changes on every stream tick; callers clear this cache
418
- * from their component's invalidate() when Pi changes theme.
419
- */
420
- export class TranscriptRenderer {
421
- private itemCache = new WeakMap<TranscriptItem, Map<string, string[]>>();
422
-
423
- render(
424
- snap: SubagentSnapshot,
425
- width: number,
426
- theme: Theme,
427
- options?: { readonly now?: number },
428
- ) {
429
- const out: string[] = [];
430
- const now = options?.now ?? Date.now();
431
- const liveIds = new Set(snap.liveTools.map((tool) => tool.toolId));
432
-
433
- for (let index = 0; index < snap.transcript.length; index++) {
434
- const item = snap.transcript[index];
435
- const context = itemContext(snap.transcript, index, liveIds);
436
- const key = `${width}|${context.token}`;
437
- const cached = this.itemCache.get(item)?.get(key);
438
- const lines =
439
- cached ??
440
- renderTranscriptItem(theme, item, width, context, now, snap.cwd);
441
- if (!cached) {
442
- const widths = this.itemCache.get(item) ?? new Map<string, string[]>();
443
- if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) {
444
- const oldestWidth = widths.keys().next().value;
445
- if (oldestWidth !== undefined) widths.delete(oldestWidth);
446
- }
447
- widths.set(key, lines);
448
- this.itemCache.set(item, widths);
449
- }
450
- if (lines.length > 0) {
451
- if (
452
- out.length > 0 &&
453
- !isPairedToolResult(snap.transcript[index - 1], item)
454
- ) {
455
- out.push("");
456
- }
457
- out.push(...lines);
458
- }
459
- }
460
- while (out.length > 0 && out[out.length - 1] === "") out.pop();
461
-
462
- // Live streaming assistant buffers (cleared when the finalized message lands).
463
- if (snap.liveAssistant) {
464
- const { thinking, text } = snap.liveAssistant;
465
- const before = out.length;
466
- if (out.length > 0) out.push("");
467
- if (thinking.trim()) out.push(...renderThinking(theme, thinking, width));
468
- if (text.trim()) out.push(...renderMarkdown(text, width));
469
- if (out.length === before + 1) out.pop();
470
- }
471
-
472
- // Live tool executions. The manager drops a live entry when its ToolEnd
473
- // lands, and the transcript's call line then takes over with the settled
474
- // glyph in the same column, so the block never reflows.
475
- for (const tool of snap.liveTools) {
476
- if (out.length > 0) out.push("");
477
- const phase: ToolPhase = tool.done
478
- ? tool.isError
479
- ? "error"
480
- : "ok"
481
- : "live";
482
- out.push(
483
- renderToolLine(
484
- theme,
485
- phase,
486
- tool.name,
487
- tool.argsPreview,
488
- width,
489
- now,
490
- snap.cwd,
491
- ),
492
- );
493
- const preview = firstOutputPreview(tool.outputPreview);
494
- if (preview)
495
- out.push(
496
- renderOutputLine(theme, !!tool.isError, preview, width, snap.cwd),
497
- );
498
- }
499
-
500
- // Queued steering/follow-up messages: show them immediately so Enter
501
- // visibly acknowledges the user's input instead of appearing to do nothing.
502
- for (const message of snap.queued) {
503
- if (out.length > 0) out.push("");
504
- const prefix = theme.fg("warning", `> [queued ${message.kind}] `);
505
- const wrapped = wrapTextWithAnsi(
506
- sanitizeText(message.text),
507
- Math.max(1, width - visibleWidth(prefix)),
508
- );
509
- for (let i = 0; i < wrapped.length; i++) {
510
- out.push(
511
- truncateToWidth(
512
- (i === 0 ? prefix : " ".repeat(visibleWidth(prefix))) +
513
- theme.fg("muted", wrapped[i]),
514
- width,
515
- ),
516
- );
517
- }
518
- }
519
-
520
- return out;
521
- }
522
-
523
- invalidate() {
524
- this.itemCache = new WeakMap();
525
- }
526
- }
527
-
528
- /** Render a subagent's conversation as width-bounded lines. */
529
- export function buildTranscriptLines(
530
- snap: SubagentSnapshot,
531
- width: number,
532
- theme: Theme,
533
- renderer?: TranscriptRenderer,
534
- options?: { readonly now?: number },
535
- ) {
536
- return (renderer ?? new TranscriptRenderer()).render(
537
- snap,
538
- width,
539
- theme,
540
- options,
541
- );
542
- }