@oh-my-pi/pi-tui 18.2.11 → 18.3.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/CHANGELOG.md +20 -0
  2. package/THIRD-PARTY-NOTICES.txt +1 -1
  3. package/dist/types/apps/ps-data.d.ts +1 -1
  4. package/dist/types/apps/ps-top.d.ts +1 -1
  5. package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
  6. package/dist/types/overlays/annotation-overlay.d.ts +30 -0
  7. package/dist/types/overlays/annotation-types.d.ts +84 -0
  8. package/dist/types/overlays/copy-selector.d.ts +30 -2
  9. package/dist/types/overlays/copy-targets.d.ts +2 -0
  10. package/dist/types/overlays/model-hub.d.ts +2 -2
  11. package/dist/types/overlays/oauth-selector.d.ts +3 -6
  12. package/dist/types/overlays/usage-dashboard.d.ts +2 -0
  13. package/dist/types/prompt/image-source.d.ts +9 -8
  14. package/dist/types/status-line/context-usage.d.ts +3 -1
  15. package/dist/types/theme/mermaid-cache.d.ts +4 -6
  16. package/dist/types/tools/bash.d.ts +7 -0
  17. package/dist/types/tools/daemon.d.ts +46 -0
  18. package/dist/types/tools/irc.d.ts +30 -0
  19. package/dist/types/tools/line-ranges.d.ts +1 -1
  20. package/dist/types/tools/proc-render.d.ts +34 -0
  21. package/dist/types/tools/read.d.ts +4 -1
  22. package/dist/types/tools/wait.d.ts +131 -0
  23. package/dist/types/tools/write.d.ts +4 -0
  24. package/dist/types/tools/xd-url.d.ts +8 -0
  25. package/dist/types/windows-altgr.d.ts +54 -0
  26. package/package.json +13 -10
  27. package/src/apps/debug/protocol-probe.ts +1 -1
  28. package/src/apps/ps-data.ts +1 -1
  29. package/src/apps/ps-top.ts +1 -1
  30. package/src/chat/chat-transcript-builder.ts +4 -4
  31. package/src/chat/tool-execution.ts +8 -8
  32. package/src/chat/transcript-render-helpers.ts +3 -3
  33. package/src/components/markdown.ts +1 -1
  34. package/src/desktop-notify.ts +1 -1
  35. package/src/overlays/annotation-overlay.ts +1145 -0
  36. package/src/overlays/annotation-types.ts +78 -0
  37. package/src/overlays/copy-selector.ts +45 -19
  38. package/src/overlays/copy-targets.ts +3 -3
  39. package/src/overlays/model-hub.ts +5 -4
  40. package/src/overlays/oauth-selector.ts +8 -12
  41. package/src/overlays/plan-review-overlay.ts +213 -40
  42. package/src/overlays/usage-dashboard.ts +22 -1
  43. package/src/prompt/image-source.ts +9 -8
  44. package/src/setup/scenes/sign-in.ts +1 -1
  45. package/src/status-line/context-usage.ts +15 -3
  46. package/src/terminal-capabilities.ts +2 -2
  47. package/src/terminal.ts +8 -1
  48. package/src/theme/mermaid-cache.ts +83 -25
  49. package/src/tools/bash.ts +14 -1
  50. package/src/tools/daemon.ts +51 -0
  51. package/src/tools/index.ts +2 -2
  52. package/src/tools/irc.ts +33 -0
  53. package/src/tools/line-ranges.ts +13 -3
  54. package/src/tools/proc-render.ts +294 -0
  55. package/src/tools/read.ts +22 -1
  56. package/src/tools/wait.ts +595 -0
  57. package/src/tools/write.ts +76 -0
  58. package/src/tools/xd-url.ts +9 -0
  59. package/src/windows-altgr.ts +227 -0
  60. package/dist/types/tools/hub.d.ts +0 -342
  61. package/src/tools/hub.ts +0 -1492
package/src/tools/read.ts CHANGED
@@ -4,7 +4,7 @@ import { LINE_RANGE_CHUNK_SOURCE, parseLineRanges } from "./line-ranges";
4
4
  import * as path from "node:path";
