@tt-a1i/openpi 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +35 -44
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +216 -170
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/result-delivery.ts +50 -5
  38. package/extensions/subagents/src/runtime.ts +8 -5
  39. package/extensions/subagents/src/ui/takeover.ts +84 -109
  40. package/extensions/subagents/src/ui/transcript.ts +76 -42
  41. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  42. package/extensions/tasks/ui.ts +79 -62
  43. package/extensions/ui-customization/footer.ts +7 -4
  44. package/extensions/user-input-fold/index.ts +185 -0
  45. package/extensions/workflows/artifacts.ts +35 -0
  46. package/extensions/workflows/controller.ts +14 -2
  47. package/extensions/workflows/coordinator.ts +64 -0
  48. package/extensions/workflows/dashboard.ts +353 -173
  49. package/extensions/workflows/handoff.ts +62 -20
  50. package/extensions/workflows/index.ts +647 -387
  51. package/extensions/workflows/model.ts +57 -15
  52. package/extensions/workflows/navigation.ts +33 -14
  53. package/extensions/workflows/prompt.ts +104 -8
  54. package/extensions/workflows/replay-safety.ts +16 -6
  55. package/extensions/workflows/result-delivery.ts +189 -0
  56. package/extensions/workflows/sandbox-child.cjs +11 -0
  57. package/package.json +1 -1
  58. package/skills/subagents/SKILL.md +2 -2
  59. package/skills/workflows/REFERENCE.md +7 -4
  60. package/skills/workflows/SKILL.md +53 -10
  61. package/extensions/subagents/src/format.ts +0 -48
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Takeover UI for subagents (ported from v1, rendering from the synchronous
3
3
  * SubagentReadModel instead of live pi sessions):
4
- * - SubagentDashboard: full popup (overlay) listing all subagents.
4
+ * - SubagentDashboard: compact picker docked above the input, listing all subagents.
5
5
  * - TakeoverView: full interactive view of one subagent with an input line
6
6
  * to steer/continue it.
7
7
  */
