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