@linxiraos/pi-tui 1.3.2 → 1.4.1

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 (116) hide show
  1. package/CHANGELOG.md +181 -181
  2. package/dist/types/apps/session-picker.d.ts +13 -1
  3. package/dist/types/autocomplete.d.ts +6 -0
  4. package/dist/types/components/sparkline.d.ts +34 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/native/describe.d.ts +7 -1
  7. package/dist/types/native/overlay.d.ts +2 -0
  8. package/dist/types/native/tone.d.ts +2 -0
  9. package/dist/types/overlays/agent-hub.d.ts +3 -3
  10. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  11. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  12. package/dist/types/overlays/model-browser.d.ts +1 -1
  13. package/dist/types/overlays/resume-picker.d.ts +293 -0
  14. package/dist/types/overlays/resume-tree.d.ts +353 -0
  15. package/dist/types/overlays/session-selector.d.ts +22 -4
  16. package/dist/types/render/output-block.d.ts +2 -0
  17. package/dist/types/render/tool-card.d.ts +2 -0
  18. package/dist/types/sixel.d.ts +28 -0
  19. package/dist/types/status-line/host.d.ts +3 -0
  20. package/dist/types/terminal-capabilities.d.ts +6 -1
  21. package/dist/types/tools/bash.d.ts +2 -0
  22. package/dist/types/tools/default-renderer.d.ts +3 -0
  23. package/dist/types/tools/eval.d.ts +9 -8
  24. package/dist/types/tools/hub.d.ts +3 -1
  25. package/dist/types/tools/irc.d.ts +11 -1
  26. package/dist/types/tools/job-widget.d.ts +226 -0
  27. package/dist/types/tools/json-tree.d.ts +2 -0
  28. package/dist/types/tools/line-ranges.d.ts +8 -1
  29. package/dist/types/tools/mcp.d.ts +23 -0
  30. package/dist/types/tools/output-meta.d.ts +1 -1
  31. package/dist/types/tools/read.d.ts +8 -2
  32. package/dist/types/tools/renderer.d.ts +8 -2
  33. package/dist/types/tools/result-card.d.ts +1 -1
  34. package/dist/types/tools/streaming-output.d.ts +1 -0
  35. package/dist/types/tools/task.d.ts +12 -0
  36. package/dist/types/tools/wait.d.ts +11 -0
  37. package/package.json +9 -9
  38. package/src/apps/session-picker.ts +20 -2
  39. package/src/autocomplete.d.ts +6 -0
  40. package/src/autocomplete.ts +21 -6
  41. package/src/chat/skill-message.ts +16 -17
  42. package/src/chat/tool-execution.ts +35 -0
  43. package/src/chrome/transcript-container.ts +85 -13
  44. package/src/components/editor.ts +17 -7
  45. package/src/components/image.ts +5 -4
  46. package/src/components/markdown.ts +11 -3
  47. package/src/components/sparkline.d.ts +34 -0
  48. package/src/components/sparkline.ts +89 -0
  49. package/src/index.d.ts +1 -0
  50. package/src/index.ts +1 -0
  51. package/src/native/backend.ts +32 -6
  52. package/src/native/describe.d.ts +7 -1
  53. package/src/native/describe.ts +18 -1
  54. package/src/native/overlay.d.ts +2 -0
  55. package/src/native/overlay.ts +3 -0
  56. package/src/native/tone.d.ts +2 -0
  57. package/src/native/tone.ts +22 -0
  58. package/src/overlays/agent-hub.ts +14 -10
  59. package/src/overlays/agent-transcript-viewer.ts +2 -2
  60. package/src/overlays/agents-hub.ts +2 -1
  61. package/src/overlays/approval-dialog.ts +59 -13
  62. package/src/overlays/hub-frame.ts +2 -1
  63. package/src/overlays/jobs-panel.ts +24 -2
  64. package/src/overlays/model-browser.ts +3 -0
  65. package/src/overlays/model-hub.ts +2 -1
  66. package/src/overlays/resume-picker.ts +706 -0
  67. package/src/overlays/resume-tree.ts +1901 -0
  68. package/src/overlays/session-selector.ts +579 -39
  69. package/src/prompt/attachment-chips.ts +1 -1
  70. package/src/render/output-block.d.ts +2 -0
  71. package/src/render/output-block.ts +25 -12
  72. package/src/render/tool-card.d.ts +2 -0
  73. package/src/render/tool-card.ts +6 -0
  74. package/src/sixel.d.ts +28 -0
  75. package/src/sixel.ts +185 -0
  76. package/src/status-line/component.ts +19 -2
  77. package/src/status-line/host.ts +3 -0
  78. package/src/terminal-capabilities.d.ts +6 -1
  79. package/src/terminal-capabilities.ts +54 -3
  80. package/src/theme/symbols.ts +5 -3
  81. package/src/tools/bash.d.ts +2 -0
  82. package/src/tools/bash.ts +25 -9
  83. package/src/tools/default-renderer.d.ts +3 -0
  84. package/src/tools/default-renderer.ts +54 -17
  85. package/src/tools/eval.d.ts +9 -8
  86. package/src/tools/eval.ts +32 -14
  87. package/src/tools/find.ts +4 -5
  88. package/src/tools/glob.ts +4 -6
  89. package/src/tools/grep.ts +2 -4
  90. package/src/tools/hub.ts +4 -1
  91. package/src/tools/irc.d.ts +11 -1
  92. package/src/tools/irc.ts +11 -1
  93. package/src/tools/job-widget.d.ts +226 -0
  94. package/src/tools/job-widget.ts +1036 -0
  95. package/src/tools/json-tree.d.ts +2 -0
  96. package/src/tools/json-tree.ts +8 -0
  97. package/src/tools/line-ranges.d.ts +8 -1
  98. package/src/tools/line-ranges.ts +23 -13
  99. package/src/tools/mcp.d.ts +23 -0
  100. package/src/tools/mcp.ts +44 -1
  101. package/src/tools/output-meta.d.ts +1 -1
  102. package/src/tools/output-meta.ts +1 -1
  103. package/src/tools/proc-render.ts +1 -0
  104. package/src/tools/read.d.ts +8 -2
  105. package/src/tools/read.ts +64 -21
  106. package/src/tools/renderer.d.ts +8 -2
  107. package/src/tools/renderer.ts +8 -2
  108. package/src/tools/result-card.d.ts +1 -1
  109. package/src/tools/result-card.ts +1 -2
  110. package/src/tools/streaming-output.d.ts +1 -0
  111. package/src/tools/streaming-output.ts +11 -9
  112. package/src/tools/task.d.ts +12 -0
  113. package/src/tools/task.ts +25 -1
  114. package/src/tools/wait.d.ts +11 -0
  115. package/src/tools/wait.ts +296 -147
  116. package/src/tui.ts +45 -5