5
5
  import type { Component } from "../tui";
6
6
  import { Text } from "../components/text";
7
- import type { RenderResultOptions, ToolRenderer } from "./renderer";
7
+ import type { RenderResultOptions, ToolActivityContext, ToolActivitySummary, ToolRenderer } from "./renderer";
8
8
  import { getLanguageFromPath } from "../lang-from-path";
9
9
  import type { Theme } from "../theme/theme";
10
10
  import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine } from "../render";
@@ -16,6 +16,7 @@ import { formatBytes, sanitizeDisplayLines, shortenPath, wrapBrackets } from "..
16
16
 
17
17
  import type { OutputMeta } from "./output-meta";
18
18
  import type { TruncationResult } from "./streaming-output";
19
+ import { renderProcRead, type ProcReadDetails } from "./proc-render";
19
20
 
20
21
  /** Read result metadata retains truncation statistics, not a second copy of the body. */
21
22
  export type ReadTruncationStats = Omit<TruncationResult, "content">;
@@ -23,6 +24,7 @@ export type ReadTruncationStats = Omit<TruncationResult, "content">;
23
24
  /** Display metadata for file and URL reads. */
24
25
  export interface ReadToolDetails {
25
26
  kind?: "file" | "url";
27
+ proc?: ProcReadDetails;
26
28
  /** Filesystem hyperlink target resolved by the executing tool. */
27
29
  displayTarget?: string;
28
30
  truncation?: ReadTruncationStats;
@@ -91,6 +93,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
91
93
  memory: true,
92
94
  omp: true,
93
95
  pr: true,
96
+ proc: true,
94
97
  rule: true,
95
98
  security: true,
96
99
  skill: true,
@@ -266,9 +269,19 @@ function formatReadPathLink(
266
269
 
267
270
  /** Render file, image, and URL reads in the transcript. */
268
271
  export const readToolRenderer = {
272
+ activitySummary(args: unknown, _context: ToolActivityContext): ToolActivitySummary {
273
+ const input = args as ReadRenderArgs | undefined;
274
+ const rawPath =
275
+ typeof input?.file_path === "string" ? input.file_path : typeof input?.path === "string" ? input.path : "";
276
+ if (/^proc:\/\//i.test(rawPath))
277
+ return { label: "Process", detail: rawPath.slice("proc://".length) || "jobs & services" };
278
+ return { label: "Read", detail: shortenPath(rawPath) };
279
+ },
269
280
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
270
281
  const rawPath =
271
282
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
283
+ if (/^proc:\/\//i.test(rawPath))
284
+ return renderProcRead(rawPath.slice("proc://".length), undefined, undefined, _options, uiTheme);
272
285
  if (isReadableUrlPath(rawPath)) {
273
286
  return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
274
287
  }
@@ -296,6 +309,14 @@ export const readToolRenderer = {
296
309
  const urlDetails = result.details as ReadUrlToolDetails | undefined;
297
310
  const baseRawPathForKind =
298
311
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
312
+ if (/^proc:\/\//i.test(baseRawPathForKind))
313
+ return renderProcRead(
314
+ baseRawPathForKind.slice("proc://".length),
315
+ result,
316
+ result.details?.proc,
317
+ options,
318
+ uiTheme,
319
+ );
299
320
  if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
300
321
  return renderReadUrlResult(
301
322
  result as {
@@ -0,0 +1,595 @@
1
+ import type { Component } from "../tui";
2
+ import { Text } from "../components/text";
3
+ import { visibleWidth } from "../utils";
4
+ import { formatAge } from "@oh-my-pi/pi-utils";
5
+ import { shimmerEnabled, shimmerText } from "../theme/shimmer";
6
+ import type { Theme } from "../theme/theme";
7
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../render/index";
8
+ import { formatArtifactErrorNotice, stripOutputNotice, type OutputMeta } from "./output-meta";
9
+ import {
10
+ FEED_MODEL_BADGE_WIDTH,
11
+ formatBadge,
12
+ formatDuration,
13
+ formatEmptyMessage,
14
+ formatFeedModelBadge,
15
+ formatStatusIcon,
16
+ getPreviewLines,
17
+ isFeedModelBadgeEnabled,
18
+ PREVIEW_LIMITS,
19
+ replaceTabs,
20
+ type ToolUIColor,
21
+ type ToolUIStatus,
22
+ cappedHeadLines,
23
+ createCachedComponent,
24
+ type ConfiguredThinkingLevel,
25
+ } from "../render/render-utils";
26
+ import type { StructuredSubagentOutput } from "./task";
27
+ import type { RenderResultOptions, ToolRenderer, ToolActivitySummary } from "./renderer";
28
+ import type { IrcDeliveryReceipt, IrcMessage } from "./irc";
29
+
30
+ /** Whether a wait snapshot contains only running jobs and no cancellations. */
31
+ export function isWaitingPollDetails(details: unknown): boolean {
32
+ const d = details as CoordinationDetails | undefined;
33
+ if (!d || !Array.isArray(d.jobs) || d.jobs.length === 0) return false;
34
+ if (d.cancelled?.length) return false;
35
+ return d.jobs.every(job => job?.status === "running");
36
+ }
37
+ /** Coordination details retained by wait and background job operations. */
38
+ export type CoordinationOp = "send" | "wait" | "jobs" | "cancel";
39
+
40
+ /** Background-job row surfaced by `wait`/`cancel`/`jobs` results. */
41
+ export interface JobSnapshot {
42
+ id: string;
43
+ type: "bash" | "task" | "eval";
44
+ status: "running" | "completed" | "failed" | "cancelled";
45
+ label: string;
46
+ durationMs: number;
47
+ /** Process exit status when the job reports one. */
48
+ exitCode?: number;
49
+ /** Effective task model selector, including an explicit reasoning suffix when configured. */
50
+ resolvedModel?: string;
51
+ /** Provider/id including routing, with no added thinking suffix. */
52
+ resolvedModelIdentity?: string;
53
+ /** Explicit thinking metadata, independent of the model identity. */
54
+ resolvedThinkingLevel?: ConfiguredThinkingLevel;
55
+ /** True when the task progress reports an attached live advisor. */
56
+ advisor?: boolean;
57
+ resultText?: string;
58
+ errorText?: string;
59
+ /** Source-output metadata retained for per-job warnings and persisted row rendering. */
60
+ meta?: OutputMeta;
61
+ /** Capture error in historical snapshots; new snapshots store source metadata in `meta`. */
62
+ artifactError?: OutputMeta["artifactError"];
63
+ structured?: StructuredSubagentOutput;
64
+ /**
65
+ * `agent://<id>` handle backing this job's artifacts — the job-row's
66
+ * registry `agentId` when the manager disambiguated a requested job id
67
+ * on collision, else the job id itself. See {@link AsyncJob.agentId}.
68
+ */
69
+ agentUrlId?: string;
70
+ }
71
+
72
+ /** Outcome of cancelling one background job. */
73
+ export type CancelStatus = "cancelled" | "not_found" | "already_completed";
74
+
75
+ /** Cancellation outcome and its model-facing explanation. */
76
+ export interface CancelOutcome {
77
+ id: string;
78
+ status: CancelStatus;
79
+ message: string;
80
+ }
81
+
82
+ /**
83
+ * A live subagent from the AgentRegistry that has no backing job in the
84
+ * AsyncJobManager — e.g. an idle agent woken (or a parked agent revived) via
85
+ * a peer message, or a spawn owned by another agent. Surfaced by background
86
+ * snapshots so the displayed picture matches the UI's running-agent
87
+ * count.
88
+ */
89
+ export interface AgentActivitySnapshot {
90
+ id: string;
91
+ parentId?: string;
92
+ /** Latest activity gist recorded by the registry (display-only). */
93
+ activity?: string;
94
+ /** Time since the agent was registered. */
95
+ ageMs: number;
96
+ /**
97
+ * Whether an attached session corroborates the `running` claim. False marks
98
+ * a ref that says `running` with no turn in flight — either a spawn still
99
+ * wiring up or a stale registration that an explicit cancellation clears (#8634).
100
+ */
101
+ live: boolean;
102
+ /**
103
+ * Acceptance time of the agent's final result, when the registry recorded
104
+ * one. With `live: false` this is an accepted run the parent may cancel
105
+ * instead of waiting on (#11079).
106
+ */
107
+ acceptedAt?: number;
108
+ }
109
+
110
+ /** Details shared by messaging receipts and job/wait results. */
111
+ export interface CoordinationDetails {
112
+ meta?: OutputMeta;
113
+ op: CoordinationOp;
114
+ from?: string;
115
+ to?: string;
116
+ receipts?: IrcDeliveryReceipt[];
117
+ /** Message consumed by `wait`. */
118
+ waited?: IrcMessage | null;
119
+ jobs?: JobSnapshot[];
120
+ cancelled?: { id: string; status: CancelStatus }[];
121
+ /** Running subagents not represented by a job row in this result. */
122
+ agents?: AgentActivitySnapshot[];
123
+ /** `wait` was cut short by steering, a peer message, or a completion notice that injects next. */
124
+ interrupted?: boolean;
125
+ }
126
+
127
+ // =============================================================================
128
+ // TUI Renderer
129
+ // =============================================================================
130
+
131
+ const LABEL_MAX_WIDTH = 60;
132
+ const PREVIEW_LINES_COLLAPSED = 1;
133
+ const PREVIEW_LINES_EXPANDED = 4;
134
+ const LABEL_LINES_COLLAPSED = 1;
135
+ const LABEL_LINES_EXPANDED = 3;
136
+ const PREVIEW_LINE_WIDTH = 80;
137
+
138
+ function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus {
139
+ switch (status) {
140
+ case "completed":
141
+ return "done";
142
+ case "failed":
143
+ return "error";
144
+ case "cancelled":
145
+ return "aborted";
146
+ case "running":
147
+ return "running";
148
+ }
149
+ }
150
+
151
+ function statusToColor(status: JobSnapshot["status"]): ToolUIColor {
152
+ switch (status) {
153
+ case "completed":
154
+ return "success";
155
+ case "failed":
156
+ return "error";
157
+ case "cancelled":
158
+ return "warning";
159
+ case "running":
160
+ return "accent";
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Task job results are delivered in the model-facing `<task-result>` envelope
166
+ * (prompts/tools/task-summary.md) so the parent agent can parse status and the
167
+ * `agent://` pointer. The wrapper markup is noise to a human — preview the
168
+ * inner <output>/<preview> body instead.
169
+ */
170
+ function stripTaskResultEnvelope(text: string): string {
171
+ if (!text.startsWith("<task-result")) return text;
172
+ const body = /<(output|preview)(?:\s[^>]*)?>\n?([\s\S]*?)\n?<\/\1>/.exec(text)?.[2];
173
+ return body?.trim() || text;
174
+ }
175
+
176
+ /**
177
+ * Pretty-printed JSON output wastes the collapsed one-line preview on a lone
178
+ * "{" — flatten structured-looking bodies onto a single line. Slice first:
179
+ * downstream truncation keeps at most a few hundred columns, so collapsing
180
+ * whitespace across a multi-KB body would be pure waste.
181
+ */
182
+ function flattenStructuredPreview(text: string): string {
183
+ const first = text[0];
184
+ if (first !== "{" && first !== "[") return text;
185
+ return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
186
+ }
187
+
188
+ /** Pending wait frame. */
189
+ function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component {
190
+ return new Text(renderStatusLine({ icon: "pending", title: "Wait" }, uiTheme), 0, 0);
191
+ }
192
+
193
+ /** Result frame for wait snapshots and the agents roster. */
194
+ function jobsRenderResult(
195
+ result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
196
+ options: RenderResultOptions,
197
+ uiTheme: Theme,
198
+ ): Component {
199
+ let jobs = result.details?.jobs ?? [];
200
+ const agents = result.details?.agents ?? [];
201
+
202
+ if (jobs.length === 0 && agents.length === 0) {
203
+ const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
204
+ const header = renderStatusLine({ icon: "warning", title: "Wait" }, uiTheme);
205
+ return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
206
+ }
207
+
208
+ // Agent-carrying results (jobs snapshot / empty-wait roster) are real
209
+ // snapshots, not displaceable waiting frames — only agentless waits
210
+ // collapse their still-running rows once sealed.
211
+ if (!options.isPartial && agents.length === 0) {
212
+ jobs = jobs.filter(job => job.status !== "running");
213
+ if (jobs.length === 0) {
214
+ return new Text("", 0, 0);
215
+ }
216
+ }
217
+
218
+ const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
219
+ for (const job of jobs) counts[job.status]++;
220
+
221
+ // The title already carries the running count, so meta lists only the
222
+ // settled categories — "waiting on 19 of 19 · 19 running" read awkward.
223
+ const meta: string[] = [];
224
+ if (counts.completed > 0) meta.push(uiTheme.fg("success", `${counts.completed} done`));
225
+ if (counts.failed > 0) meta.push(uiTheme.fg("error", `${counts.failed} failed`));
226
+ if (counts.cancelled > 0) meta.push(uiTheme.fg("warning", `${counts.cancelled} cancelled`));
227
+ if (agents.length > 0 && jobs.length > 0) {
228
+ meta.push(uiTheme.fg("accent", `${agents.length} agent${agents.length === 1 ? "" : "s"}`));
229
+ }
230
+
231
+ const headerIcon: ToolUIStatus =
232
+ counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success";
233
+ const jobsNoun = jobs.length === 1 ? "job" : "jobs";
234
+ const description =
235
+ jobs.length === 0
236
+ ? `${agents.length} running agent${agents.length === 1 ? "" : "s"} — no jobs`
237
+ : counts.running > 0
238
+ ? counts.running === jobs.length
239
+ ? `waiting on ${jobs.length} ${jobsNoun}`
240
+ : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
241
+ : `${jobs.length} ${jobsNoun} settled`;
242
+
243
+ const header = renderStatusLine(
244
+ {
245
+ icon: headerIcon,
246
+ spinnerFrame: counts.running > 0 || agents.length > 0 ? options.spinnerFrame : undefined,
247
+ title: description,
248
+ meta,
249
+ },
250
+ uiTheme,
251
+ );
252
+
253
+ const outputMeta = result.details?.meta;
254
+ // Historical snapshots promoted a job failure to the root. Render it there
255
+ // only when no row identifies that failure; new report capture errors are distinct.
256
+ const aggregateArtifactError =
257
+ outputMeta?.artifactError &&
258
+ (outputMeta.source?.type === "report" ||
259
+ !jobs.some(job => (job.meta?.artifactError ?? job.artifactError) === outputMeta.artifactError))
260
+ ? outputMeta.artifactError
261
+ : undefined;
262
+
263
+ // Sort: running first (so user sees what's still pending), then failed, then completed/cancelled.
264
+ const statusOrder: Record<JobSnapshot["status"], number> = {
265
+ running: 0,
266
+ failed: 1,
267
+ cancelled: 2,
268
+ completed: 3,
269
+ };
270
+ const sortedJobs = [...jobs].sort((a, b) => {
271
+ const diff = statusOrder[a.status] - statusOrder[b.status];
272
+ if (diff !== 0) return diff;
273
+ return b.durationMs - a.durationMs;
274
+ });
275
+
276
+ let cached: RenderCache | undefined;
277
+ return {
278
+ render(width: number): readonly string[] {
279
+ const expanded = options.expanded;
280
+ const spinnerFrame = options.spinnerFrame ?? 0;
281
+ // Running-job labels shimmer while the wait block is live; the band
282
+ // phase is Date.now()-sampled at render time, so serving cached bytes
283
+ // would pin it to the ~12.5fps spinner-glyph cadence instead of the
284
+ // 30fps redraw. Bypass the cache while any row animates, and key on
285
+ // the animation state so a sealed block never hits stale shimmered
286
+ // bytes (spinnerFrame falls back to 0 on both sides of the seal).
287
+ const shimmerActive = counts.running > 0 && options.spinnerFrame !== undefined && shimmerEnabled();
288
+ const showModelBadge = isFeedModelBadgeEnabled();
289
+ const key = new Hasher()
290
+ .bool(expanded)
291
+ .u32(width)
292
+ .u32(spinnerFrame)
293
+ .bool(shimmerActive)
294
+ .bool(showModelBadge)
295
+ .digest();
296
+ if (!shimmerActive && cached?.key === key) return cached.lines;
297
+
298
+ const itemLines = renderTreeList<JobSnapshot>(
299
+ {
300
+ items: sortedJobs,
301
+ expanded,
302
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
303
+ itemType: "job",
304
+ renderItem: (job, context) => {
305
+ const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
306
+ const lines: string[] = [];
307
+ const icon = `${formatStatusIcon(
308
+ statusToIcon(job.status),
309
+ uiTheme,
310
+ job.status === "running" ? options.spinnerFrame : undefined,
311
+ )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
312
+ const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
313
+ const durationSuffix = `${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
314
+ const displayId = truncateToWidth(
315
+ replaceTabs(job.id).replace(/\s+/g, " "),
316
+ Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
317
+ Ellipsis.Unicode,
318
+ );
319
+ const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
320
+ const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
321
+ const visibleLabelLines = rawLabelLines
322
+ .slice(0, maxLabelLines)
323
+ .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
324
+ if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
325
+ const last = visibleLabelLines[visibleLabelLines.length - 1]!;
326
+ visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
327
+ }
328
+ const rowPrefix = `${icon} ${typeBadge} `;
329
+ const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
330
+ const modelBadge =
331
+ job.type === "task" && showModelBadge && typeof modelIdentity === "string"
332
+ ? formatFeedModelBadge(
333
+ modelIdentity,
334
+ job.resolvedThinkingLevel,
335
+ job.advisor === true,
336
+ uiTheme,
337
+ Math.min(
338
+ FEED_MODEL_BADGE_WIDTH,
339
+ Math.max(0, rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1),
340
+ ),
341
+ )
342
+ : "";
343
+ const modelLead = modelBadge ? `${modelBadge} ` : "";
344
+ const headRaw = displayId;
345
+ // Running rows in a live block shimmer their label; once the block
346
+ // stops animating (sealed, or a settled snapshot — spinnerFrame
347
+ // cleared) they render static so scrollback never keeps a mid-sweep
348
+ // shimmer band.
349
+ const live = job.status === "running" && options.spinnerFrame !== undefined;
350
+ const headLabel = live
351
+ ? shimmerEnabled()
352
+ ? shimmerText(headRaw, uiTheme)
353
+ : uiTheme.fg("accent", headRaw)
354
+ : uiTheme.fg("toolOutput", headRaw);
355
+ let row = `${rowPrefix}${modelLead}${headLabel}`;
356
+ const distinctLabel = job.label.trim() !== job.id;
357
+ const label = visibleLabelLines[0] ?? "";
358
+ const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
359
+ if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
360
+ row += durationSuffix;
361
+ lines.push(truncateToWidth(row, rowWidth, ""));
362
+ const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
363
+ for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
364
+ lines.push(
365
+ ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
366
+ );
367
+ }
368
+ const artifactError = job.meta?.artifactError ?? job.artifactError;
369
+ if (artifactError) {
370
+ lines.push(
371
+ uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth)),
372
+ );
373
+ }
374
+
375
+ // Legacy rows did not retain full metadata. Strip the known warning
376
+ // footer so the dedicated row/root warning remains the only copy.
377
+ const previewError =
378
+ artifactError ?? (outputMeta?.source?.type !== "report" ? outputMeta?.artifactError : undefined);
379
+ const previewMeta = job.meta ?? (previewError ? { artifactError: previewError } : undefined);
380
+
381
+ const preview = flattenStructuredPreview(
382
+ stripTaskResultEnvelope(
383
+ stripOutputNotice(job.errorText?.trim() || job.resultText?.trim() || "", previewMeta).trim(),
384
+ ),
385
+ );
386
+ if (preview) {
387
+ const maxLines = expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED;
388
+ const previewLines = getPreviewLines(
389
+ preview,
390
+ maxLines,
391
+ Math.min(PREVIEW_LINE_WIDTH, continuationWidth),
392
+ Ellipsis.Unicode,
393
+ );
394
+ const tone = job.errorText ? "error" : "dim";
395
+ for (const pl of previewLines) {
396
+ lines.push(` ${uiTheme.fg(tone, pl)}`);
397
+ }
398
+ }
399
+ return lines;
400
+ },
401
+ },
402
+ uiTheme,
403
+ );
404
+
405
+ // Agents run outside job control; render them as their own tree so
406
+ // they never skew the job counts or the "waiting on N jobs" title.
407
+ const agentLines =
408
+ agents.length === 0
409
+ ? []
410
+ : renderTreeList<AgentActivitySnapshot>(
411
+ {
412
+ items: agents,
413
+ expanded,
414
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
415
+ itemType: "agent",
416
+ renderItem: (agent, context) => {
417
+ const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
418
+ const icon = agent.live
419
+ ? formatStatusIcon("running", uiTheme, options.spinnerFrame)
420
+ : formatStatusIcon("warning", uiTheme);
421
+ const badge = agent.live
422
+ ? formatBadge("agent", "accent", uiTheme)
423
+ : formatBadge("agent · no turn", "warning", uiTheme);
424
+ const id = truncateToWidth(
425
+ replaceTabs(agent.id).replace(/\s+/g, " "),
426
+ Math.max(0, rowWidth - visibleWidth(`${icon} ${badge}`)),
427
+ Ellipsis.Unicode,
428
+ );
429
+ const gist = agent.activity
430
+ ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}`
431
+ : "";
432
+ const parent = agent.parentId ? uiTheme.fg("dim", ` ← ${agent.parentId}`) : "";
433
+ const age = uiTheme.fg("dim", formatDuration(agent.ageMs));
434
+ return [
435
+ truncateToWidth(
436
+ `${icon} ${uiTheme.fg("muted", id)} ${badge}${gist} ${age}${parent}`,
437
+ rowWidth,
438
+ "",
439
+ ),
440
+ ];
441
+ },
442
+ },
443
+ uiTheme,
444
+ );
445
+
446
+ const all = [header];
447
+ if (aggregateArtifactError) {
448
+ all.push(uiTheme.fg("warning", formatArtifactErrorNotice(aggregateArtifactError)));
449
+ }
450
+ all.push(...itemLines, ...agentLines);
451
+ for (let i = 0; i < all.length; i++) all[i] = truncateToWidth(all[i]!, width, Ellipsis.Unicode);
452
+ cached = { key, lines: all };
453
+ return all;
454
+ },
455
+ invalidate() {
456
+ cached = undefined;
457
+ },
458
+ };
459
+ }
460
+
461
+ // =============================================================================
462
+ // Display-only IRC cards and wait-message results
463
+ // =============================================================================
464
+
465
+ const BODY_LINES_COLLAPSED = 2;
466
+ const BODY_LINES_EXPANDED = 12;
467
+ const BODY_LINE_WIDTH = 100;
468
+
469
+ function ircGlyph(theme: Theme): string {
470
+ return theme.styledSymbol("tool.irc", "accent");
471
+ }
472
+
473
+ function messageAge(ts: number | undefined): string {
474
+ if (!ts) return "";
475
+ return formatAge(Math.max(1, Math.round((Date.now() - ts) / 1000)));
476
+ }
477
+
478
+ /**
479
+ * Quote-bordered message body preview. `tone` separates outbound text (dim)
480
+ * from received text (toolOutput); a trailing dim counter marks elided lines.
481
+ */
482
+ function bodyLines(
483
+ body: string,
484
+ expanded: boolean,
485
+ theme: Theme,
486
+ options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
487
+ ): string[] {
488
+ const indent = options.indent ?? "";
489
+ const tone = options.tone ?? "toolOutput";
490
+ const max = expanded ? BODY_LINES_EXPANDED : (options.collapsedLines ?? BODY_LINES_COLLAPSED);
491
+ const preview = cappedHeadLines(
492
+ body.split("\n").filter(line => line.trim()),
493
+ max,
494
+ );
495
+ const quote = theme.fg("dim", theme.md.quoteBorder);
496
+ const lines = preview.lines.map(
497
+ line =>
498
+ `${indent}${quote} ${theme.fg(tone, replaceTabs(truncateToWidth(line.trim(), BODY_LINE_WIDTH, Ellipsis.Unicode)))}`,
499
+ );
500
+ const hidden = preview.hidden;
501
+ if (hidden > 0) {
502
+ lines.push(`${indent}${quote} ${theme.fg("dim", `… +${hidden} more ${hidden === 1 ? "line" : "lines"}`)}`);
503
+ }
504
+ return lines;
505
+ }
506
+
507
+ /**
508
+ * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
509
+ * delivered to this session, `irc:autoreply` side-channel replies sent on
510
+ * this session's behalf, and `irc:relay` observations of agent↔agent
511
+ * traffic. Shares the tool renderer's glyph + quote-border conventions so
512
+ * cards and peer-message output look identical in the transcript.
513
+ */
514
+ export function createIrcMessageCard(
515
+ card: {
516
+ kind: "incoming" | "autoreply" | "relay" | "workpool";
517
+ from?: string;
518
+ to?: string;
519
+ body?: string;
520
+ replyTo?: string;
521
+ timestamp?: number;
522
+ pool?: string;
523
+ mode?: string;
524
+ },
525
+ getExpanded: () => boolean,
526
+ uiTheme: Theme,
527
+ ): Component {
528
+ const from = card.from?.trim() || "?";
529
+ const title =
530
+ card.kind === "incoming"
531
+ ? `IRC ${uiTheme.nav.back} ${from}`
532
+ : card.kind === "autoreply"
533
+ ? `IRC ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
534
+ : card.kind === "workpool"
535
+ ? `Pool ${card.pool?.trim() || "?"} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
536
+ : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
537
+ const body = card.body ?? "";
538
+ const meta: string[] = [];
539
+ if (card.kind === "autoreply") meta.push("auto");
540
+ if (card.kind === "workpool" && card.mode) meta.push(card.mode);
541
+ if (card.replyTo) meta.push("reply");
542
+ const age = messageAge(card.timestamp);
543
+ if (age) meta.push(age);
544
+ return createCachedComponent(
545
+ getExpanded,
546
+ (width, expanded) => {
547
+ const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
548
+ if (body.trim()) {
549
+ lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
550
+ }
551
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
552
+ },
553
+ { paddingX: 1 },
554
+ );
555
+ }
556
+
557
+ /** Render either a received message or a background-job snapshot. */
558
+ export const waitToolRenderer = {
559
+ inline: true,
560
+ mergeCallAndResult: true,
561
+ activitySummary(): ToolActivitySummary {
562
+ return { label: "Wait", detail: "Background work or peer message" };
563
+ },
564
+ renderCall: waitRenderCall,
565
+ renderResult(
566
+ result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
567
+ options: RenderResultOptions,
568
+ uiTheme: Theme,
569
+ ): Component {
570
+ if (result.details?.interrupted && !result.isError) {
571
+ return new Text(
572
+ renderStatusLine({ icon: "info", title: "Wait", meta: ["interrupted by message"] }, uiTheme),
573
+ 0,
574
+ 0,
575
+ );
576
+ }
577
+ const waited = result.details?.waited;
578
+ if (!waited) return jobsRenderResult(result, options, uiTheme);
579
+ return createCachedComponent(
580
+ () => options.expanded,
581
+ (width, expanded) =>
582
+ [
583
+ renderStatusLine(
584
+ {
585
+ iconOverride: ircGlyph(uiTheme),
586
+ title: `IRC ${uiTheme.nav.back} ${replaceTabs(waited.from)}`,
587
+ meta: [messageAge(waited.ts), ...(waited.replyTo ? ["reply"] : [])],
588
+ },
589
+ uiTheme,
590
+ ),
591
+ ...bodyLines(waited.body, expanded, uiTheme, { indent: " " }),
592
+ ].map(line => truncateToWidth(line, width, Ellipsis.Unicode)),
593
+ );
594
+ },
595
+ } satisfies ToolRenderer<object, CoordinationDetails>;