@bermudi/pi-delegate 0.1.13 → 0.1.14

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.
@@ -12,10 +12,17 @@ import {
12
12
  spinnerFrame,
13
13
  formatToolCallShort,
14
14
  previewOutputLine,
15
+ taskTokenLabel,
15
16
  waitingLabel,
16
17
  } from "./format.ts";
17
- import { stripAnsi, resolveCarriageReturn } from "./utils.ts";
18
+ import {
19
+ resolveCarriageReturn,
20
+ sanitizeTerminalLine,
21
+ sanitizeTerminalText,
22
+ stripAnsi,
23
+ } from "./utils.ts";
18
24
  import { getMaxConcurrent } from "./config.ts";
25
+ import { toolExpandHint } from "./key-hints.ts";
19
26
  import type { TaskProgress, TaskResult } from "./types.ts";
20
27
 
21
28
  /**
@@ -24,7 +31,7 @@ import type { TaskProgress, TaskResult } from "./types.ts";
24
31
  * so a single missing host hook cannot crash the renderer.
25
32
  */
26
33
  function renderOutputLines(raw: string, width: number): string[] {
27
- const trimmed = raw.trim();
34
+ const trimmed = sanitizeTerminalText(raw).trim();
28
35
  if (!trimmed) return [];
29
36
  try {
30
37
  if (typeof getMarkdownTheme !== "function") return trimmed.split("\n");
@@ -59,6 +66,8 @@ export interface BranchCtx {
59
66
  ticketId?: string;
60
67
  /** Async ticket status, when known, so the renderer can show cancelling/cancelled. */
61
68
  ticketStatus?: "running" | "cancelling" | "done" | "failed" | "cancelled";
69
+ /** Actual batch wall time when no live render state is available. */
70
+ elapsedMs?: number;
62
71
  }
63
72
 
64
73
  /** Helpers shared across both branches. `pushWarnings` mutates `lines`.
@@ -77,28 +86,48 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
77
86
  const { progress, total, w, expanded, state, theme, lines } = ctx;
78
87
  const { statJoin, modelLabel, compactActivity, pushWarnings } = h;
79
88
 
80
- const done = progress.filter(
89
+ const finished = progress.filter(
81
90
  (p) => p.status === "done" || p.status === "failed",
82
91
  ).length;
92
+ const failed = progress.filter((p) => p.status === "failed").length;
83
93
  const running = progress.filter((p) => p.status === "running").length;
84
- const elapsed = state.startedAt
85
- ? ` · ${fmtDuration(Date.now() - state.startedAt)}`
86
- : "";
94
+ const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
95
+ const hasIncomplete = progress.some((p) => p.incomplete !== undefined);
87
96
 
88
- // Richer header: agent counts + wall time. The Ctrl+O affordance is
89
- // tool-scoped in Pi, so a single header hint suffices one per running
90
- // task just repeats the same line N times.
97
+ // Keep the live and final summaries in the same order so the header remains
98
+ // easy to scan as a partial result resolves into its final form.
91
99
  const headerParts: string[] = [];
92
- if (ctx.ticketStatus === "cancelling") headerParts.push("CANCELLING");
93
100
  if (running > 0) headerParts.push(`${running} running`);
94
- headerParts.push(`${done}/${total} done`);
95
- if (!expanded && running > 0)
96
- headerParts.push(theme.fg("accent", "Ctrl+O for detail"));
97
- lines.push(theme.fg("muted", `${headerParts.join(" · ")}${elapsed}`), "");
101
+ headerParts.push(`${finished}/${total} finished`);
102
+ if (failed > 0) headerParts.push(`${failed} failed`);
103
+ headerParts.push(
104
+ hasIncomplete
105
+ ? `≥${fmtTokens(totalTokens)} tokens · incomplete accounting/evidence`
106
+ : `${fmtTokens(totalTokens)} tokens`,
107
+ );
108
+ if (state.startedAt)
109
+ headerParts.push(fmtDuration(Date.now() - state.startedAt));
110
+ const stateLabel =
111
+ ctx.ticketStatus === "cancelling"
112
+ ? `${theme.fg("error", "■ cancelling")} · `
113
+ : "";
114
+ const expandHint = toolExpandHint();
115
+ const detailHint =
116
+ !expanded && running > 0 && expandHint
117
+ ? ` · ${theme.fg("accent", expandHint)}`
118
+ : "";
119
+ lines.push(
120
+ `${stateLabel}${theme.fg("muted", headerParts.join(" · "))}${detailHint}`,
121
+ "",
122
+ );
98
123
 
99
124
  for (let i = 0; i < total; i++) {
100
125
  const p = progress[i]!;
101
126
  const ind = indent(i, total);
127
+ const agent = sanitizeTerminalLine(p.agent);
128
+ const task = sanitizeTerminalLine(p.task);
129
+ const taskId = formatTaskId(p.id);
130
+ const taskIdTag = taskId ? theme.fg("accent", taskId) : "";
102
131
  const runParts: string[] = [];
103
132
  if (p.toolUses > 0)
104
133
  runParts.push(`${p.toolUses} tool${p.toolUses > 1 ? "s" : ""}`);
@@ -108,18 +137,20 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
108
137
  case "done":
109
138
  lines.push(
110
139
  truncLine(
111
- `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
140
+ `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` ${task}`)}${expanded ? `${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)])}` : ""}`,
112
141
  w,
113
142
  ),
114
143
  );
115
144
  if (expanded) {
116
145
  for (const activity of p.activities.slice(-3)) {
117
- const call = formatToolCallShort(activity.name, activity.args);
146
+ const call = sanitizeTerminalLine(
147
+ formatToolCallShort(activity.name, activity.args),
148
+ );
118
149
  const icon = activity.result?.isError
119
150
  ? theme.fg("error", "✗")
120
151
  : theme.fg("success", "✓");
121
152
  lines.push(
122
- truncLine(`${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`, w),
153
+ truncLine(`${ind}${theme.fg("dim", `→ ${call}`)} ${icon}`, w),
123
154
  );
124
155
  }
125
156
  }
@@ -127,18 +158,20 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
127
158
  case "failed":
128
159
  lines.push(
129
160
  truncLine(
130
- `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
161
+ `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` ${task}`)}${expanded ? modelLabel(p) : ""}${p.error ? theme.fg("error", ` · ${sanitizeTerminalLine(p.error)}`) : ""}`,
131
162
  w,
132
163
  ),
133
164
  );
134
165
  if (expanded) {
135
166
  for (const activity of p.activities.slice(-3)) {
136
- const call = formatToolCallShort(activity.name, activity.args);
167
+ const call = sanitizeTerminalLine(
168
+ formatToolCallShort(activity.name, activity.args),
169
+ );
137
170
  const icon = activity.result?.isError
138
171
  ? theme.fg("error", "✗")
139
172
  : theme.fg("success", "✓");
140
173
  lines.push(
141
- truncLine(`${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`, w),
174
+ truncLine(`${ind}${theme.fg("dim", `→ ${call}`)} ${icon}`, w),
142
175
  );
143
176
  }
144
177
  }
@@ -159,7 +192,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
159
192
  const glyph = theme.fg("warning", spinnerFrame());
160
193
  lines.push(
161
194
  truncLine(
162
- `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin(runParts)}${issueTag}${theme.fg("muted", ageTag)}`,
195
+ `${tree(i, total)} ${glyph} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` ${task}`)}${expanded ? `${modelLabel(p)}${statJoin(runParts)}` : ""}${issueTag}${theme.fg("dim", ageTag)}`,
163
196
  w,
164
197
  ),
165
198
  );
@@ -168,24 +201,27 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
168
201
  // ── Expanded: recent activity history (like done/failed) ──
169
202
  if (p.activities.length > 0) {
170
203
  for (const activity of p.activities.slice(-5)) {
171
- const call = formatToolCallShort(activity.name, activity.args);
204
+ const call = sanitizeTerminalLine(
205
+ formatToolCallShort(activity.name, activity.args),
206
+ );
172
207
  if (!activity.result) {
173
208
  // In-flight
174
209
  const elapsed = ` | ${fmtDuration(Date.now() - activity.startTime)}`;
175
210
  lines.push(
176
211
  truncLine(
177
- `${ind}${theme.fg("warning", `> ${call}${elapsed}`)}`,
212
+ `${ind}${theme.fg("warning", "›")} ${call}${theme.fg("dim", elapsed)}`,
178
213
  w,
179
214
  ),
180
215
  );
181
216
  // Show live stdout/stderr preview for streaming tools
182
217
  if (activity.liveOutput) {
183
- const clean = stripAnsi(
184
- resolveCarriageReturn(activity.liveOutput),
218
+ const clean = resolveCarriageReturn(
219
+ stripAnsi(activity.liveOutput),
185
220
  );
186
221
  const preview = clean
187
222
  .split("\n")
188
- .filter((l) => l.trim())
223
+ .map(sanitizeTerminalLine)
224
+ .filter(Boolean)
189
225
  .slice(-3);
190
226
  for (const outLine of preview) {
191
227
  lines.push(
@@ -202,7 +238,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
202
238
  : theme.fg("success", "✓");
203
239
  lines.push(
204
240
  truncLine(
205
- `${ind}${theme.fg("muted", `→ ${call}`)} ${icon}`,
241
+ `${ind}${theme.fg("dim", `→ ${call}`)} ${icon}`,
206
242
  w,
207
243
  ),
208
244
  );
@@ -210,16 +246,16 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
210
246
  }
211
247
  } else {
212
248
  lines.push(
213
- truncLine(`${ind}${theme.fg("muted", " thinking…")}`, w),
249
+ truncLine(`${ind}${theme.fg("dim", " thinking…")}`, w),
214
250
  );
215
251
  }
216
252
  } else {
217
253
  // ── Collapsed: compact tool line with duration ─────
218
- // The Ctrl+O affordance lives once in the header now — emitting
219
- // it per task just repeats the same hint for every running agent.
254
+ // The host-configured expand affordance lives once in the header;
255
+ // emitting it per task repeats the hint for every running agent.
220
256
  lines.push(
221
257
  truncLine(
222
- `${ind}${theme.fg("muted", `⎿ ${compactActivity(p)}`)}`,
258
+ `${ind}${theme.fg("warning", "›")} ${sanitizeTerminalLine(compactActivity(p))}`,
223
259
  w,
224
260
  ),
225
261
  );
@@ -235,7 +271,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
235
271
  );
236
272
  lines.push(
237
273
  truncLine(
238
- `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)} ${queuedTag}`,
274
+ `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` ${task}`)}${expanded ? modelLabel(p) : ""}${queuedTag}`,
239
275
  w,
240
276
  ),
241
277
  );
@@ -245,6 +281,18 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
245
281
  }
246
282
  }
247
283
 
284
+ function hasUserCancellationMarker(
285
+ progress: TaskProgress,
286
+ result: TaskResult | { error: string } | undefined,
287
+ ): boolean {
288
+ if (progress.failureKind === "cancelled") return true;
289
+ return (
290
+ result !== undefined &&
291
+ "failureKind" in result &&
292
+ result.failureKind === "cancelled"
293
+ );
294
+ }
295
+
248
296
  /** Final (or async-ticket poll) view — static status glyphs, output previews,
249
297
  * markdown rendering in expanded mode, and the ticket banner for live
250
298
  * background tickets. */
@@ -259,11 +307,20 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
259
307
  // this branch must render every status. A ticket banner is shown when
260
308
  // details.ticketId is present so the human sees this is background work.
261
309
  const succeeded = progress.filter((p) => p.status === "done").length;
262
- const failed = progress.filter((p) => p.status === "failed").length;
263
- const finalized = succeeded + failed;
310
+ const cancelled = progress.filter(
311
+ (p, index) =>
312
+ p.status === "failed" && hasUserCancellationMarker(p, taskResults[index]),
313
+ ).length;
314
+ const failed = progress.filter(
315
+ (p, index) =>
316
+ p.status === "failed" &&
317
+ !hasUserCancellationMarker(p, taskResults[index]),
318
+ ).length;
319
+ const finalized = succeeded + failed + cancelled;
264
320
  const running = progress.filter((p) => p.status === "running").length;
265
321
  const pending = progress.filter((p) => p.status === "pending").length;
266
322
  const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
323
+ const hasIncomplete = progress.some((p) => p.incomplete !== undefined);
267
324
  const ticketId = ctx.ticketId;
268
325
  const ticketStatus = ctx.ticketStatus;
269
326
  // A terminal ticket can retain a stale running/pending row while its workers
@@ -273,29 +330,71 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
273
330
  ticketStatus === undefined ||
274
331
  ticketStatus === "running" ||
275
332
  ticketStatus === "cancelling";
276
- const elapsed = state.startedAt
277
- ? fmtDuration(Date.now() - state.startedAt)
278
- : fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
333
+ // Terminal tickets render stale running/pending rows with a terminal glyph.
334
+ // A caller-aborted task retains a structured cancellation marker after
335
+ // settling; count it as cancelled rather than as a generic failure. Provider
336
+ // failures whose human-facing text happens to be "Aborted" remain failures.
337
+ const terminalUnfinished = ticketIsLive ? 0 : running + pending;
338
+ const terminalFinalized = finalized + terminalUnfinished;
339
+ const terminalCancelled =
340
+ cancelled + (ticketStatus === "cancelled" ? terminalUnfinished : 0);
341
+ const terminalFailed =
342
+ failed + (ticketStatus === "cancelled" ? 0 : terminalUnfinished);
343
+ const ticketWasCancelled = ticketStatus === "cancelled";
344
+ let elapsed: string | undefined;
345
+ if (ctx.elapsedMs !== undefined) {
346
+ elapsed = fmtDuration(ctx.elapsedMs);
347
+ } else if (state.startedAt !== undefined) {
348
+ elapsed = fmtDuration(Date.now() - state.startedAt);
349
+ }
279
350
 
351
+ const safeTicketId = ticketId ? sanitizeTerminalLine(ticketId) : "";
352
+ const ticketLabel = safeTicketId ? `ticket ${safeTicketId} · ` : "";
353
+ const expandHint = toolExpandHint();
354
+ const detailHint =
355
+ !expanded && expandHint ? ` · ${theme.fg("accent", expandHint)}` : "";
280
356
  if (ticketId && ticketIsLive) {
281
357
  // Background ticket — frame it as in-progress, not a finished result.
282
- const ticketParts = [
283
- `ticket ${ticketId}`,
284
- `${finalized}/${total} finalized`,
285
- ];
358
+ const ticketParts = [`${finalized}/${total} finished`];
286
359
  if (running > 0) ticketParts.push(`${running} active`);
287
360
  if (pending > 0) ticketParts.push(`${pending} queued`);
288
361
  if (failed > 0) ticketParts.push(`${failed} failed`);
289
- ticketParts.push(
290
- ticketStatus === "cancelling" ? "CANCELLING" : "running in background",
362
+ if (cancelled > 0) ticketParts.push(`${cancelled} cancelled`);
363
+ const glyph =
364
+ ticketStatus === "cancelling"
365
+ ? theme.fg("error", "■")
366
+ : theme.fg("warning", "◐");
367
+ const stateLabel =
368
+ ticketStatus === "cancelling"
369
+ ? ` ${theme.fg("error", "cancelling")}`
370
+ : "";
371
+ lines.push(
372
+ `${glyph}${stateLabel} ${theme.fg("muted", `${ticketLabel}${ticketParts.join(" · ")}`)}${detailHint}`,
373
+ "",
291
374
  );
292
- lines.push(theme.fg("warning", `⏳ ${ticketParts.join(" · ")}`), "");
293
375
  } else {
376
+ const headerParts = [`${terminalFinalized}/${total} finished`];
377
+ if (terminalFailed > 0) headerParts.push(`${terminalFailed} failed`);
378
+ if (terminalCancelled > 0)
379
+ headerParts.push(`${terminalCancelled} cancelled`);
380
+ // Cancellation is a ticket-level outcome even when every worker returned a
381
+ // normal terminal row. Keep it distinct from actual failed-row counts.
382
+ if (ticketWasCancelled) headerParts.push("ticket cancelled");
383
+ headerParts.push(
384
+ hasIncomplete
385
+ ? `≥${fmtTokens(totalTokens)} tokens · incomplete accounting/evidence`
386
+ : `${fmtTokens(totalTokens)} tokens`,
387
+ );
388
+ if (elapsed) headerParts.push(elapsed);
389
+ const glyph =
390
+ ticketStatus === "cancelled" ||
391
+ ticketStatus === "failed" ||
392
+ terminalFailed > 0 ||
393
+ terminalCancelled > 0
394
+ ? theme.fg("error", "✗")
395
+ : theme.fg("success", "✓");
294
396
  lines.push(
295
- theme.fg(
296
- "muted",
297
- `${succeeded}/${total} completed · ${elapsed} wall · ${fmtTokens(totalTokens)} tokens`,
298
- ),
397
+ `${glyph} ${theme.fg("muted", `${ticketLabel}${headerParts.join(" · ")}`)}${detailHint}`,
299
398
  "",
300
399
  );
301
400
  }
@@ -304,54 +403,70 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
304
403
  const p = progress[i]!;
305
404
  const r = taskResults[i];
306
405
  const ind = indent(i, total);
307
- const isCancelledPending =
308
- ticketStatus === "cancelled" && p.status === "pending";
406
+ const agent = sanitizeTerminalLine(p.agent);
407
+ const task = sanitizeTerminalLine(p.task);
408
+ const isTerminalUnfinished =
409
+ !ticketIsLive && (p.status === "running" || p.status === "pending");
410
+ const isCancelledUnfinished =
411
+ ticketStatus === "cancelled" && isTerminalUnfinished;
412
+ const isCancelledResult =
413
+ p.status === "failed" && hasUserCancellationMarker(p, r);
414
+ const isCancelledTask = isCancelledUnfinished || isCancelledResult;
309
415
 
310
- // Unified status glyphs: ✓ done, ✗ failed, ◐ running, ○ pending.
311
- // Cancelled-but-not-started tasks show as failed so they are not mistaken
312
- // for still-queued work.
313
- const icon = isCancelledPending
314
- ? theme.fg("error", "✗")
315
- : p.status === "done"
316
- ? theme.fg("success", "")
317
- : p.status === "failed"
318
- ? theme.fg("error", "")
319
- : p.status === "running"
320
- ? theme.fg("warning", "")
321
- : theme.fg("muted", "");
322
- const taskId = p.id ? formatTaskId(p.id) : "";
323
- const taskIdTag = p.id ? theme.fg("accent", taskId) : "";
324
- const taskIdWidth = p.id ? taskId.length : 0;
416
+ // Unified status glyphs: ✓ done, ✗ failed/cancelled, ◐ running, ○ pending.
417
+ // Terminal unfinished rows never retain a live glyph; their tail identifies
418
+ // cancellation separately from failure.
419
+ let icon: string;
420
+ if (isTerminalUnfinished) {
421
+ icon = theme.fg("error", "");
422
+ } else if (p.status === "done") {
423
+ icon = theme.fg("success", "");
424
+ } else if (p.status === "failed") {
425
+ icon = theme.fg("error", "");
426
+ } else if (p.status === "running") {
427
+ icon = theme.fg("warning", "");
428
+ } else {
429
+ icon = theme.fg("muted", "");
430
+ }
431
+ const taskId = formatTaskId(p.id);
432
+ const taskIdTag = taskId ? theme.fg("accent", taskId) : "";
433
+ const taskIdWidth = taskId.length;
325
434
  const previewBudget = Math.max(1, w - 30 - taskIdWidth);
326
- const taskPreview = theme.fg("muted", trunc(p.task, previewBudget));
435
+ const taskPreview = theme.fg("muted", ` — ${trunc(task, previewBudget)}`);
327
436
  const isLive =
328
- ticketIsLive &&
329
- (p.status === "running" ||
330
- (p.status === "pending" && !isCancelledPending));
437
+ ticketIsLive && (p.status === "running" || p.status === "pending");
331
438
  // Live tasks show an activity/waiting hint instead of final stats.
332
439
  const liveTail =
333
- p.status === "running"
334
- ? theme.fg("muted", ` · ${compactActivity(p)}`)
335
- : p.status === "pending" && !isCancelledPending
336
- ? theme.fg("muted", ` ${waitingLabel(running, getMaxConcurrent())}`)
337
- : "";
338
- const cancelledTail = isCancelledPending
440
+ p.status === "pending"
441
+ ? theme.fg("muted", ` ${waitingLabel(running, getMaxConcurrent())}`)
442
+ : "";
443
+ const cancelledTail = isCancelledTask
339
444
  ? theme.fg("error", " · CANCELLED")
340
445
  : "";
341
446
  lines.push(
342
447
  truncLine(
343
- `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)}${taskIdTag} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
448
+ `${tree(i, total)} ${icon} ${theme.bold(agent)}${taskIdTag}${taskPreview}${expanded ? modelLabel(p) : ""}${isLive ? liveTail : cancelledTail || (expanded ? statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)]) : "")}`,
344
449
  w,
345
450
  ),
346
451
  );
347
452
 
453
+ if (isLive && p.status === "running") {
454
+ lines.push(
455
+ truncLine(
456
+ `${ind}${theme.fg("warning", "›")} ${sanitizeTerminalLine(compactActivity(p))}`,
457
+ w,
458
+ ),
459
+ );
460
+ }
461
+
348
462
  // Warnings (e.g. unknown tools ignored) — muted line under the task.
349
463
  pushWarnings(p, ind);
350
464
 
351
465
  // Tool activities: compact summary only in expanded mode, terminal tasks only.
352
466
  if (p.activities.length > 0 && expanded && !isLive) {
353
467
  const names = p.activities
354
- .map((a) => a.name)
468
+ .map((a) => sanitizeTerminalLine(a.name))
469
+ .filter(Boolean)
355
470
  .filter((n, i, arr) => arr.indexOf(n) === i);
356
471
  const nameList =
357
472
  names.slice(0, 4).join(", ") +
@@ -366,17 +481,31 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
366
481
  const status = statusParts.length ? ` · ${statusParts.join(", ")}` : "";
367
482
  lines.push(
368
483
  truncLine(
369
- `${ind}${theme.fg("muted", `${p.activities.length} tool${p.activities.length > 1 ? "s" : ""}: ${nameList}${status}`)}`,
484
+ `${ind}${theme.fg("dim", `${p.activities.length} tool${p.activities.length > 1 ? "s" : ""}: ${nameList}${status}`)}`,
485
+ w,
486
+ ),
487
+ );
488
+ }
489
+
490
+ if (
491
+ !isLive &&
492
+ ((r && "incomplete" in r && r.incomplete === "quiescence_abandoned") ||
493
+ p.incomplete === "quiescence_abandoned")
494
+ ) {
495
+ lines.push(
496
+ truncLine(
497
+ `${ind}${theme.fg("warning", "INCOMPLETE: accounting and output/file evidence are lower bounds; session quarantined")}`,
370
498
  w,
371
499
  ),
372
500
  );
373
501
  }
374
502
 
375
503
  // Surface errors even when output exists (agent may have emitted text before failing).
376
- // Live tasks and cancelled-but-not-started tasks already show their status
377
- // on the row, so don't duplicate it as an error line.
378
- if (!isLive && !isCancelledPending && r && "error" in r && r.error) {
379
- lines.push(truncLine(`${ind}${theme.fg("error", r.error)}`, w));
504
+ // Live and cancelled tasks already show their status on the row, so don't
505
+ // duplicate it as an error line.
506
+ if (!isLive && !isCancelledTask && r && "error" in r && r.error) {
507
+ const error = sanitizeTerminalLine(r.error);
508
+ if (error) lines.push(truncLine(`${ind}${theme.fg("error", error)}`, w));
380
509
  }
381
510
  if (r && "integration" in r && r.integration) {
382
511
  const integration = r.integration;
@@ -395,17 +524,33 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
395
524
  if (integration.patchPath)
396
525
  lines.push(
397
526
  truncLine(
398
- `${ind}${theme.fg("muted", `patch: ${integration.patchPath}`)}`,
527
+ `${ind}${theme.fg("dim", `patch: ${sanitizeTerminalLine(integration.patchPath)}`)}`,
399
528
  w,
400
529
  ),
401
530
  );
402
531
  if (integration.worktreePath)
403
532
  lines.push(
404
533
  truncLine(
405
- `${ind}${theme.fg("muted", `worktree: ${integration.worktreePath}`)}`,
534
+ `${ind}${theme.fg("dim", `worktree: ${sanitizeTerminalLine(integration.worktreePath)}`)}`,
535
+ w,
536
+ ),
537
+ );
538
+ if (integration.cleanupIssue) {
539
+ lines.push(
540
+ truncLine(
541
+ `${ind}${theme.fg("error", `cleanup ${integration.cleanupIssue.status}: ${sanitizeTerminalLine(integration.cleanupIssue.reason)}`)}`,
406
542
  w,
407
543
  ),
408
544
  );
545
+ if (integration.cleanupIssue.recoveryPath) {
546
+ lines.push(
547
+ truncLine(
548
+ `${ind}${theme.fg("dim", `cleanup recovery: ${sanitizeTerminalLine(integration.cleanupIssue.recoveryPath)}`)}`,
549
+ w,
550
+ ),
551
+ );
552
+ }
553
+ }
409
554
  }
410
555
  }
411
556
  // Collapsed: one-line output preview so a human scanning the TUI sees
@@ -417,7 +562,9 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
417
562
  ? previewOutputLine(r.output ?? "", w - ind.length - 3)
418
563
  : "";
419
564
  if (preview) {
420
- lines.push(truncLine(`${ind}${theme.fg("muted", `⎿ ${preview}`)}`, w));
565
+ lines.push(
566
+ truncLine(`${ind}${theme.fg("success", "⎿")} ${preview}`, w),
567
+ );
421
568
  }
422
569
  }
423
570
  // Output: render markdown only in expanded mode.