@@ -13,15 +13,20 @@ import type {
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
15
15
  import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
+ import {
17
+ hintLine,
18
+ panelFrame,
19
+ type ScreenHint,
20
+ } from "../../../shared/screen-chrome.ts";
16
21
  import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
17
22
  import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
18
- import { formatContextUtilization } from "../format.ts";
23
+ import { formatContextUtilization } from "../../../shared/context-utilization.ts";
19
24
  import type { SubagentReadModel } from "../manager.ts";
20
25
  import {
21
- SPINNER_INTERVAL_MS,
22
- TranscriptRenderer,
23
26
  buildTranscriptLines,
27
+ SPINNER_INTERVAL_MS,
24
28
  spinnerFrame,
29
+ TranscriptRenderer,
25
30
  } from "./transcript.ts";
26
31
 
27
32
  export function sanitizeSubagentDisplayLine(value: string) {
@@ -44,48 +49,22 @@ function statusGlyph(
44
49
  snap: SubagentSnapshot,
45
50
  theme: Theme,
46
51
  now = Date.now(),
52
+ selected = false,
47
53
  ): string {
54
+ // A selected row keeps its state glyph and borrows the accent tone, so the
55
+ // list never hides what is still running behind a selection marker.
56
+ const tone = (color: "warning" | "success" | "error") =>
57
+ selected ? ("accent" as const) : color;
48
58
  switch (snap.status) {
49
59
  case "running":
50
- return theme.fg("warning", spinnerFrame(now));
60
+ return theme.fg(tone("warning"), spinnerFrame(now));
51
61
  case "done":
52
- return theme.fg("success", "✓");
62
+ return theme.fg(tone("success"), "✓");
53
63
  case "error":
54
- return theme.fg("error", "✗");
64
+ return theme.fg(tone("error"), "✗");
55
65
  }
56
66
  }
57
67
 
58
- /**
59
- * Tool names come from the child's own tool-call events, so they are as
60
- * untrusted as their arguments and must be sanitized before reaching the
61
- * terminal: `truncateToWidth` only ignores escape sequences while measuring.
62
- */
63
- function runningActivity(snap: SubagentSnapshot) {
64
- if (snap.status !== "running") return "";
65
- const liveTool = snap.liveTools.at(-1);
66
- if (liveTool) {
67
- const name = sanitizeSubagentDisplayLine(liveTool.name);
68
- const args = truncateToWidth(
69
- sanitizeSubagentDisplayLine(liveTool.argsPreview ?? ""),
70
- 48,
71
- );
72
- return args ? `${name} · ${args}` : name;
73
- }
74
- for (const item of [...snap.transcript].reverse()) {
75
- if (item.kind === "toolResult") {
76
- return sanitizeSubagentDisplayLine(item.name);
77
- }
78
- if (item.kind !== "assistant") continue;
79
- const tool = [...item.parts]
80
- .reverse()
81
- .find((part) => part.type === "toolCall");
82
- if (tool?.type === "toolCall") {
83
- return sanitizeSubagentDisplayLine(tool.name);
84
- }
85
- }
86
- return "";
87
- }
88
-
89
68
  // --- Entry points --------------------------------------------------------------
90
69
 
91
70
  export interface TakeoverOptions {
@@ -127,7 +106,14 @@ export async function openSubagentPicker(
127
106
  new SubagentDashboard(tui, theme, keybindings, view, selection, done),
128
107
  {
129
108
  overlay: true,
130
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
109
+ // Dock the picker just above the editor (editor + strip + footer ≈ 5
110
+ // rows) like a command palette, instead of covering the conversation.
111
+ overlayOptions: {
112
+ anchor: "bottom-center",
113
+ width: "100%",
114
+ maxHeight: "60%",
115
+ margin: { bottom: 5 },
116
+ },
131
117
  },
132
118
  );
133
119
 
@@ -139,7 +125,10 @@ export async function openSubagentPicker(
139
125
  }
140
126
  }
141
127
 
142
- // --- Dashboard (fullscreen overlay) ----------------------------------------------
128
+ // --- Dashboard (picker docked above the input) ---------------------------------
129
+
130
+ /** A picker is a glance, not a workspace: cap the list window and scroll. */
131
+ const MAX_PICKER_ROWS = 10;
143
132
 
144
133
  export interface DashboardSelection {
145
134
  id?: string;
@@ -258,24 +247,6 @@ export class SubagentDashboard implements Component {
258
247
  }
259
248
  }
260
249
 
261
- private pad(text: string, width: number): string {
262
- const truncated = truncateToWidth(text, width);
263
- return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
264
- }
265
-
266
- private borderSegment(width: number, title: string): string {
267
- const theme = this.theme;
268
- const label = title
269
- ? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
270
- : "";
271
- const labelWidth = visibleWidth(label);
272
- return (
273
- theme.fg("border", "─") +
274
- (label ? theme.fg("text", label) : "") +
275
- theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
276
- );
277
- }
278
-
279
250
  render(width: number): string[] {
280
251
  const theme = this.theme;
281
252
  const subs = this.subs();
@@ -283,13 +254,14 @@ export class SubagentDashboard implements Component {
283
254
 
284
255
  // One timestamp per frame so every row's spinner shows the same frame.
285
256
  const now = Date.now();
257
+ // Docked above the editor, the panel borrows conversation space: cap the
258
+ // list window and scroll instead of growing toward the top of the screen.
286
259
  const rows = this.tui.terminal.rows || 30;
287
- const maxBodyHeight = Math.max(1, rows - 5);
260
+ const maxBodyHeight = Math.min(Math.max(1, rows - 5), MAX_PICKER_ROWS);
288
261
  const bodyHeight =
289
262
  subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length);
290
263
  const innerWidth = Math.max(0, width - 2);
291
264
 
292
- const lines: string[] = [];
293
265
  const running = subs.filter((snap) => snap.status === "running").length;
294
266
  const done = subs.filter((snap) => snap.status === "done").length;
295
267
  const failed = subs.filter((snap) => snap.status === "error").length;
@@ -301,37 +273,32 @@ export class SubagentDashboard implements Component {
301
273
  ]
302
274
  .filter(Boolean)
303
275
  .join(" · ") || "no agents";
304
- lines.push(
305
- theme.fg("border", "╭") +
306
- this.borderSegment(innerWidth, `Subagents · ${summary}`) +
307
- theme.fg("border", "╮"),
308
- );
309
-
310
- const divider = theme.fg("border", "│");
311
276
  const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now);
312
- for (const row of rowLines) {
313
- lines.push(divider + this.pad(row, innerWidth) + divider);
314
- }
315
-
316
- // Bottom border
317
- lines.push(
318
- theme.fg("border", "╰") +
319
- theme.fg("border", "─".repeat(innerWidth)) +
320
- theme.fg("border", "╯"),
321
- );
322
-
323
- // Hints
324
- lines.push(
325
- truncateToWidth(
326
- theme.fg(
327
- "dim",
328
- ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
329
- ),
277
+ const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
278
+ configuredKeys(this.keybindings, binding);
279
+ // Shared chrome, so this panel and its hints read the same as /ps and
280
+ // /workflows. The frame is padded to the rows it was given, which keeps
281
+ // this view's content-fit height rather than reintroducing a fixed one.
282
+ return [
283
+ // One empty row of air between the conversation and the docked panel.
284
+ "",
285
+ ...panelFrame(theme, {
286
+ label: `Subagents · ${summary}`,
287
+ rows: rowLines,
288
+ width,
289
+ height: rowLines.length + 2,
290
+ }),
291
+ hintLine(
292
+ theme,
293
+ [
294
+ [`${keys("tui.select.up")}/${keys("tui.select.down")}/jk`, "select"],
295
+ [keys("tui.select.confirm"), "take over"],
296
+ ["x", "abort"],
297
+ [keys("tui.select.cancel"), "close"],
298
+ ],
330
299
  width,
331
300
  ),
332
- );
333
-
334
- return lines;
301
+ ];
335
302
  }
336
303
 
337
304
  private renderRows(
@@ -362,13 +329,16 @@ export class SubagentDashboard implements Component {
362
329
  const index = start + i;
363
330
  const isSelected = index === this.selection.index;
364
331
 
365
- const marker = isSelected ? theme.fg("accent", "❯") : " ";
332
+ // One glyph column: a selected row tints its status glyph with the
333
+ // accent tone instead of stacking a second marker. The live tool name
334
+ // is deliberately absent: it flickers with every tool call and says
335
+ // nothing the user acts on.
336
+ const glyph = statusGlyph(snap, theme, now, isSelected);
366
337
  const safeTitle = sanitizeSubagentDisplayLine(snap.title) || snap.id;
367
338
  const title = isSelected
368
339
  ? theme.fg("accent", safeTitle)
369
340
  : theme.fg("text", safeTitle);
370
- const activity = runningActivity(snap);
371
- const prefix = ` ${marker} ${statusGlyph(snap, theme, now)} `;
341
+ const prefix = ` ${glyph} `;
372
342
 
373
343
  const utilization = formatContextUtilization(snap.usage);
374
344
  const metadata = [
@@ -392,20 +362,9 @@ export class SubagentDashboard implements Component {
392
362
  const right = metadata.join(dot);
393
363
  const rightWidth = visibleWidth(right);
394
364
  const leftMax = Math.max(0, width - rightWidth - (right ? 2 : 0));
395
- const activityLabel = activity ? theme.fg("muted", ` · ${activity}`) : "";
396
365
  const left =
397
366
  prefix +
398
- truncateToWidth(
399
- title,
400
- Math.max(
401
- 0,
402
- leftMax - visibleWidth(prefix) - visibleWidth(activityLabel),
403
- ),
404
- ) +
405
- truncateToWidth(
406
- activityLabel,
407
- Math.max(0, leftMax - visibleWidth(prefix)),
408
- );
367
+ truncateToWidth(title, Math.max(0, leftMax - visibleWidth(prefix)));
409
368
  const gap = right
410
369
  ? Math.max(1, width - visibleWidth(left) - rightWidth)
411
370
  : 0;
@@ -694,13 +653,29 @@ export class TakeoverView implements Component, Focusable {
694
653
  ),
695
654
  );
696
655
  lines.push(...this.input.render(width));
697
- const hints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`;
698
- const compactHints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll`;
656
+ const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
657
+ configuredKeys(this.keybindings, binding);
658
+ const editing: ScreenHint[] = [
659
+ [keys("tui.input.submit"), "send"],
660
+ [keys("app.interrupt"), "back"],
661
+ [keys("app.clear"), "abort run"],
662
+ [
663
+ `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`,
664
+ "scroll",
665
+ ],
666
+ ];
667
+ // Same drop-the-page-hint fallback as before, measured on the styled line
668
+ // so the keys-brighter-than-labels styling cannot change what fits.
669
+ const full = hintLine(
670
+ theme,
671
+ [
672
+ ...editing,
673
+ [`${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, "page"],
674
+ ],
675
+ width,
676
+ );
699
677
  lines.push(
700
- truncateToWidth(
701
- theme.fg("dim", visibleWidth(hints) <= width ? hints : compactHints),
702
- width,
703
- ),
678
+ visibleWidth(full) <= width ? full : hintLine(theme, editing, width),
704
679
  );
705
680
  lines.push(this.rule(width, theme.fg("borderAccent", "─")));
706
681
  return lines;
@@ -18,28 +18,14 @@ import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
18
18
 
19
19
  const MAX_CACHED_WIDTHS_PER_ITEM = 2;
20
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
- }
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";
43
29
 
44
30
  /**
45
31
  * Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
@@ -79,7 +65,11 @@ function parsedArgs(preview: string) {
79
65
  }
80
66
 
81
67
  /** Turn common tool arguments into a useful, bounded summary without retaining raw args. */
82
- export function summarizeToolArgs(name: string, argsPreview?: string) {
68
+ export function summarizeToolArgs(
69
+ name: string,
70
+ argsPreview?: string,
71
+ cwd?: string,
72
+ ) {
83
73
  if (!argsPreview) return undefined;
84
74
 
85
75
  const fallback = compactPreview(argsPreview);
@@ -88,20 +78,33 @@ export function summarizeToolArgs(name: string, argsPreview?: string) {
88
78
  const args = parsedArgs(fallback);
89
79
  if (!args) return fallback;
90
80
 
81
+ const path = (field: string) => {
82
+ const value = stringField(args, field);
83
+ return value ? relativeToCwd(value, cwd) : undefined;
84
+ };
85
+
91
86
  const tool = name.toLowerCase();
92
87
  if (tool === "bash") return stringField(args, "command") ?? fallback;
93
88
  if (tool === "read" || tool === "write" || tool === "edit") {
94
- return stringField(args, "path") ?? fallback;
89
+ return path("path") ?? fallback;
95
90
  }
96
91
  if (tool === "rg" || tool === "fd") {
97
92
  const pattern = stringField(args, "pattern");
98
- const path = stringField(args, "path");
99
- if (pattern && path) return `${pattern} · ${path}`;
100
- return pattern ?? path ?? fallback;
93
+ const searchPath = path("path");
94
+ if (pattern && searchPath) return `${pattern} · ${searchPath}`;
95
+ return pattern ?? searchPath ?? fallback;
101
96
  }
102
97
  return fallback;
103
98
  }
104
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
+
105
108
  function transcriptMarkdownTheme() {
106
109
  const theme = getMarkdownTheme();
107
110
  return {
@@ -169,14 +172,18 @@ function renderThinking(theme: Theme, text: string, width: number) {
169
172
  return out;
170
173
  }
171
174
 
172
- function renderToolBody(theme: Theme, name: string, argsPreview?: string) {
175
+ function renderToolBody(
176
+ theme: Theme,
177
+ name: string,
178
+ argsPreview?: string,
179
+ cwd?: string,
180
+ ) {
173
181
  const toolName = sanitizeText(name);
174
- const preview = summarizeToolArgs(toolName, argsPreview);
182
+ const preview = summarizeToolArgs(toolName, argsPreview, cwd);
175
183
  // The `$` form only earns its prompt when there is a command to show; a bare
176
184
  // `$ ` would read as an empty shell line.
177
185
  if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`);
178
186
  return (
179
- theme.fg("dim", "→ ") +
180
187
  theme.fg("toolTitle", toolName) +
181
188
  (preview ? theme.fg("dim", ` ${preview}`) : "")
182
189
  );
@@ -210,7 +217,7 @@ function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) {
210
217
  }
211
218
  }
212
219
 
213
- /** Command line: `<glyph> $ cmd` for bash, `<glyph> name args` otherwise. */
220
+ /** Command line: `<glyph> $ cmd` for bash, `<glyph> name args` otherwise. */
214
221
  function renderToolLine(
215
222
  theme: Theme,
216
223
  phase: ToolPhase,
@@ -218,9 +225,10 @@ function renderToolLine(
218
225
  argsPreview: string | undefined,
219
226
  width: number,
220
227
  now: number,
228
+ cwd?: string,
221
229
  ) {
222
230
  return truncateToWidth(
223
- `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview)}`,
231
+ `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview, cwd)}`,
224
232
  width,
225
233
  );
226
234
  }
@@ -231,10 +239,14 @@ function renderOutputLine(
231
239
  isError: boolean,
232
240
  outputPreview: string,
233
241
  width: number,
242
+ cwd?: string,
234
243
  ) {
235
- const preview = outputPreview || "(no output)";
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)";
236
248
  const content = isError
237
- ? theme.fg(outputPreview ? "error" : "dim", preview)
249
+ ? theme.fg(text ? "error" : "dim", preview)
238
250
  : theme.fg("dim", preview);
239
251
  return truncateToWidth(` ${content}`, width);
240
252
  }
@@ -245,6 +257,7 @@ function renderAssistantItem(
245
257
  width: number,
246
258
  phases: ReadonlyMap<string, ToolPhase>,
247
259
  now: number,
260
+ cwd?: string,
248
261
  ) {
249
262
  const out: string[] = [];
250
263
  for (const part of item.parts) {
@@ -265,7 +278,15 @@ function renderAssistantItem(
265
278
  // command twice and make the block reflow when the tool settles.
266
279
  if (phase === "live") continue;
267
280
  out.push(
268
- renderToolLine(theme, phase, part.name, part.argsPreview, width, now),
281
+ renderToolLine(
282
+ theme,
283
+ phase,
284
+ part.name,
285
+ part.argsPreview,
286
+ width,
287
+ now,
288
+ cwd,
289
+ ),
269
290
  );
270
291
  }
271
292
  }
@@ -278,6 +299,7 @@ function renderToolResultItem(
278
299
  width: number,
279
300
  paired: boolean,
280
301
  now: number,
302
+ cwd?: string,
281
303
  ) {
282
304
  const preview = firstOutputPreview(item.outputPreview);
283
305
  // An orphan result (its call is not the previous item) still needs a glyph:
@@ -293,11 +315,11 @@ function renderToolResultItem(
293
315
  now,
294
316
  ),
295
317
  ...(preview
296
- ? [renderOutputLine(theme, item.isError, preview, width)]
318
+ ? [renderOutputLine(theme, item.isError, preview, width, cwd)]
297
319
  : []),
298
320
  ];
299
321
  }
300
- return [renderOutputLine(theme, item.isError, preview, width)];
322
+ return [renderOutputLine(theme, item.isError, preview, width, cwd)];
301
323
  }
302
324
 
303
325
  function isPairedToolResult(
@@ -321,12 +343,13 @@ function renderTranscriptItem(
321
343
  width: number,
322
344
  context: ItemContext,
323
345
  now: number,
346
+ cwd?: string,
324
347
  ) {
325
348
  if (item.kind === "user") return renderUserText(theme, item.text, width);
326
349
  if (item.kind === "assistant") {
327
- return renderAssistantItem(theme, item, width, context.phases, now);
350
+ return renderAssistantItem(theme, item, width, context.phases, now, cwd);
328
351
  }
329
- return renderToolResultItem(theme, item, width, context.paired, now);
352
+ return renderToolResultItem(theme, item, width, context.paired, now, cwd);
330
353
  }
331
354
 
332
355
  interface ItemContext {
@@ -413,7 +436,8 @@ export class TranscriptRenderer {
413
436
  const key = `${width}|${context.token}`;
414
437
  const cached = this.itemCache.get(item)?.get(key);
415
438
  const lines =
416
- cached ?? renderTranscriptItem(theme, item, width, context, now);
439
+ cached ??
440
+ renderTranscriptItem(theme, item, width, context, now, snap.cwd);
417
441
  if (!cached) {
418
442
  const widths = this.itemCache.get(item) ?? new Map<string, string[]>();
419
443
  if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) {
@@ -456,11 +480,21 @@ export class TranscriptRenderer {
456
480
  : "ok"
457
481
  : "live";
458
482
  out.push(
459
- renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now),
483
+ renderToolLine(
484
+ theme,
485
+ phase,
486
+ tool.name,
487
+ tool.argsPreview,
488
+ width,
489
+ now,
490
+ snap.cwd,
491
+ ),
460
492
  );
461
493
  const preview = firstOutputPreview(tool.outputPreview);
462
494
  if (preview)
463
- out.push(renderOutputLine(theme, !!tool.isError, preview, width));
495
+ out.push(
496
+ renderOutputLine(theme, !!tool.isError, preview, width, snap.cwd),
497
+ );
464
498
  }
465
499
 
466
500
  // Queued steering/follow-up messages: show them immediately so Enter
@@ -26,7 +26,7 @@ export function buildWaitResultPreview(
26
26
  const results = details?.results ?? [];
27
27
  const failed = results.filter((result) => result.status === "error").length;
28
28
  const header =
29
- theme.fg(failed > 0 ? "warning" : "success", "") +
29
+ theme.fg(failed > 0 ? "warning" : "success", failed > 0 ? "!" : "✓") +
30
30
  ` ${theme.fg("accent", theme.bold(`${results.length} subagent${results.length === 1 ? "" : "s"} settled`))}` +
31
31
  (failed > 0 ? theme.fg("error", ` · ${failed} failed`) : "");
32
32
  const lines = [header];