@bermudi/pi-delegate 0.1.13 → 0.1.15

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