@@ -0,0 +1,1036 @@
1
+ /**
2
+ * Live widgets for background jobs: the rendered state of what a job is
3
+ * doing (a benchmark's requests/s over time, tests passed per model, CI jobs
4
+ * green so far), shown in the `wait` card, the HUD's jobs pill and the jobs
5
+ * sheet.
6
+ *
7
+ * A widget is data, not code. In coding-agent, a cheap model writes a
8
+ * `JobWidgetSpec` once per job (rules that pull numbers out of the job's
9
+ * output streams, and the blocks to draw them with); the job-widgets runtime
10
+ * then applies those rules to every new line and recomputes a
11
+ * {@link JobWidgetView}. Renderers here draw that view as TSP nodes (Tern) or
12
+ * ANSI lines, and never see a rule or a regex.
13
+ */
14
+
15
+ import type { TspChartMark, TspChartProps, TspPropNode, TspSpan, TspTone } from "@linxiraos/pi-wire";
16
+ import { renderProgressBar } from "../components/progress-bar";
17
+ import { fitSparkValues, renderSparkline } from "../components/sparkline";
18
+ import { col, compact, elapsed, keyed, node, propNode, row, span, stableKey, text } from "../native/describe";
19
+ import type { NativeNode } from "../native/node";
20
+ import { plainLine } from "../native/spans";
21
+ import { toneColor } from "../native/tone";
22
+ import type { Theme } from "../theme/theme";
23
+ import { Ellipsis, truncateToWidth, visibleWidth } from "../utils";
24
+
25
+ /** Where a widget's view stands. */
26
+ export type JobWidgetState =
27
+ /** The cheap model is writing the spec; the view holds the model-free fallback meanwhile. */
28
+ | "authoring"
29
+ /** The spec's rules drive the view. */
30
+ | "live"
31
+ /** No spec (no model, authoring failed, nothing to read yet): last line and rates only. */
32
+ | "fallback";
33
+
34
+ /** A formatted number with the direction it moved, for headlines and stats. */
35
+ export interface JobWidgetTrend {
36
+ /** `▲ 15%`, `▼ 3.2 ms`. */
37
+ text: string;
38
+ /** `success` when the move is toward `better`, `error` away from it, `muted` without a direction. */
39
+ tone: "success" | "error" | "muted";
40
+ }
41
+
42
+ /** The headline value: the one number a glance should take away (`186 tok/s`). */
43
+ export interface JobWidgetHeadline {
44
+ /** Formatted value with its unit. */
45
+ text: string;
46
+ /** What the value is, when the title does not already say it (`decode`, `p50`). */
47
+ label?: string;
48
+ trend?: JobWidgetTrend;
49
+ }
50
+
51
+ /** A time series drawn as columns, newest right, with optional reference lines. */
52
+ export interface JobWidgetSparkBlock {
53
+ kind: "spark";
54
+ /** What the series measures (`tok/s · nvfp4 b1`). */
55
+ label: string;
56
+ /** At most 48 values, oldest first, already downsampled. */
57
+ values: number[];
58
+ /** Domain of the chart; covers the values and every mark. */
59
+ min: number;
60
+ max: number;
61
+ /** Horizontal reference lines in value units (a target, a baseline). */
62
+ marks?: { value: number; label: string; tone?: TspTone }[];
63
+ /** Latest value, formatted (`186 tok/s`). */
64
+ last: string;
65
+ /** Per-point tooltip text, same length as `values` (`12:04:31 · 186 tok/s`). */
66
+ tips?: string[];
67
+ tone?: TspTone;
68
+ }
69
+
70
+ /** One bar per key (a model, a shard): a value against a common maximum. */
71
+ export interface JobWidgetBarsBlock {
72
+ kind: "bars";
73
+ label: string;
74
+ items: { label: string; value: number; text: string; tone?: TspTone }[];
75
+ /** The value a full bar stands for. */
76
+ max: number;
77
+ }
78
+
79
+ /** Progress of a countable whole (`7/9 trials`). */
80
+ export interface JobWidgetMeterBlock {
81
+ kind: "meter";
82
+ label: string;
83
+ /** 0–1. */
84
+ value: number;
85
+ /** `7/9 trials`. */
86
+ text: string;
87
+ tone?: TspTone;
88
+ }
89
+
90
+ /** A few labelled numbers on one line (`p50 41 ms · p99 212 ms · errors 0`). */
91
+ export interface JobWidgetStatBlock {
92
+ kind: "stat";
93
+ items: {
94
+ label: string;
95
+ text: string;
96
+ tone?: TspTone;
97
+ trend?: JobWidgetTrend;
98
+ }[];
99
+ }
100
+
101
+ /** One cell per key with its latest state (CI jobs, shards, workers). */
102
+ export interface JobWidgetGridBlock {
103
+ kind: "grid";
104
+ label: string;
105
+ items: { label: string; text: string; tone: TspTone }[];
106
+ }
107
+
108
+ /** The latest line a rule captured (the test now running, the current phase). */
109
+ export interface JobWidgetTextBlock {
110
+ kind: "text";
111
+ text: string;
112
+ }
113
+
114
+ export type JobWidgetBlock =
115
+ | JobWidgetSparkBlock
116
+ | JobWidgetBarsBlock
117
+ | JobWidgetMeterBlock
118
+ | JobWidgetStatBlock
119
+ | JobWidgetGridBlock
120
+ | JobWidgetTextBlock;
121
+
122
+ /**
123
+ * Everything a renderer needs to draw one job's widget. Plain JSON: it rides
124
+ * on `JobSnapshot.widget` through `wait` progress updates and is persisted
125
+ * with the final `wait` result, so a sealed card redraws the last state after
126
+ * a session reload.
127
+ */
128
+ export interface JobWidgetView {
129
+ /** Short name of what is being watched (`verb-bench · 3 models`). */
130
+ title: string;
131
+ headline?: JobWidgetHeadline;
132
+ /** What the job is doing now: a subagent's stage, or the last line of output. */
133
+ stage?: string;
134
+ /** Top to bottom; the first block is the primary one, and compact views show only it. */
135
+ blocks: JobWidgetBlock[];
136
+ state: JobWidgetState;
137
+ /** Epoch ms of the newest data the view reflects. */
138
+ updatedAt: number;
139
+ }
140
+
141
+ // =============================================================================
142
+ // Glances: the HUD pill, the status line, tooltips
143
+ // =============================================================================
144
+
145
+ /** A running job's widget, as the HUD and the status line list them. */
146
+ export interface JobWidgetEntry {
147
+ id: string;
148
+ type: JobTileType;
149
+ view: JobWidgetView;
150
+ }
151
+
152
+ /** What a glance at a widget reads: its headline, else the primary figure of its blocks. */
153
+ export interface JobWidgetGlance {
154
+ text: string;
155
+ label?: string;
156
+ trend?: JobWidgetTrend;
157
+ }
158
+
159
+ /** Values the HUD's mini chart keeps from the primary spark. */
160
+ const MINI_SPARK_POINTS = 16;
161
+ /** Tiles the HUD hover card shows (two rows of two) before it counts the rest. */
162
+ const HOVERCARD_JOBS = 4;
163
+ /** Keys a tile's bars block lists before it counts the rest. */
164
+ const TILE_BARS = 3;
165
+ /** Cells of a stage shown as a tile's body: two or three lines of a tile. */
166
+ const TILE_LEAD_CELLS = 120;
167
+ /** ANSI widgets stop growing here so wide terminals keep bars and labels together. */
168
+ const ANSI_MAX_WIDTH = 80;
169
+ const ANSI_LABEL_MIN = 4;
170
+ const ANSI_LABEL_MAX = 16;
171
+ const ANSI_BAR_MAX = 32;
172
+ /** Sparkline cells below which the mark labels give way to the series. */
173
+ const ANSI_SPARK_MIN = 12;
174
+ /** Lines a grid's labelled cells may wrap onto before it falls back to bare cells. */
175
+ const GRID_LINES = 3;
176
+ const INDENT = " ";
177
+ const GAP = " ";
178
+ const DEFAULT_TONE: TspTone = "accent";
179
+
180
+ /** The view's headline as a glance; undefined without one. */
181
+ function headlineGlance(view: JobWidgetView): JobWidgetGlance | undefined {
182
+ const headline = view.headline;
183
+ const value = headline ? plainLine(headline.text) : "";
184
+ if (!headline || !value) return undefined;
185
+ const label = headline.label ? plainLine(headline.label) : "";
186
+ return {
187
+ text: value,
188
+ ...(label ? { label } : {}),
189
+ ...(headline.trend ? { trend: headline.trend } : {}),
190
+ };
191
+ }
192
+
193
+ /** What one glance at the widget reads: the headline, else the latest figure of its first spark or meter. */
194
+ export function jobWidgetGlance(view: JobWidgetView): JobWidgetGlance | undefined {
195
+ const headline = headlineGlance(view);
196
+ if (headline) return headline;
197
+ for (const block of view.blocks) {
198
+ if (block.kind === "spark" && block.values.length > 0 && plainLine(block.last)) {
199
+ return { text: plainLine(block.last), label: plainLine(block.label) };
200
+ }
201
+ if (block.kind === "meter" && plainLine(block.text))
202
+ return { text: plainLine(block.text), label: plainLine(block.label) };
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ /** The widget's first spark cut to its newest `points` values (the HUD's mini chart); undefined without data. */
208
+ export function jobWidgetSpark(view: JobWidgetView, points = MINI_SPARK_POINTS): JobWidgetSparkBlock | undefined {
209
+ const spark = view.blocks.find((block): block is JobWidgetSparkBlock => block.kind === "spark");
210
+ if (!spark || spark.values.length === 0) return undefined;
211
+ if (spark.values.length <= points) return spark;
212
+ const from = spark.values.length - points;
213
+ return {
214
+ ...spark,
215
+ values: spark.values.slice(from),
216
+ ...(spark.tips ? { tips: spark.tips.slice(from) } : {}),
217
+ };
218
+ }
219
+
220
+ /** The entry whose live widget (one a spec drives) saw data last. */
221
+ export function latestJobWidget<T extends JobWidgetEntry>(entries: readonly T[]): T | undefined {
222
+ let latest: T | undefined;
223
+ for (const entry of entries) {
224
+ if (entry.view.state !== "live") continue;
225
+ if (!latest || entry.view.updatedAt > latest.view.updatedAt) latest = entry;
226
+ }
227
+ return latest;
228
+ }
229
+
230
+ /** The widget as one plain line for tooltips: `verb-bench · 3 models — decode 186 tok/s ▲ 15%`. */
231
+ export function jobWidgetSummary(view: JobWidgetView): string {
232
+ const glance = jobWidgetGlance(view);
233
+ const title = plainLine(view.title);
234
+ if (!glance) return title;
235
+ const figure = [glance.label, glance.text, glance.trend && plainLine(glance.trend.text)].filter(Boolean).join(" ");
236
+ return title ? `${title} — ${figure}` : figure;
237
+ }
238
+
239
+ /** Spans of a glance: muted label, strong number, toned trend. */
240
+ export function jobWidgetGlanceSpans(glance: JobWidgetGlance): TspSpan[] {
241
+ return compact([
242
+ glance.label ? span(`${glance.label} `, "muted") : undefined,
243
+ span(glance.text, "num strong"),
244
+ glance.trend && span(` ${plainLine(glance.trend.text)}`, toneColor(glance.trend.tone)),
245
+ ]);
246
+ }
247
+
248
+ /**
249
+ * A glance for the ANSI status line: the figure with its trend, then a
250
+ * `cells`-wide sparkline of the primary spark when there is one.
251
+ */
252
+ export function formatJobWidgetGlance(view: JobWidgetView, theme: Theme, cells = 8): string {
253
+ const glance = jobWidgetGlance(view);
254
+ const parts: string[] = [];
255
+ if (glance) {
256
+ parts.push(
257
+ glance.trend
258
+ ? `${theme.bold(glance.text)} ${theme.fg(toneColor(glance.trend.tone), plainLine(glance.trend.text))}`
259
+ : theme.bold(glance.text),
260
+ );
261
+ }
262
+ const spark = jobWidgetSpark(view, cells);
263
+ if (spark && cells > 0) parts.push(ansiSpark(spark, spark.values, theme));
264
+ return parts.join(" ");
265
+ }
266
+
267
+ // =============================================================================
268
+ // Native (TSP)
269
+ // =============================================================================
270
+
271
+ /**
272
+ * A job's whole widget as a TSP subtree (role `zeta.job.widget`), as the jobs
273
+ * sheet details it: a head row (title, stage, headline) over every block.
274
+ */
275
+ export function describeJobWidget(view: JobWidgetView): NativeNode {
276
+ return col(
277
+ [describeHead(view), ...view.blocks.map((block, index) => keyed(describeBlock(block), `${index}-${block.kind}`))],
278
+ { role: "zeta.job.widget", gap: "xs" },
279
+ );
280
+ }
281
+
282
+ /**
283
+ * The HUD jobs pill's hover card: up to four running jobs as tiles, then a
284
+ * count of the rest; undefined without widgets. Its tiles carry no clock:
285
+ * Tern draws the card only once hovered, and a clock drawn then would start
286
+ * from the last time the program wrote.
287
+ */
288
+ export function describeJobWidgetsHovercard(entries: readonly JobWidgetEntry[]): TspPropNode | undefined {
289
+ if (entries.length === 0) return undefined;
290
+ const shown = entries.slice(0, HOVERCARD_JOBS);
291
+ const tiles = shown.map((entry): JobTile => ({
292
+ id: entry.id,
293
+ type: entry.type,
294
+ status: "running",
295
+ label: entry.id,
296
+ live: true,
297
+ widget: entry.view,
298
+ }));
299
+ const children = [describeJobTiles(tiles)];
300
+ const rest = entries.length - shown.length;
301
+ if (rest > 0) {
302
+ children.push(
303
+ text([span(`+${rest} more ${rest === 1 ? "job" : "jobs"} · /jobs`, "dim")], {
304
+ role: "zeta.jobs.hovercard.more",
305
+ wrap: "none",
306
+ }),
307
+ );
308
+ }
309
+ return propNode(col(children, { role: "zeta.jobs.hovercard", gap: "md" }));
310
+ }
311
+
312
+ /** The HUD's mini chart: the newest values of the widget's first spark; undefined without one. */
313
+ export function describeJobWidgetMiniSpark(view: JobWidgetView): NativeNode | undefined {
314
+ const spark = jobWidgetSpark(view);
315
+ if (!spark) return undefined;
316
+ return node("chart", {
317
+ role: "zeta.hud.spark",
318
+ kind: "spark",
319
+ size: "sm",
320
+ series: spark.values.map(value => ({ value })),
321
+ min: spark.min,
322
+ max: spark.max,
323
+ token: toneColor(spark.tone ?? DEFAULT_TONE),
324
+ summary: `${plainLine(spark.label)} ${plainLine(spark.last)}`,
325
+ });
326
+ }
327
+
328
+ // =============================================================================
329
+ // Tiles: the wait card, the HUD hover card
330
+ // =============================================================================
331
+
332
+ /** Where a tiled job stands. */
333
+ export type JobTileStatus = "running" | "completed" | "failed" | "cancelled";
334
+
335
+ /** What kind of job a tile shows: a subagent (`task`) goes by its name, any other job by what it runs. */
336
+ export type JobTileType = "bash" | "task" | "eval";
337
+
338
+ /** One job as a tile draws it: who it is, how long it has run, and its widget when it has one. */
339
+ export interface JobTile {
340
+ id: string;
341
+ type: JobTileType;
342
+ status: JobTileStatus;
343
+ /** The job's label (a command): the title of a job that is no subagent, before a widget names it. */
344
+ label: string;
345
+ /** How long the job has run; a tile without it shows no clock. */
346
+ durationMs?: number;
347
+ /** Whether the job is live (its dot pulses, its clock runs); settled and sealed tiles freeze. */
348
+ live: boolean;
349
+ /** The job's widget; only a live one (a spec drives it) draws figures and charts, any lends its stage. */
350
+ widget?: JobWidgetView;
351
+ /** A task's own completion estimate (0–100), drawn as a ring. */
352
+ completionPercent?: number;
353
+ /** What the job is doing now, when its widget names nothing: a task's stage in its own words. */
354
+ stage?: string;
355
+ exitCode?: number;
356
+ }
357
+
358
+ const TILE_GLYPH: Record<JobTileStatus, string> = {
359
+ running: "●",
360
+ completed: "✓",
361
+ failed: "✕",
362
+ cancelled: "■",
363
+ };
364
+ const TILE_TONE: Record<JobTileStatus, TspTone> = {
365
+ running: "accent",
366
+ completed: "success",
367
+ failed: "error",
368
+ cancelled: "warning",
369
+ };
370
+ /** A figure's leading number and the unit after it: `1.10k req/s`, `7/9 trials`, `68%`. */
371
+ const FIGURE_NUMBER = /^([-+]?(?:\d[\d,]*(?:\.\d+)?|\.\d+)[kKMGT]?%?)(?:\s+|(?=\/)|$)(.*)$/;
372
+
373
+ /**
374
+ * Jobs as one frame of equal tiles (role `zeta.job.tiles`) that wraps onto
375
+ * more rows when the frame runs out of width.
376
+ */
377
+ export function describeJobTiles(tiles: readonly JobTile[]): NativeNode {
378
+ return row(
379
+ tiles.map(tile => keyed(describeJobTile(tile), stableKey(tile.id))),
380
+ { role: "zeta.job.tiles", gap: "none", wrap: true },
381
+ );
382
+ }
383
+
384
+ /**
385
+ * One job as a tile (role `zeta.job.tile`): a head line (status, name, id,
386
+ * a task's own completion estimate as a ring, clock), then what a live
387
+ * widget shows (the figure a glance takes away, its primary block, quiet
388
+ * lines of stats) and the stage. With nothing to chart, the stage is the
389
+ * tile's body.
390
+ */
391
+ export function describeJobTile(tile: JobTile): NativeNode {
392
+ const view = tile.widget?.state === "live" ? tile.widget : undefined;
393
+ const glance = view ? jobWidgetGlance(view) : undefined;
394
+ const title = tileTitle(tile);
395
+ const id = plainLine(tile.id);
396
+ const pulse = tile.live && tile.status === "running";
397
+ const clock = tile.durationMs === undefined ? undefined : elapsed(tile.durationMs, !tile.live);
398
+ const percent = tile.completionPercent === undefined ? undefined : Math.round(tile.completionPercent);
399
+ const figure =
400
+ glance &&
401
+ text(figureSpans(glance), {
402
+ role: "zeta.job.tile.figure",
403
+ wrap: "none",
404
+ truncate: "end",
405
+ });
406
+ const head = row(
407
+ compact([
408
+ text([span(TILE_GLYPH[tile.status], toneColor(TILE_TONE[tile.status]), pulse ? { fx: "pulse" } : undefined)], {
409
+ role: "zeta.job.tile.dot",
410
+ wrap: "none",
411
+ }),
412
+ text([span(title)], {
413
+ role: "zeta.job.tile.title",
414
+ wrap: "none",
415
+ truncate: "end",
416
+ grow: 1,
417
+ }),
418
+ id === title
419
+ ? undefined
420
+ : text([span(id, "dim")], {
421
+ role: "zeta.job.tile.id",
422
+ wrap: "none",
423
+ }),
424
+ // Tern tints the ring by its value and shows the label on hover.
425
+ percent === undefined
426
+ ? undefined
427
+ : keyed(
428
+ node("meter", {
429
+ role: "zeta.job.tile.ring",
430
+ style: "ring",
431
+ size: "sm",
432
+ value: fraction(percent, 100),
433
+ label: `${percent}%`,
434
+ title: `The agent's own completion estimate: ${percent}%`,
435
+ }),
436
+ "ring",
437
+ ),
438
+ clock && keyed(clock, "clock"),
439
+ ]),
440
+ { role: "zeta.job.tile.head", gap: "xs", align: "center" },
441
+ );
442
+ const visual = tileVisual(view?.blocks[0]);
443
+ return col(
444
+ compact([
445
+ keyed(head, "head"),
446
+ figure && keyed(figure, "figure"),
447
+ visual && keyed(visual, "visual"),
448
+ ...tileNotes(tile, view, !figure && !visual),
449
+ ]),
450
+ {
451
+ role: "zeta.job.tile",
452
+ tone: TILE_TONE[tile.status],
453
+ gap: "xs",
454
+ },
455
+ );
456
+ }
457
+
458
+ /** A subagent's name; any other job's widget title, else its label's first line, else its id. */
459
+ function tileTitle(tile: JobTile): string {
460
+ if (tile.type === "task") return plainLine(tile.id);
461
+ return plainLine(tile.widget?.title ?? "") || plainLine(tile.label.split(/\r?\n/, 1)[0] ?? "") || plainLine(tile.id);
462
+ }
463
+
464
+ /**
465
+ * `p50 41 ms ▲ 8%`: the number in full between its quiet label and unit, then the toned
466
+ * trend; a label the unit already states (`tok/s 162 tok/s`) is dropped.
467
+ */
468
+ function figureSpans(glance: JobWidgetGlance): TspSpan[] {
469
+ const match = FIGURE_NUMBER.exec(glance.text);
470
+ const value = match?.[1] ?? glance.text;
471
+ const unit = (match?.[2] ?? "").replace(/^\/\s*/, "/ ");
472
+ const label = glance.label && !unit.toLowerCase().includes(glance.label.toLowerCase()) ? glance.label : "";
473
+ return compact([
474
+ label ? span(`${label} `, "dim") : undefined,
475
+ span(value),
476
+ unit ? span(` ${unit}`, "dim") : undefined,
477
+ glance.trend && span(` ${plainLine(glance.trend.text)}`, toneColor(glance.trend.tone)),
478
+ ]);
479
+ }
480
+
481
+ /** A tile's picture of its primary block; stats and text read as notes instead. */
482
+ function tileVisual(block: JobWidgetBlock | undefined): NativeNode | undefined {
483
+ switch (block?.kind) {
484
+ case "spark":
485
+ if (block.values.length === 0) return undefined;
486
+ return node("chart", {
487
+ ...sparkChartProps(block),
488
+ role: "zeta.job.tile.chart",
489
+ size: "sm",
490
+ });
491
+ case "meter":
492
+ return tileMeter(block.value, block.tone);
493
+ case "bars": {
494
+ const rows = block.items.slice(0, TILE_BARS).map((item, index) =>
495
+ keyed(
496
+ row(
497
+ [
498
+ text([span(plainLine(item.label), "dim")], {
499
+ role: "zeta.job.tile.key",
500
+ wrap: "none",
501
+ truncate: "end",
502
+ }),
503
+ tileMeter(fraction(item.value, block.max), item.tone),
504
+ text([span(plainLine(item.text))], {
505
+ role: "zeta.job.tile.num",
506
+ wrap: "none",
507
+ }),
508
+ ],
509
+ { role: "zeta.job.tile.bar", gap: "sm", align: "center" },
510
+ ),
511
+ `${index}`,
512
+ ),
513
+ );
514
+ const rest = block.items.length - rows.length;
515
+ if (rest > 0)
516
+ rows.push(
517
+ text([span(`+${rest} more`, "dim")], {
518
+ role: "zeta.job.tile.more",
519
+ wrap: "none",
520
+ }),
521
+ );
522
+ return rows.length > 0 ? col(rows, { role: "zeta.job.tile.bars", gap: "xs" }) : undefined;
523
+ }
524
+ case "grid": {
525
+ if (block.items.length === 0) return undefined;
526
+ // No-break spaces inside a cell: lines wrap between cells only.
527
+ const spans: TspSpan[] = [];
528
+ for (const item of block.items) {
529
+ if (spans.length > 0) spans.push(span(" "));
530
+ spans.push(
531
+ span("●\u00a0", toneColor(item.tone)),
532
+ span(plainLine(item.label).replaceAll(" ", "\u00a0"), "dim"),
533
+ );
534
+ }
535
+ return text(spans, { role: "zeta.job.tile.grid", wrap: "word" });
536
+ }
537
+ default:
538
+ return undefined;
539
+ }
540
+ }
541
+
542
+ function tileMeter(value: number, tone: TspTone = DEFAULT_TONE): NativeNode {
543
+ return node("meter", {
544
+ role: "zeta.job.tile.meter",
545
+ value: fraction(value, 1),
546
+ size: "sm",
547
+ tone,
548
+ grow: 1,
549
+ });
550
+ }
551
+
552
+ /**
553
+ * The live widget's stats and text lines (one that repeats the stage is
554
+ * left out), and the stage with a failed exit code ahead of it: a quiet
555
+ * last line, or the tile's body (`lead`, first and up to two lines) when
556
+ * the tile has nothing to chart.
557
+ */
558
+ function tileNotes(tile: JobTile, view: JobWidgetView | undefined, lead: boolean): NativeNode[] {
559
+ // The widget's stage is newer than the job's report (a rule reads it live) or the same report.
560
+ const stage = plainLine(tile.widget?.stage ?? tile.stage ?? "");
561
+ const failed = tile.exitCode !== undefined && tile.exitCode !== 0 ? `exit ${tile.exitCode}` : "";
562
+ const notes: NativeNode[] = [];
563
+ for (const block of view?.blocks ?? []) {
564
+ if (block.kind === "stat" && block.items.length > 0) {
565
+ notes.push(
566
+ text(statSpans(block), {
567
+ role: "zeta.job.tile.stats",
568
+ wrap: "none",
569
+ truncate: "end",
570
+ }),
571
+ );
572
+ } else if (block.kind === "text" && plainLine(block.text) && plainLine(block.text) !== stage) {
573
+ notes.push(
574
+ text([span(plainLine(block.text), "dim")], {
575
+ role: "zeta.job.tile.note",
576
+ wrap: "none",
577
+ truncate: "end",
578
+ }),
579
+ );
580
+ }
581
+ }
582
+ if (stage || failed) {
583
+ const spans = compact([
584
+ failed ? span(failed, "error") : undefined,
585
+ failed && stage ? span(" · ", "dim") : undefined,
586
+ stage
587
+ ? lead
588
+ ? span(truncateToWidth(stage, TILE_LEAD_CELLS, Ellipsis.Unicode))
589
+ : span(stage, "dim")
590
+ : undefined,
591
+ ]);
592
+ if (lead) notes.unshift(text(spans, { role: "zeta.job.tile.lead", wrap: "word" }));
593
+ else notes.push(text(spans, { role: "zeta.job.tile.stage", wrap: "none", truncate: "end" }));
594
+ }
595
+ return notes.map((note, index) => keyed(note, `note-${index}`));
596
+ }
597
+
598
+ function describeHead(view: JobWidgetView): NativeNode {
599
+ const stage = view.stage ? plainLine(view.stage) : "";
600
+ const headline = headlineGlance(view);
601
+ return row(
602
+ compact([
603
+ text([span(plainLine(view.title), "strong")], {
604
+ role: "zeta.job.widget.title",
605
+ wrap: "none",
606
+ truncate: "end",
607
+ ...(stage ? {} : { grow: 1 }),
608
+ }),
609
+ stage
610
+ ? text([span(stage, "muted")], {
611
+ role: "zeta.job.widget.stage",
612
+ wrap: "none",
613
+ truncate: "end",
614
+ grow: 1,
615
+ })
616
+ : undefined,
617
+ headline &&
618
+ text(jobWidgetGlanceSpans(headline), {
619
+ role: "zeta.job.widget.headline",
620
+ wrap: "none",
621
+ }),
622
+ ]),
623
+ { role: "zeta.job.widget.head", gap: "sm", align: "baseline" },
624
+ );
625
+ }
626
+
627
+ function describeBlock(block: JobWidgetBlock): NativeNode {
628
+ switch (block.kind) {
629
+ case "spark":
630
+ return describeSpark(block);
631
+ case "bars":
632
+ return describeBars(block);
633
+ case "meter":
634
+ return labelled(
635
+ block.label,
636
+ node("meter", {
637
+ value: fraction(block.value, 1),
638
+ label: plainLine(block.text),
639
+ size: "sm",
640
+ grow: 1,
641
+ ...(block.tone ? { tone: block.tone } : {}),
642
+ }),
643
+ "zeta.job.widget.meter",
644
+ );
645
+ case "stat":
646
+ return text(statSpans(block), {
647
+ role: "zeta.job.widget.stat",
648
+ wrap: "none",
649
+ truncate: "end",
650
+ });
651
+ case "grid":
652
+ return col(
653
+ [
654
+ text([span(plainLine(block.label), "dim")], {
655
+ role: "zeta.job.widget.caption",
656
+ wrap: "none",
657
+ truncate: "end",
658
+ }),
659
+ block.items.length > 0
660
+ ? row(
661
+ block.items.map((item, index) =>
662
+ node(
663
+ "badge",
664
+ {
665
+ text: plainLine(item.label),
666
+ tone: item.tone,
667
+ title: plainLine(item.text),
668
+ },
669
+ undefined,
670
+ `${index}`,
671
+ ),
672
+ ),
673
+ { role: "zeta.job.widget.cells", gap: "xs", wrap: true },
674
+ )
675
+ : waiting(),
676
+ ],
677
+ { role: "zeta.job.widget.grid", gap: "xs" },
678
+ );
679
+ case "text":
680
+ return text([span(plainLine(block.text), "muted")], {
681
+ role: "zeta.job.widget.text",
682
+ wrap: "none",
683
+ truncate: "end",
684
+ });
685
+ }
686
+ }
687
+
688
+ /** A column chart with dashed reference lines, the latest value and the mark labels at its side. */
689
+ function describeSpark(block: JobWidgetSparkBlock): NativeNode {
690
+ if (block.values.length === 0) return labelled(block.label, waiting(), "zeta.job.widget.spark");
691
+ const marks = block.marks ?? [];
692
+ const label = plainLine(block.label);
693
+ const last = plainLine(block.last);
694
+ return row(
695
+ [
696
+ node("chart", { ...sparkChartProps(block), size: "md", grow: 1 }, undefined, "chart"),
697
+ keyed(
698
+ col(
699
+ compact([
700
+ text([span(last, "num strong")], { wrap: "none" }),
701
+ label && !last.endsWith(label)
702
+ ? text([span(label, "muted")], { wrap: "none", truncate: "end" })
703
+ : undefined,
704
+ ...marks.map(mark =>
705
+ text([span("┄ ", toneColor(mark.tone ?? "muted")), span(plainLine(mark.label), "dim")], {
706
+ wrap: "none",
707
+ truncate: "end",
708
+ }),
709
+ ),
710
+ ]),
711
+ { role: "zeta.job.widget.side", gap: "none" },
712
+ ),
713
+ "side",
714
+ ),
715
+ ],
716
+ { role: "zeta.job.widget.spark", gap: "sm", align: "center" },
717
+ );
718
+ }
719
+
720
+ /** A spark as chart props: columns over the block's domain, its toned reference lines and per-point tooltips. */
721
+ function sparkChartProps(block: JobWidgetSparkBlock): TspChartProps {
722
+ const series = block.values.map((value, index) => {
723
+ const tip = block.tips?.[index];
724
+ return tip ? { value, title: plainLine(tip) } : { value };
725
+ });
726
+ const marks = (block.marks ?? []).map((mark): TspChartMark => {
727
+ const title = plainLine(mark.label);
728
+ return {
729
+ value: mark.value,
730
+ ...(mark.tone ? { tone: mark.tone } : {}),
731
+ ...(title ? { title } : {}),
732
+ };
733
+ });
734
+ return {
735
+ kind: "bars",
736
+ series,
737
+ min: block.min,
738
+ max: block.max,
739
+ ...(marks.length > 0 ? { marks } : {}),
740
+ token: toneColor(block.tone ?? DEFAULT_TONE),
741
+ summary: `${plainLine(block.label)} ${plainLine(block.last)}`,
742
+ };
743
+ }
744
+
745
+ /** One meter row per key under the block's caption. */
746
+ function describeBars(block: JobWidgetBarsBlock): NativeNode {
747
+ const rows: NativeNode[] = block.items.map((item, index) =>
748
+ keyed(
749
+ labelled(
750
+ item.label,
751
+ node("meter", {
752
+ value: fraction(item.value, block.max),
753
+ label: plainLine(item.text),
754
+ size: "sm",
755
+ grow: 1,
756
+ ...(item.tone ? { tone: item.tone } : {}),
757
+ }),
758
+ "zeta.job.widget.bar",
759
+ ),
760
+ `${index}`,
761
+ ),
762
+ );
763
+ const caption = text([span(plainLine(block.label), "dim")], {
764
+ role: "zeta.job.widget.caption",
765
+ wrap: "none",
766
+ truncate: "end",
767
+ });
768
+ return col([caption, ...(rows.length > 0 ? rows : [waiting()])], {
769
+ role: "zeta.job.widget.bars",
770
+ gap: "xs",
771
+ });
772
+ }
773
+
774
+ /** A label column (≈12 cells, muted) before `content`. */
775
+ function labelled(label: string, content: NativeNode, role: string): NativeNode {
776
+ return row(
777
+ [
778
+ text([span(plainLine(label), "muted")], {
779
+ wrap: "none",
780
+ truncate: "end",
781
+ min: { w: "12ch" },
782
+ max: { w: "12ch" },
783
+ }),
784
+ content,
785
+ ],
786
+ { role, gap: "sm", align: "center" },
787
+ );
788
+ }
789
+
790
+ function waiting(): NativeNode {
791
+ return text([span("waiting for data", "dim")], { wrap: "none" });
792
+ }
793
+
794
+ /** `p50 41 ms ▼ 12% · errors 0`: dim labels, values in their tone, toned trends. */
795
+ function statSpans(block: JobWidgetStatBlock): TspSpan[] {
796
+ const spans: TspSpan[] = [];
797
+ for (const [index, item] of block.items.entries()) {
798
+ if (index > 0) spans.push(span(" · ", "dim"));
799
+ spans.push(span(`${plainLine(item.label)} `, "dim"));
800
+ spans.push(item.tone ? span(plainLine(item.text), toneColor(item.tone)) : span(plainLine(item.text)));
801
+ if (item.trend) spans.push(span(` ${plainLine(item.trend.text)}`, toneColor(item.trend.tone)));
802
+ }
803
+ return spans;
804
+ }
805
+
806
+ /** `value / max` clamped to 0–1 (0 for an empty or broken scale). */
807
+ function fraction(value: number, max: number): number {
808
+ if (!(max > 0) || !Number.isFinite(value)) return 0;
809
+ return Math.min(1, Math.max(0, value / max));
810
+ }
811
+
812
+ // =============================================================================
813
+ // ANSI
814
+ // =============================================================================
815
+
816
+ /**
817
+ * A job's whole widget as terminal lines, each within `width` (capped at 80
818
+ * so bars stay next to their labels): the head line (title, stage, headline
819
+ * at the right), then every block under a shared label column.
820
+ */
821
+ export function renderJobWidgetLines(view: JobWidgetView, width: number, theme: Theme): string[] {
822
+ const w = Math.min(Math.max(0, Math.floor(width)), ANSI_MAX_WIDTH);
823
+ if (w === 0) return [];
824
+ const labelWidth = ansiLabelWidth(view.blocks, w);
825
+ const stage = view.stage ? plainLine(view.stage) : "";
826
+ const title = theme.bold(plainLine(view.title));
827
+ const headline = headlineGlance(view);
828
+ const lines = [
829
+ ansiHeadRow(
830
+ stage ? `${title}${GAP}${theme.fg("muted", stage)}` : title,
831
+ headline && ansiGlance(headline, theme),
832
+ w,
833
+ ),
834
+ ];
835
+ for (const block of view.blocks) lines.push(...renderBlock(block, w, labelWidth, theme));
836
+ return lines.map(line => truncateToWidth(line, w, Ellipsis.Unicode));
837
+ }
838
+
839
+ /** How the ANSI wait card draws a tile. */
840
+ export interface JobTileLineOptions {
841
+ /** Opens the title line: the caller's status icon and type badge. */
842
+ lead: string;
843
+ /** Closes the title line at the right, after the figure: the duration. */
844
+ meta: string;
845
+ /** Every block rather than the primary one alone (an expanded card). */
846
+ full?: boolean;
847
+ }
848
+
849
+ /**
850
+ * A tile as terminal lines within `width`: the lead, name and id with the
851
+ * figure and meta at the right, then a live widget's primary block (every
852
+ * block when `full`) and dim lines of stats and stage, capped at 80 cells.
853
+ */
854
+ export function renderJobTileLines(tile: JobTile, width: number, theme: Theme, options: JobTileLineOptions): string[] {
855
+ const w = Math.min(Math.max(0, Math.floor(width)), ANSI_MAX_WIDTH);
856
+ if (w === 0) return [];
857
+ const view = tile.widget?.state === "live" ? tile.widget : undefined;
858
+ const glance = view ? jobWidgetGlance(view) : undefined;
859
+ const title = tileTitle(tile);
860
+ const id = plainLine(tile.id);
861
+ const left = `${options.lead}${theme.bold(title)}${id === title ? "" : ` ${theme.fg("dim", id)}`}`;
862
+ const right = [glance && ansiGlance(glance, theme), options.meta].filter(Boolean).join(GAP);
863
+ const lines = [ansiHeadRow(left, right, w)];
864
+ const shown = (options.full ? view?.blocks : view?.blocks.slice(0, 1)) ?? [];
865
+ const blocks = shown.filter(block => block.kind !== "stat" && block.kind !== "text");
866
+ const labelWidth = ansiLabelWidth(blocks, w);
867
+ for (const block of blocks) lines.push(...renderBlock(block, w, labelWidth, theme));
868
+ const stage = plainLine(tile.widget?.stage ?? tile.stage ?? "");
869
+ for (const block of view?.blocks ?? []) {
870
+ if (block.kind === "stat" || (block.kind === "text" && plainLine(block.text) !== stage)) {
871
+ lines.push(...renderBlock(block, w, 0, theme));
872
+ }
873
+ }
874
+ if (stage) lines.push(`${INDENT}${theme.fg("muted", stage)}`);
875
+ return lines.map(line => truncateToWidth(line, w, Ellipsis.Unicode));
876
+ }
877
+
878
+ /** One label column for every block: the widest label, within 4–16 cells and a third of the width. */
879
+ function ansiLabelWidth(blocks: readonly JobWidgetBlock[], width: number): number {
880
+ let widest = 0;
881
+ const measure = (label: string): void => {
882
+ widest = Math.max(widest, visibleWidth(plainLine(label)));
883
+ };
884
+ for (const block of blocks) {
885
+ if (block.kind === "spark" || block.kind === "meter" || block.kind === "grid") measure(block.label);
886
+ else if (block.kind === "bars") for (const item of block.items) measure(item.label);
887
+ }
888
+ if (widest === 0) return 0;
889
+ const cap = Math.min(ANSI_LABEL_MAX, Math.floor((width - INDENT.length) / 3));
890
+ return Math.max(0, Math.min(Math.max(widest, ANSI_LABEL_MIN), cap));
891
+ }
892
+
893
+ /** `left` cut to fit with `right` flush at the right edge; `right` gives way when `left` would keep under 8 cells. */
894
+ function ansiHeadRow(left: string, right: string | undefined, width: number): string {
895
+ if (!right) return truncateToWidth(left, width, Ellipsis.Unicode);
896
+ const room = width - visibleWidth(right) - GAP.length;
897
+ if (room < Math.min(visibleWidth(left), 8)) return truncateToWidth(left, width, Ellipsis.Unicode);
898
+ const head = truncateToWidth(left, room, Ellipsis.Unicode);
899
+ return `${head}${" ".repeat(Math.max(GAP.length, width - visibleWidth(head) - visibleWidth(right)))}${right}`;
900
+ }
901
+
902
+ function ansiGlance(glance: JobWidgetGlance, theme: Theme): string {
903
+ let out = theme.bold(glance.text);
904
+ if (glance.label) out = `${theme.fg("muted", glance.label)} ${out}`;
905
+ if (glance.trend) out += ` ${theme.fg(toneColor(glance.trend.tone), plainLine(glance.trend.text))}`;
906
+ return out;
907
+ }
908
+
909
+ /** The indent and the padded label column that open a block row. */
910
+ function ansiLead(label: string, labelWidth: number, theme: Theme): string {
911
+ if (labelWidth === 0) return INDENT;
912
+ return `${INDENT}${theme.fg("muted", truncateToWidth(plainLine(label), labelWidth, Ellipsis.Unicode, true))}${GAP}`;
913
+ }
914
+
915
+ function renderBlock(block: JobWidgetBlock, width: number, labelWidth: number, theme: Theme): string[] {
916
+ switch (block.kind) {
917
+ case "spark":
918
+ return [renderSpark(block, width, labelWidth, theme)];
919
+ case "bars": {
920
+ const lines = [`${INDENT}${theme.fg("dim", plainLine(block.label))}`];
921
+ if (block.items.length === 0) lines.push(`${INDENT}${theme.fg("dim", "waiting for data")}`);
922
+ const texts = block.items.map(item => plainLine(item.text));
923
+ const textWidth = Math.max(0, ...texts.map(value => visibleWidth(value)));
924
+ for (const [index, item] of block.items.entries()) {
925
+ const lead = ansiLead(item.label, labelWidth, theme);
926
+ const value = texts[index]!;
927
+ const bar = ansiBar(
928
+ fraction(item.value, block.max),
929
+ width - visibleWidth(lead) - GAP.length - textWidth,
930
+ item.tone,
931
+ theme,
932
+ );
933
+ lines.push(`${lead}${bar}${GAP}${" ".repeat(textWidth - visibleWidth(value))}${value}`);
934
+ }
935
+ return lines;
936
+ }
937
+ case "meter": {
938
+ const lead = ansiLead(block.label, labelWidth, theme);
939
+ const value = plainLine(block.text);
940
+ const bar = ansiBar(
941
+ fraction(block.value, 1),
942
+ width - visibleWidth(lead) - GAP.length - visibleWidth(value),
943
+ block.tone,
944
+ theme,
945
+ );
946
+ return [`${lead}${bar}${GAP}${theme.bold(value)}`];
947
+ }
948
+ case "stat":
949
+ return [
950
+ `${INDENT}${block.items
951
+ .map(item => {
952
+ const color = item.tone ? toneColor(item.tone) : "toolOutput";
953
+ const trend = item.trend
954
+ ? ` ${theme.fg(toneColor(item.trend.tone), plainLine(item.trend.text))}`
955
+ : "";
956
+ return `${theme.fg("dim", plainLine(item.label))} ${theme.fg(color, plainLine(item.text))}${trend}`;
957
+ })
958
+ .join(theme.fg("dim", " · "))}`,
959
+ ];
960
+ case "grid":
961
+ return renderGrid(block, width, labelWidth, theme);
962
+ case "text":
963
+ return [`${INDENT}${theme.fg("dim", plainLine(block.text))}`];
964
+ }
965
+ }
966
+
967
+ /** Sparkline, latest value, then `┄ target` labels while the series keeps enough cells. */
968
+ function renderSpark(block: JobWidgetSparkBlock, width: number, labelWidth: number, theme: Theme): string {
969
+ const lead = ansiLead(block.label, labelWidth, theme);
970
+ if (block.values.length === 0) return `${lead}${theme.fg("dim", "waiting for data")}`;
971
+ const last = theme.bold(plainLine(block.last));
972
+ const marks = (block.marks ?? []).map(
973
+ mark => `${theme.fg(toneColor(mark.tone ?? "muted"), "┄")} ${theme.fg("dim", plainLine(mark.label))}`,
974
+ );
975
+ const room = width - visibleWidth(lead) - GAP.length;
976
+ let tail = [last, ...marks].join(GAP);
977
+ if (marks.length > 0 && room - visibleWidth(tail) < Math.min(ANSI_SPARK_MIN, block.values.length)) tail = last;
978
+ const cells = Math.max(1, room - visibleWidth(tail));
979
+ return `${lead}${ansiSpark(block, fitSparkValues(block.values, cells), theme)}${GAP}${tail}`;
980
+ }
981
+
982
+ function ansiSpark(block: JobWidgetSparkBlock, values: readonly number[], theme: Theme): string {
983
+ const color = toneColor(block.tone ?? DEFAULT_TONE);
984
+ return renderSparkline(values, {
985
+ min: block.min,
986
+ max: block.max,
987
+ style: glyph => theme.fg(color, glyph),
988
+ });
989
+ }
990
+
991
+ /** A `█…░` bar `room` cells wide at most (capped at 32), filled in the item's tone. */
992
+ function ansiBar(value: number, room: number, tone: TspTone | undefined, theme: Theme): string {
993
+ const color = toneColor(tone ?? DEFAULT_TONE);
994
+ return renderProgressBar(value, Math.max(0, Math.min(ANSI_BAR_MAX, room)), {
995
+ style: {
996
+ filled: "█",
997
+ empty: "░",
998
+ styleFilled: cells => theme.fg(color, cells),
999
+ styleEmpty: cells => theme.fg("dim", cells),
1000
+ },
1001
+ });
1002
+ }
1003
+
1004
+ /**
1005
+ * Tone-coloured cells: `● label` cells wrapped under the label column while
1006
+ * they fit in three lines, else one line of bare `●`/`◌` cells.
1007
+ */
1008
+ function renderGrid(block: JobWidgetGridBlock, width: number, labelWidth: number, theme: Theme): string[] {
1009
+ const lead = ansiLead(block.label, labelWidth, theme);
1010
+ if (block.items.length === 0) return [`${lead}${theme.fg("dim", "waiting for data")}`];
1011
+ const leadWidth = visibleWidth(lead);
1012
+ const room = width - leadWidth;
1013
+ // Pending, muted and neutral cells are still in progress (a hollow dot); every other tone is a settled state.
1014
+ const glyph = (tone: TspTone): string =>
1015
+ theme.fg(toneColor(tone), tone === "pending" || tone === "muted" || tone === "neutral" ? "◌" : "●");
1016
+ const rows: string[][] = [[]];
1017
+ let used = 0;
1018
+ for (const item of block.items) {
1019
+ const name = plainLine(item.label);
1020
+ const cellWidth = 2 + visibleWidth(name);
1021
+ const current = rows[rows.length - 1]!;
1022
+ const needed = current.length === 0 ? cellWidth : used + GAP.length + cellWidth;
1023
+ if (current.length > 0 && needed > room) {
1024
+ rows.push([]);
1025
+ used = cellWidth;
1026
+ } else {
1027
+ used = needed;
1028
+ }
1029
+ rows[rows.length - 1]!.push(`${glyph(item.tone)} ${theme.fg("toolOutput", name)}`);
1030
+ }
1031
+ if (rows.length <= GRID_LINES && rows.every(cells => visibleWidth(cells.join(GAP)) <= room)) {
1032
+ const pad = " ".repeat(leadWidth);
1033
+ return rows.map((cells, index) => `${index === 0 ? lead : pad}${cells.join(GAP)}`);
1034
+ }
1035
+ return [`${lead}${block.items.map(item => glyph(item.tone)).join(" ")}`];
1036
+ }