@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/hub.ts DELETED
@@ -1,1492 +0,0 @@
1
- import { styleTerminalRow } from "./terminal-output";
2
- import { TERMINAL_STATES } from "../apps/ps-data";
3
- import type { Component } from "../tui";
4
- import { Text } from "../components/text";
5
- import { visibleWidth } from "../utils";
6
- import { formatAge, pluralize } from "@oh-my-pi/pi-utils";
7
- import { shimmerEnabled, shimmerText } from "../theme/shimmer";
8
- import type { Theme, ThemeColor } from "../theme/theme";
9
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../render/index";
10
- import { framedToolCard } from "../render/tool-card";
11
- import { formatArtifactErrorNotice, stripOutputNotice, type OutputMeta } from "./output-meta";
12
- import {
13
- FEED_MODEL_BADGE_WIDTH,
14
- formatBadge,
15
- formatDuration,
16
- formatEmptyMessage,
17
- formatFeedModelBadge,
18
- formatStatusIcon,
19
- getPreviewLines,
20
- isFeedModelBadgeEnabled,
21
- PREVIEW_LIMITS,
22
- replaceTabs,
23
- type ToolUIColor,
24
- type ToolUIStatus,
25
- capPreviewLines,
26
- cappedHeadLines,
27
- createCachedComponent,
28
- DEFAULT_TERMINAL_PREVIEW_LINES,
29
- formatMoreItems,
30
- formatExpandHint,
31
- previewLine,
32
- TRUNCATE_LENGTHS,
33
- shortenPath,
34
- formatErrorDetail,
35
- type ConfiguredThinkingLevel,
36
- } from "../render/render-utils";
37
- import type { StructuredSubagentOutput } from "./task";
38
- import type { RenderResultOptions, ToolRenderer, ToolActivitySummary } from "./renderer";
39
-
40
- /** Whether a wait snapshot contains only running jobs and no cancellations. */
41
- export function isWaitingPollDetails(details: unknown): boolean {
42
- const d = details as CoordinationDetails | undefined;
43
- if (!d || !Array.isArray(d.jobs) || d.jobs.length === 0) return false;
44
- if (d.cancelled?.length) return false;
45
- return d.jobs.every(job => job?.status === "running");
46
- }
47
- /**
48
- * Hub operations: messaging (`send`/`wait`/`inbox`/`list`), jobs
49
- * (`wait`/`cancel`/`jobs`), and process supervision (`start`/`ps`/`logs`/
50
- * `stop`/`restart`/`describe`, plus `send`/`wait` when they carry `name`).
51
- */
52
- export type HubOp =
53
- | "send"
54
- | "wait"
55
- | "inbox"
56
- | "list"
57
- | "jobs"
58
- | "cancel"
59
- | "start"
60
- | "ps"
61
- | "logs"
62
- | "stop"
63
- | "restart"
64
- | "describe";
65
-
66
- /** Peer row surfaced by `op:"list"`. */
67
- export interface HubPeerInfo {
68
- id: string;
69
- displayName: string;
70
- kind: string;
71
- status: string;
72
- parentId?: string;
73
- unread: number;
74
- lastActivity: number;
75
- activity?: string;
76
- }
77
-
78
- /** Status values `op:"list"` can filter on. Advisor is a kind, not a status. */
79
- export type HubListStatus = "running" | "idle" | "parked";
80
-
81
- /** Model-facing roster bounds shared by the hub schema and executor. */
82
- export const DEFAULT_HUB_LIST_LIMIT = 32;
83
-
84
- /** Maximum number of peers in a hub roster page. */
85
- export const MAX_HUB_LIST_LIMIT = 100;
86
-
87
- /** Addressable roster tallies always returned by `op:"list"`. */
88
- export interface HubRosterCounts {
89
- running: number;
90
- idle: number;
91
- parked: number;
92
- shown: number;
93
- truncated: number;
94
- }
95
-
96
- /** Background-job row surfaced by `wait`/`cancel`/`jobs` results. */
97
- export interface JobSnapshot {
98
- id: string;
99
- type: "bash" | "task" | "eval";
100
- status: "running" | "completed" | "failed" | "cancelled";
101
- label: string;
102
- durationMs: number;
103
- /** Process exit status when the job reports one. */
104
- exitCode?: number;
105
- /** Effective task model selector, including an explicit reasoning suffix when configured. */
106
- resolvedModel?: string;
107
- /** Provider/id including routing, with no added thinking suffix. */
108
- resolvedModelIdentity?: string;
109
- /** Explicit thinking metadata, independent of the model identity. */
110
- resolvedThinkingLevel?: ConfiguredThinkingLevel;
111
- /** True when the task progress reports an attached live advisor. */
112
- advisor?: boolean;
113
- resultText?: string;
114
- errorText?: string;
115
- /** Source-output metadata retained for per-job warnings and persisted row rendering. */
116
- meta?: OutputMeta;
117
- /** Capture error in historical snapshots; new snapshots store source metadata in `meta`. */
118
- artifactError?: OutputMeta["artifactError"];
119
- structured?: StructuredSubagentOutput;
120
- /**
121
- * `agent://<id>` handle backing this job's artifacts — the job-row's
122
- * registry `agentId` when the manager disambiguated a requested job id
123
- * on collision, else the job id itself. See {@link AsyncJob.agentId}.
124
- */
125
- agentUrlId?: string;
126
- }
127
-
128
- /** Outcome of cancelling one background job. */
129
- export type CancelStatus = "cancelled" | "not_found" | "already_completed";
130
-
131
- /** Cancellation outcome and its model-facing explanation. */
132
- export interface CancelOutcome {
133
- id: string;
134
- status: CancelStatus;
135
- message: string;
136
- }
137
-
138
- /**
139
- * A live subagent from the AgentRegistry that has no backing job in the
140
- * AsyncJobManager — e.g. an idle agent woken (or a parked agent revived) via
141
- * a hub message, or a spawn owned by another agent. Surfaced by `jobs` and
142
- * empty-wait snapshots so the hub's picture matches the UI's running-agent
143
- * count.
144
- */
145
- export interface AgentActivitySnapshot {
146
- id: string;
147
- parentId?: string;
148
- /** Latest activity gist recorded by the registry (display-only). */
149
- activity?: string;
150
- /** Time since the agent was registered. */
151
- ageMs: number;
152
- /**
153
- * Whether an attached session corroborates the `running` claim. False marks
154
- * a ref that says `running` with no turn in flight — either a spawn still
155
- * wiring up or a stale registration that `hub cancel <id>` clears (#8634).
156
- */
157
- live: boolean;
158
- /**
159
- * Acceptance time of the agent's final result, when the registry recorded
160
- * one. With `live: false` this is an accepted run the parent may cancel
161
- * instead of waiting on (#11079).
162
- */
163
- acceptedAt?: number;
164
- }
165
-
166
- /** Result details for messaging and job ops; fields are disjoint per op. */
167
- export interface CoordinationDetails {
168
- meta?: OutputMeta;
169
- op: HubOp;
170
- from?: string;
171
- to?: string;
172
- receipts?: IrcDeliveryReceipt[];
173
- /** Message consumed by `wait` / `send await:true`; null when the wait timed out. */
174
- waited?: IrcMessage | null;
175
- inbox?: IrcMessage[];
176
- peers?: HubPeerInfo[];
177
- /** Present on `op:"list"`: addressable running/idle/parked plus page size. */
178
- counts?: HubRosterCounts;
179
- jobs?: JobSnapshot[];
180
- cancelled?: { id: string; status: CancelStatus }[];
181
- /** Running subagents not represented by a job row in this result. */
182
- agents?: AgentActivitySnapshot[];
183
- }
184
-
185
- /** Hub result details: coordination snapshots or launch (process) state. */
186
- export type HubDetails = CoordinationDetails | LaunchToolDetails;
187
-
188
- /** Partially-streamed hub call arguments, as seen by the renderers. */
189
- export type HubRenderArgs = {
190
- op?: string;
191
- to?: string;
192
- message?: string;
193
- replyTo?: string;
194
- await?: boolean;
195
- from?: string;
196
- peek?: boolean;
197
- ids?: string[];
198
- } & Partial<Omit<LaunchParams, "op">>;
199
- /** Stable lifecycle states exposed by the launch tool. */
200
- export type DaemonState = "starting" | "running" | "ready" | "restarting" | "stopping" | "exited" | "failed";
201
-
202
- /** Restart behavior applied after an unexpected daemon exit. */
203
- export type DaemonRestartPolicy = "no" | "on-failure" | "always";
204
-
205
- /** Readiness conditions; every configured condition must pass. */
206
- export interface DaemonReadySpec {
207
- log?: string;
208
- port?: number;
209
- host?: string;
210
- timeoutMs: number;
211
- }
212
-
213
- /** Immutable launch specification retained for restart and inspection. */
214
- export interface DaemonSpec {
215
- name: string;
216
- application: string;
217
- args: string[];
218
- env: Record<string, string>;
219
- cwd: string;
220
- pty: boolean;
221
- ready?: DaemonReadySpec;
222
- restart: DaemonRestartPolicy;
223
- persist: boolean;
224
- detached: boolean;
225
- }
226
-
227
- /** Serializable daemon state visible to every client in one broker scope. */
228
- export interface DaemonSnapshot {
229
- name: string;
230
- id: string;
231
- state: DaemonState;
232
- pid?: number;
233
- createdAt: number;
234
- startedAt: number;
235
- readyAt?: number;
236
- exitedAt?: number;
237
- exitCode?: number;
238
- exitReason?: string;
239
- restartCount: number;
240
- outputBytes: number;
241
- owner?: string;
242
- readyMatch?: string;
243
- /** Readiness conditions still unmet while `state` is `starting`; absent once ready or without a ready spec. */
244
- readyPending?: ("log" | "port")[];
245
- persist: boolean;
246
- detached: boolean;
247
- }
248
- /** Serializable peer message retained in hub result snapshots. */
249
- export interface IrcMessage {
250
- id: string;
251
- /** Sender agent id. */
252
- from: string;
253
- /** Recipient agent id (resolved; "all" is expanded by the tool, not stored). */
254
- to: string;
255
- body: string;
256
- ts: number;
257
- /** Message id being answered. */
258
- replyTo?: string;
259
- /**
260
- * Automated wake-turn relay of a woken subagent's stop output (task executor
261
- * `relayWakeTurnOutput`). Relays are answers, never wake sources: the
262
- * recipient's own wake-turn relay must skip them or two idle peers
263
- * ping-pong forever.
264
- */
265
- wakeRelay?: boolean;
266
- }
267
-
268
- /** Delivery outcome for one peer recipient. */
269
- export interface IrcDeliveryReceipt {
270
- to: string;
271
- outcome: "injected" | "woken" | "revived" | "failed";
272
- error?: string;
273
- }
274
- /** Broker-facing launch parameters; the hub adapts its `ps` op to `list` before calling in. */
275
- export interface LaunchParams {
276
- op: "start" | "list" | "logs" | "wait" | "send" | "stop" | "restart" | "describe";
277
- name?: string;
278
- application?: string;
279
- args?: string[];
280
- env?: Record<string, string>;
281
- cwd?: string;
282
- pty?: boolean;
283
- ready?: { log?: string; port?: number; host?: string; timeout?: number };
284
- restart?: "no" | "on-failure" | "always";
285
- persist?: boolean;
286
- detached?: boolean;
287
- lines?: number;
288
- head?: boolean;
289
- grep?: string;
290
- follow?: boolean;
291
- cursor?: number;
292
- for?: "ready" | "exit";
293
- pattern?: string;
294
- text?: string;
295
- enter?: boolean;
296
- keys?: string[];
297
- signal?: "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGQUIT" | "SIGKILL";
298
- timeout?: number;
299
- }
300
-
301
- /** Structured launch state retained for compact TUI rendering. */
302
- export interface LaunchToolDetails {
303
- op: LaunchParams["op"];
304
- daemon?: DaemonSnapshot;
305
- daemons?: DaemonSnapshot[];
306
- cursor?: number;
307
- timedOut?: boolean;
308
- /** logs: daemon lifecycle state at read time. */
309
- state?: DaemonState;
310
- /** logs: virtual terminal rows for display; model-facing content remains sanitized text. */
311
- terminalRows?: string[];
312
- /** wait: output line that satisfied the pattern. */
313
- matched?: string;
314
- /** describe: immutable launch spec backing the command/cwd detail lines. */
315
- spec?: DaemonSpec;
316
- }
317
-
318
- /**
319
- * Human sentences for the readiness conditions still unmet, e.g.
320
- * `port 5173 on 127.0.0.1 never accepted connections`. `ready` (from the start
321
- * params) adds the concrete pattern/port; absent it falls back to generic labels.
322
- */
323
- export function readyPendingSummary(daemon: DaemonSnapshot, ready?: LaunchParams["ready"]): string[] {
324
- const parts: string[] = [];
325
- for (const condition of daemon.readyPending ?? []) {
326
- if (condition === "log") {
327
- parts.push(ready?.log ? `log pattern /${ready.log}/ never matched` : "the log pattern never matched");
328
- } else {
329
- parts.push(
330
- ready?.port !== undefined
331
- ? `port ${ready.port} on ${ready.host ?? "127.0.0.1"} never accepted connections`
332
- : "the port never accepted connections",
333
- );
334
- }
335
- }
336
- return parts;
337
- }
338
-
339
- /**
340
- * What a timed-out `wait` was actually blocked on: the output `pattern`, the
341
- * process exiting (`for: "exit"`, the default), or the unmet readiness
342
- * conditions (`for: "ready"`). Never reports readiness for an exit/pattern
343
- * wait — the process may well be ready and simply still running.
344
- */
345
- export function waitPendingSummary(daemon: DaemonSnapshot, params: Pick<LaunchParams, "for" | "pattern">): string[] {
346
- if (params.pattern) return [`output pattern /${params.pattern}/ never matched`];
347
- if ((params.for ?? "exit") === "exit") return [`process exit (still ${daemon.state})`];
348
- const pending = readyPendingSummary(daemon);
349
- return pending.length > 0 ? pending : [`readiness (still ${daemon.state})`];
350
- }
351
- /** Hub roster ordering (running before idle before parked) shared with the child prompt's live-row cap. */
352
- export const LIST_STATUS_ORDER: Record<string, number> = { running: 0, idle: 1, parked: 2 };
353
-
354
- // =============================================================================
355
- // TUI Renderer (jobs half)
356
- // =============================================================================
357
-
358
- interface JobRenderArgs {
359
- poll?: string[];
360
- cancel?: string[];
361
- list?: boolean;
362
- }
363
-
364
- /** Hub args → legacy job-renderer arg shape, preserving the exact frame titles. */
365
- function toJobRenderArgs(args: HubRenderArgs | undefined): JobRenderArgs | undefined {
366
- if (!args) return undefined;
367
- switch (args.op) {
368
- case "wait":
369
- return { poll: args.ids };
370
- case "cancel":
371
- return { cancel: args.ids ?? [] };
372
- case "jobs":
373
- return { list: true };
374
- default:
375
- return {};
376
- }
377
- }
378
-
379
- const LABEL_MAX_WIDTH = 60;
380
- const PREVIEW_LINES_COLLAPSED = 1;
381
- const PREVIEW_LINES_EXPANDED = 4;
382
- const LABEL_LINES_COLLAPSED = 1;
383
- const LABEL_LINES_EXPANDED = 3;
384
- const PREVIEW_LINE_WIDTH = 80;
385
-
386
- function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus {
387
- switch (status) {
388
- case "completed":
389
- return "done";
390
- case "failed":
391
- return "error";
392
- case "cancelled":
393
- return "aborted";
394
- case "running":
395
- return "running";
396
- }
397
- }
398
-
399
- function statusToColor(status: JobSnapshot["status"]): ToolUIColor {
400
- switch (status) {
401
- case "completed":
402
- return "success";
403
- case "failed":
404
- return "error";
405
- case "cancelled":
406
- return "warning";
407
- case "running":
408
- return "accent";
409
- }
410
- }
411
-
412
- /**
413
- * Task job results are delivered in the model-facing `<task-result>` envelope
414
- * (prompts/tools/task-summary.md) so the parent agent can parse status and the
415
- * `agent://` pointer. The wrapper markup is noise to a human — preview the
416
- * inner <output>/<preview> body instead.
417
- */
418
- function stripTaskResultEnvelope(text: string): string {
419
- if (!text.startsWith("<task-result")) return text;
420
- const body = /<(output|preview)(?:\s[^>]*)?>\n?([\s\S]*?)\n?<\/\1>/.exec(text)?.[2];
421
- return body?.trim() || text;
422
- }
423
-
424
- /**
425
- * Pretty-printed JSON output wastes the collapsed one-line preview on a lone
426
- * "{" — flatten structured-looking bodies onto a single line. Slice first:
427
- * downstream truncation keeps at most a few hundred columns, so collapsing
428
- * whitespace across a multi-KB body would be pure waste.
429
- */
430
- function flattenStructuredPreview(text: string): string {
431
- const first = text[0];
432
- if (first !== "{" && first !== "[") return text;
433
- return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
434
- }
435
-
436
- function describeTarget(args: JobRenderArgs | undefined): string {
437
- if (args?.list) return "background jobs";
438
- const poll = args?.poll ?? [];
439
- const cancel = args?.cancel ?? [];
440
- const parts: string[] = [];
441
- if (cancel.length > 0) {
442
- parts.push(cancel.length === 1 ? `cancel ${cancel[0]}` : `cancel ${cancel.length} jobs`);
443
- }
444
- if (poll.length > 0) {
445
- parts.push(poll.length === 1 ? `poll ${poll[0]}` : `poll ${poll.length} jobs`);
446
- }
447
- if (parts.length === 0) return "all running jobs";
448
- return parts.join(", ");
449
- }
450
-
451
- /** Pending-call frame for job ops (wait/cancel/jobs). */
452
- export function jobsRenderCall(args: HubRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
453
- const text = renderStatusLine({ icon: "pending", title: describeTarget(toJobRenderArgs(args)) || "Job" }, uiTheme);
454
- return new Text(text, 0, 0);
455
- }
456
-
457
- /** Result frame for job snapshots (wait/cancel/jobs and the agents roster). */
458
- export function jobsRenderResult(
459
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
460
- options: RenderResultOptions,
461
- uiTheme: Theme,
462
- hubArgs?: HubRenderArgs,
463
- ): Component {
464
- const args = toJobRenderArgs(hubArgs);
465
- let jobs = result.details?.jobs ?? [];
466
- const agents = result.details?.agents ?? [];
467
-
468
- if (jobs.length === 0 && agents.length === 0) {
469
- const fallback = result.content?.find(c => c.type === "text")?.text || "No jobs to process";
470
- const header = renderStatusLine({ icon: "warning", title: describeTarget(args) || "Job" }, uiTheme);
471
- return new Text([header, formatEmptyMessage(fallback, uiTheme)].join("\n"), 0, 0);
472
- }
473
-
474
- const isPollCall = args ? !args.list && (!args.cancel || args.cancel.length === 0 || args.poll !== undefined) : true;
475
-
476
- // Agent-carrying results (jobs snapshot / empty-wait roster) are real
477
- // snapshots, not displaceable waiting frames — only agentless waits
478
- // collapse their still-running rows once sealed.
479
- if (!options.isPartial && isPollCall && agents.length === 0) {
480
- jobs = jobs.filter(job => job.status !== "running");
481
- if (jobs.length === 0) {
482
- return new Text("", 0, 0);
483
- }
484
- }
485
-
486
- const counts = { completed: 0, failed: 0, cancelled: 0, running: 0 };
487
- for (const job of jobs) counts[job.status]++;
488
-
489
- // The title already carries the running count, so meta lists only the
490
- // settled categories — "waiting on 19 of 19 · 19 running" read awkward.
491
- const meta: string[] = [];
492
- if (counts.completed > 0) meta.push(uiTheme.fg("success", `${counts.completed} done`));
493
- if (counts.failed > 0) meta.push(uiTheme.fg("error", `${counts.failed} failed`));
494
- if (counts.cancelled > 0) meta.push(uiTheme.fg("warning", `${counts.cancelled} cancelled`));
495
- if (agents.length > 0 && jobs.length > 0) {
496
- meta.push(uiTheme.fg("accent", `${agents.length} agent${agents.length === 1 ? "" : "s"}`));
497
- }
498
-
499
- const headerIcon: ToolUIStatus =
500
- counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success";
501
- const jobsNoun = jobs.length === 1 ? "job" : "jobs";
502
- const description =
503
- jobs.length === 0
504
- ? `${agents.length} running agent${agents.length === 1 ? "" : "s"} — no jobs`
505
- : counts.running > 0
506
- ? counts.running === jobs.length
507
- ? `waiting on ${jobs.length} ${jobsNoun}`
508
- : `waiting on ${counts.running} of ${jobs.length} ${jobsNoun}`
509
- : `${jobs.length} ${jobsNoun} settled`;
510
-
511
- const header = renderStatusLine(
512
- {
513
- icon: headerIcon,
514
- spinnerFrame: counts.running > 0 || agents.length > 0 ? options.spinnerFrame : undefined,
515
- title: description,
516
- meta,
517
- },
518
- uiTheme,
519
- );
520
-
521
- const outputMeta = result.details?.meta;
522
- // Historical snapshots promoted a job failure to the root. Render it there
523
- // only when no row identifies that failure; new report capture errors are distinct.
524
- const aggregateArtifactError =
525
- outputMeta?.artifactError &&
526
- (outputMeta.source?.type === "report" ||
527
- !jobs.some(job => (job.meta?.artifactError ?? job.artifactError) === outputMeta.artifactError))
528
- ? outputMeta.artifactError
529
- : undefined;
530
-
531
- // Sort: running first (so user sees what's still pending), then failed, then completed/cancelled.
532
- const statusOrder: Record<JobSnapshot["status"], number> = {
533
- running: 0,
534
- failed: 1,
535
- cancelled: 2,
536
- completed: 3,
537
- };
538
- const sortedJobs = [...jobs].sort((a, b) => {
539
- const diff = statusOrder[a.status] - statusOrder[b.status];
540
- if (diff !== 0) return diff;
541
- return b.durationMs - a.durationMs;
542
- });
543
-
544
- let cached: RenderCache | undefined;
545
- return {
546
- render(width: number): readonly string[] {
547
- const expanded = options.expanded;
548
- const spinnerFrame = options.spinnerFrame ?? 0;
549
- // Running-job labels shimmer while the wait block is live; the band
550
- // phase is Date.now()-sampled at render time, so serving cached bytes
551
- // would pin it to the ~12.5fps spinner-glyph cadence instead of the
552
- // 30fps redraw. Bypass the cache while any row animates, and key on
553
- // the animation state so a sealed block never hits stale shimmered
554
- // bytes (spinnerFrame falls back to 0 on both sides of the seal).
555
- const shimmerActive = counts.running > 0 && options.spinnerFrame !== undefined && shimmerEnabled();
556
- const showModelBadge = isFeedModelBadgeEnabled();
557
- const key = new Hasher()
558
- .bool(expanded)
559
- .u32(width)
560
- .u32(spinnerFrame)
561
- .bool(shimmerActive)
562
- .bool(showModelBadge)
563
- .digest();
564
- if (!shimmerActive && cached?.key === key) return cached.lines;
565
-
566
- const itemLines = renderTreeList<JobSnapshot>(
567
- {
568
- items: sortedJobs,
569
- expanded,
570
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
571
- itemType: "job",
572
- renderItem: (job, context) => {
573
- const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
574
- const lines: string[] = [];
575
- const icon = `${formatStatusIcon(
576
- statusToIcon(job.status),
577
- uiTheme,
578
- job.status === "running" ? options.spinnerFrame : undefined,
579
- )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
580
- const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
581
- const durationSuffix = `${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
582
- const displayId = truncateToWidth(
583
- replaceTabs(job.id).replace(/\s+/g, " "),
584
- Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
585
- Ellipsis.Unicode,
586
- );
587
- const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
588
- const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
589
- const visibleLabelLines = rawLabelLines
590
- .slice(0, maxLabelLines)
591
- .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
592
- if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
593
- const last = visibleLabelLines[visibleLabelLines.length - 1]!;
594
- visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
595
- }
596
- const rowPrefix = `${icon} ${typeBadge} `;
597
- const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
598
- const modelBadge =
599
- job.type === "task" && showModelBadge && typeof modelIdentity === "string"
600
- ? formatFeedModelBadge(
601
- modelIdentity,
602
- job.resolvedThinkingLevel,
603
- job.advisor === true,
604
- uiTheme,
605
- Math.min(
606
- FEED_MODEL_BADGE_WIDTH,
607
- Math.max(0, rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1),
608
- ),
609
- )
610
- : "";
611
- const modelLead = modelBadge ? `${modelBadge} ` : "";
612
- const headRaw = displayId;
613
- // Running rows in a live block shimmer their label; once the block
614
- // stops animating (sealed, or a settled snapshot — spinnerFrame
615
- // cleared) they render static so scrollback never keeps a mid-sweep
616
- // shimmer band.
617
- const live = job.status === "running" && options.spinnerFrame !== undefined;
618
- const headLabel = live
619
- ? shimmerEnabled()
620
- ? shimmerText(headRaw, uiTheme)
621
- : uiTheme.fg("accent", headRaw)
622
- : uiTheme.fg("toolOutput", headRaw);
623
- let row = `${rowPrefix}${modelLead}${headLabel}`;
624
- const distinctLabel = job.label.trim() !== job.id;
625
- const label = visibleLabelLines[0] ?? "";
626
- const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
627
- if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
628
- row += durationSuffix;
629
- lines.push(truncateToWidth(row, rowWidth, ""));
630
- const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
631
- for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
632
- lines.push(
633
- ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
634
- );
635
- }
636
- const artifactError = job.meta?.artifactError ?? job.artifactError;
637
- if (artifactError) {
638
- lines.push(
639
- uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth)),
640
- );
641
- }
642
-
643
- // Legacy rows did not retain full metadata. Strip the known warning
644
- // footer so the dedicated row/root warning remains the only copy.
645
- const previewError =
646
- artifactError ?? (outputMeta?.source?.type !== "report" ? outputMeta?.artifactError : undefined);
647
- const previewMeta = job.meta ?? (previewError ? { artifactError: previewError } : undefined);
648
-
649
- const preview = flattenStructuredPreview(
650
- stripTaskResultEnvelope(
651
- stripOutputNotice(job.errorText?.trim() || job.resultText?.trim() || "", previewMeta).trim(),
652
- ),
653
- );
654
- if (preview) {
655
- const maxLines = expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED;
656
- const previewLines = getPreviewLines(
657
- preview,
658
- maxLines,
659
- Math.min(PREVIEW_LINE_WIDTH, continuationWidth),
660
- Ellipsis.Unicode,
661
- );
662
- const tone = job.errorText ? "error" : "dim";
663
- for (const pl of previewLines) {
664
- lines.push(` ${uiTheme.fg(tone, pl)}`);
665
- }
666
- }
667
- return lines;
668
- },
669
- },
670
- uiTheme,
671
- );
672
-
673
- // Agents run outside job control; render them as their own tree so
674
- // they never skew the job counts or the "waiting on N jobs" title.
675
- const agentLines =
676
- agents.length === 0
677
- ? []
678
- : renderTreeList<AgentActivitySnapshot>(
679
- {
680
- items: agents,
681
- expanded,
682
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
683
- itemType: "agent",
684
- renderItem: (agent, context) => {
685
- const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
686
- const icon = agent.live
687
- ? formatStatusIcon("running", uiTheme, options.spinnerFrame)
688
- : formatStatusIcon("warning", uiTheme);
689
- const badge = agent.live
690
- ? formatBadge("agent", "accent", uiTheme)
691
- : formatBadge("agent · no turn", "warning", uiTheme);
692
- const id = truncateToWidth(
693
- replaceTabs(agent.id).replace(/\s+/g, " "),
694
- Math.max(0, rowWidth - visibleWidth(`${icon} ${badge}`)),
695
- Ellipsis.Unicode,
696
- );
697
- const gist = agent.activity
698
- ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}`
699
- : "";
700
- const parent = agent.parentId ? uiTheme.fg("dim", ` ← ${agent.parentId}`) : "";
701
- const age = uiTheme.fg("dim", formatDuration(agent.ageMs));
702
- return [
703
- truncateToWidth(
704
- `${icon} ${uiTheme.fg("muted", id)} ${badge}${gist} ${age}${parent}`,
705
- rowWidth,
706
- "",
707
- ),
708
- ];
709
- },
710
- },
711
- uiTheme,
712
- );
713
-
714
- const all = [header];
715
- if (aggregateArtifactError) {
716
- all.push(uiTheme.fg("warning", formatArtifactErrorNotice(aggregateArtifactError)));
717
- }
718
- all.push(...itemLines, ...agentLines);
719
- for (let i = 0; i < all.length; i++) all[i] = truncateToWidth(all[i]!, width, Ellipsis.Unicode);
720
- cached = { key, lines: all };
721
- return all;
722
- },
723
- invalidate() {
724
- cached = undefined;
725
- },
726
- };
727
- }
728
-
729
- // =============================================================================
730
- // TUI Renderer (launch half)
731
- // =============================================================================
732
-
733
- /** Args shape visible to the renderer, possibly mid-stream (every field optional). */
734
- export type LaunchRenderArgs = Partial<Omit<LaunchParams, "op">> & { op?: string };
735
-
736
- function stateColor(state: DaemonState): ThemeColor {
737
- switch (state) {
738
- case "running":
739
- case "ready":
740
- return "success";
741
- case "failed":
742
- return "error";
743
- case "exited":
744
- return "muted";
745
- default:
746
- return "warning";
747
- }
748
- }
749
-
750
- /** Compact `state · pid · uptime` fragments for the status-line meta slot. */
751
- function daemonMeta(daemon: DaemonSnapshot, theme: Theme): string[] {
752
- const meta = [theme.fg(stateColor(daemon.state), daemon.state)];
753
- if (daemon.readyPending?.length) meta.push(theme.fg("warning", `waiting on ${daemon.readyPending.join("+")}`));
754
- if (daemon.exitCode !== undefined) {
755
- meta.push(theme.fg(daemon.exitCode === 0 ? "muted" : "error", `exit ${daemon.exitCode}`));
756
- } else if (daemon.pid !== undefined) {
757
- meta.push(`pid ${daemon.pid}`);
758
- }
759
- const lifespan = formatDuration((daemon.exitedAt ?? Date.now()) - daemon.startedAt);
760
- meta.push(daemon.exitedAt === undefined ? `up ${lifespan}` : `ran ${lifespan}`);
761
- if (daemon.restartCount > 0) meta.push(`restarts ${daemon.restartCount}`);
762
- if (daemon.detached) meta.push("detached");
763
- else if (daemon.persist) meta.push("persistent");
764
- return meta;
765
- }
766
-
767
- /** Op-specific call context (command line, log filters, wait condition, send payload). */
768
- function launchCallMeta(args: LaunchRenderArgs): string[] {
769
- const meta: string[] = [];
770
- switch (args.op) {
771
- case "start":
772
- if (args.application) meta.push([args.application, ...(args.args ?? [])].join(" "));
773
- break;
774
- case "logs":
775
- if (args.follow) meta.push("follow");
776
- if (args.grep) meta.push(`grep /${args.grep}/`);
777
- break;
778
- case "wait":
779
- meta.push(args.pattern ? `for /${args.pattern}/` : `for ${args.for ?? "exit"}`);
780
- break;
781
- case "send":
782
- if (args.signal) meta.push(args.signal);
783
- else if (args.text) meta.push(args.text);
784
- if (args.keys?.length) meta.push(args.keys.join(" "));
785
- break;
786
- }
787
- return meta.map(entry => previewLine(replaceTabs(entry), TRUNCATE_LENGTHS.SHORT));
788
- }
789
-
790
- /** Pending-call frame for launch ops; consumes the spinner while the broker call is live. */
791
- export function launchRenderCall(args: LaunchRenderArgs, options: RenderResultOptions, theme: Theme): Component {
792
- const target = args.name ?? args.application;
793
- const header = renderStatusLine(
794
- {
795
- icon: options.spinnerFrame !== undefined ? "running" : "pending",
796
- spinnerFrame: options.spinnerFrame,
797
- title: `Launch ${args.op ?? "…"}`,
798
- description: target ? replaceTabs(target) : undefined,
799
- meta: launchCallMeta(args),
800
- },
801
- theme,
802
- );
803
- return new Text(header, 0, 0);
804
- }
805
-
806
- /** Result frame: one status header per op, meta from structured details, capped body lines. */
807
- export function launchRenderResult(
808
- result: { content: Array<{ type: string; text?: string }>; details?: LaunchToolDetails; isError?: boolean },
809
- options: RenderResultOptions,
810
- theme: Theme,
811
- args?: LaunchRenderArgs,
812
- ): Component {
813
- const details = result.details;
814
- const params = args ?? {};
815
- const op = details?.op ?? params.op;
816
- const isError = result.isError === true;
817
- const daemon = details?.daemon;
818
- const failed = isError || daemon?.state === "failed";
819
- const text =
820
- result.content
821
- ?.filter(item => item.type === "text")
822
- .map(item => item.text ?? "")
823
- .join("\n") ?? "";
824
-
825
- const meta: string[] = [];
826
- const body: string[] = [];
827
- let description = params.name ?? daemon?.name;
828
-
829
- if (isError) {
830
- for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("error", line));
831
- } else {
832
- switch (op) {
833
- case "start": {
834
- meta.push(...launchCallMeta(params));
835
- if (daemon) meta.push(...daemonMeta(daemon, theme));
836
- if (daemon?.readyMatch) body.push(theme.fg("dim", `log matched: ${replaceTabs(daemon.readyMatch)}`));
837
- if (daemon?.state === "failed" && daemon.exitReason)
838
- body.push(theme.fg("error", replaceTabs(daemon.exitReason)));
839
- if (details?.timedOut) {
840
- const pending = daemon ? readyPendingSummary(daemon, params.ready) : [];
841
- body.push(
842
- theme.fg(
843
- "warning",
844
- pending.length > 0
845
- ? `Not ready — ${pending.join("; ")}. Still running.`
846
- : "Readiness timed out; the process is still running.",
847
- ),
848
- );
849
- } else if (params.ready && daemon && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
850
- body.push(theme.fg("warning", "Process exited before readiness was observed."));
851
- }
852
- break;
853
- }
854
- case "send":
855
- meta.push(...launchCallMeta(params));
856
- if (daemon) meta.push(...daemonMeta(daemon, theme));
857
- break;
858
- case "stop":
859
- case "restart":
860
- if (daemon) meta.push(...daemonMeta(daemon, theme));
861
- break;
862
- case "wait": {
863
- meta.push(...launchCallMeta(params));
864
- if (daemon) meta.push(...daemonMeta(daemon, theme));
865
- if (details?.matched) body.push(theme.fg("dim", `matched: ${replaceTabs(details.matched)}`));
866
- if (details?.timedOut) {
867
- body.push(
868
- theme.fg(
869
- "warning",
870
- daemon
871
- ? `Wait timed out — still waiting on ${waitPendingSummary(daemon, params).join("; ")}.`
872
- : "Wait timed out.",
873
- ),
874
- );
875
- } else if (details && params.pattern && details.matched === undefined) {
876
- body.push(theme.fg("warning", `Process exited before output pattern /${params.pattern}/ matched.`));
877
- }
878
- break;
879
- }
880
- case "list": {
881
- const daemons = details?.daemons ?? [];
882
- description = `${daemons.length || "no"} ${pluralize("process", daemons.length)}`;
883
- for (const item of daemons) {
884
- body.push(
885
- `${theme.fg("accent", replaceTabs(item.name))} ${theme.fg("dim", daemonMeta(item, theme).join(theme.sep.dot))}`,
886
- );
887
- }
888
- break;
889
- }
890
- case "logs": {
891
- if (details?.state) meta.push(theme.fg(stateColor(details.state), details.state));
892
- if (details?.cursor !== undefined) meta.push(`cursor ${details.cursor}`);
893
- if (details?.timedOut) meta.push(theme.fg("warning", "follow timed out"));
894
- // Strip the trailing `[name: state; cursor=N]` status suffix `toolContent` appends.
895
- const logText = text.replace(/\n?\[[^\n]*\]$/, "").trimEnd();
896
- const terminalRows = details?.terminalRows;
897
- if (terminalRows) {
898
- for (const row of terminalRows) body.push(styleTerminalRow(row, theme.getFgAnsi("toolOutput")));
899
- } else if (logText) {
900
- for (const line of logText.split("\n")) body.push(theme.fg("toolOutput", replaceTabs(line)));
901
- }
902
- break;
903
- }
904
- case "describe": {
905
- if (daemon) meta.push(...daemonMeta(daemon, theme));
906
- const spec = details?.spec;
907
- if (spec) {
908
- body.push(theme.fg("toolOutput", replaceTabs([spec.application, ...spec.args].join(" "))));
909
- body.push(theme.fg("dim", `cwd ${shortenPath(spec.cwd)}`));
910
- const flags = [`pty ${spec.pty}`, `restart ${spec.restart}`];
911
- if (spec.detached) flags.push("detached");
912
- else if (spec.persist) flags.push("persistent");
913
- body.push(theme.fg("dim", flags.join(theme.sep.dot)));
914
- }
915
- break;
916
- }
917
- default:
918
- if (text.trim()) {
919
- for (const line of replaceTabs(text.trimEnd()).split("\n")) body.push(theme.fg("toolOutput", line));
920
- }
921
- }
922
- }
923
-
924
- const header = renderStatusLine(
925
- {
926
- ...(failed
927
- ? { icon: "error" as const }
928
- : options.isPartial
929
- ? { icon: "pending" as const }
930
- : { iconOverride: theme.styledSymbol("tool.launch", "accent") }),
931
- title: `Launch ${op ?? ""}`.trimEnd(),
932
- description: description ? replaceTabs(description) : undefined,
933
- meta,
934
- },
935
- theme,
936
- );
937
-
938
- if (op === "logs") {
939
- return framedToolCard(theme, ({ contentWidth }) => {
940
- const rows = body.map(line => truncateToWidth(line, contentWidth));
941
- return {
942
- header,
943
- phase: options.isPartial ? "partial" : failed ? "error" : "success",
944
- sections: [
945
- {
946
- label: theme.fg("toolTitle", "Output"),
947
- content: capPreviewLines(rows, theme, {
948
- expanded: options.expanded,
949
- max: DEFAULT_TERMINAL_PREVIEW_LINES,
950
- }),
951
- },
952
- ],
953
- };
954
- });
955
- }
956
-
957
- return createCachedComponent(
958
- () => options.expanded,
959
- (width, expanded) => {
960
- let visible = body;
961
- if (!expanded && op === "list" && body.length > PREVIEW_LIMITS.COLLAPSED_ITEMS) {
962
- const remaining = body.length - PREVIEW_LIMITS.COLLAPSED_ITEMS;
963
- visible = [
964
- ...body.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS),
965
- theme.fg("dim", `${formatMoreItems(remaining, "process")} ${formatExpandHint(theme, false, true)}`),
966
- ];
967
- }
968
- return [header, ...visible].map(line => truncateToWidth(line, width));
969
- },
970
- );
971
- }
972
-
973
- // =============================================================================
974
- // TUI Renderer (messaging half)
975
- // =============================================================================
976
-
977
- const BODY_LINES_COLLAPSED = 2;
978
- const BODY_LINES_EXPANDED = 12;
979
- const BODY_LINE_WIDTH = 100;
980
-
981
- function ircGlyph(theme: Theme): string {
982
- return theme.styledSymbol("tool.irc", "accent");
983
- }
984
-
985
- function outcomeColor(outcome: IrcDeliveryReceipt["outcome"]): ToolUIColor {
986
- switch (outcome) {
987
- case "woken":
988
- return "success";
989
- case "revived":
990
- return "warning";
991
- case "injected":
992
- return "accent";
993
- case "failed":
994
- return "error";
995
- }
996
- }
997
-
998
- /** Glyph + status word, matching the agent-hub status conventions. */
999
- function peerStatusBadge(status: string, theme: Theme): string {
1000
- switch (status) {
1001
- case "running":
1002
- return theme.fg("accent", `${theme.status.running} running`);
1003
- case "idle":
1004
- return theme.fg("success", `${theme.status.enabled} idle`);
1005
- case "parked":
1006
- return theme.fg("muted", `${theme.status.shadowed} parked`);
1007
- default:
1008
- return theme.fg("error", `${theme.status.aborted} ${status}`);
1009
- }
1010
- }
1011
-
1012
- function messageAge(ts: number | undefined): string {
1013
- if (!ts) return "";
1014
- return formatAge(Math.max(1, Math.round((Date.now() - ts) / 1000)));
1015
- }
1016
-
1017
- function firstTextBlock(result: { content: Array<{ type: string; text?: string }> }): string {
1018
- return result.content.find(part => part.type === "text")?.text?.trim() ?? "";
1019
- }
1020
-
1021
- /**
1022
- * Quote-bordered message body preview. `tone` separates outbound text (dim)
1023
- * from received text (toolOutput); a trailing dim counter marks elided lines.
1024
- */
1025
- function bodyLines(
1026
- body: string,
1027
- expanded: boolean,
1028
- theme: Theme,
1029
- options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
1030
- ): string[] {
1031
- const indent = options.indent ?? "";
1032
- const tone = options.tone ?? "toolOutput";
1033
- const max = expanded ? BODY_LINES_EXPANDED : (options.collapsedLines ?? BODY_LINES_COLLAPSED);
1034
- const preview = cappedHeadLines(
1035
- body.split("\n").filter(line => line.trim()),
1036
- max,
1037
- );
1038
- const quote = theme.fg("dim", theme.md.quoteBorder);
1039
- const lines = preview.lines.map(
1040
- line =>
1041
- `${indent}${quote} ${theme.fg(tone, replaceTabs(truncateToWidth(line.trim(), BODY_LINE_WIDTH, Ellipsis.Unicode)))}`,
1042
- );
1043
- const hidden = preview.hidden;
1044
- if (hidden > 0) {
1045
- lines.push(`${indent}${quote} ${theme.fg("dim", `… +${hidden} more ${hidden === 1 ? "line" : "lines"}`)}`);
1046
- }
1047
- return lines;
1048
- }
1049
-
1050
- /** Header title carrying the op direction: `IRC ➤ peer` out, `IRC ⟵ peer` in. */
1051
- function callTitle(args: HubRenderArgs | undefined, theme: Theme): string {
1052
- switch (args?.op) {
1053
- case "send":
1054
- return `IRC ${theme.nav.selected} ${args.to?.trim() || "…"}`;
1055
- case "wait":
1056
- return `IRC ${theme.nav.back} ${args.from?.trim() || "anyone"}`;
1057
- case "inbox":
1058
- return "IRC inbox";
1059
- case "list":
1060
- return "IRC peers";
1061
- default:
1062
- return "Hub";
1063
- }
1064
- }
1065
-
1066
- function messagingCallMeta(args: HubRenderArgs | undefined): string[] {
1067
- const meta: string[] = [];
1068
- if (args?.op === "send") {
1069
- if (args.to === "all") meta.push("broadcast");
1070
- if (args.await) meta.push("await reply");
1071
- if (args.replyTo) meta.push("reply");
1072
- }
1073
- if (args?.op === "inbox" && args.peek) meta.push("peek");
1074
- return meta;
1075
- }
1076
-
1077
- function renderErrorResult(
1078
- result: { content: Array<{ type: string; text?: string }> },
1079
- args: HubRenderArgs | undefined,
1080
- theme: Theme,
1081
- ): string[] {
1082
- const text = firstTextBlock(result) || "IRC call failed.";
1083
- return [
1084
- renderStatusLine({ icon: "error", title: callTitle(args, theme), meta: messagingCallMeta(args) }, theme),
1085
- formatErrorDetail(text, theme),
1086
- ];
1087
- }
1088
-
1089
- /**
1090
- * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
1091
- * delivered to this session, `irc:autoreply` side-channel replies sent on
1092
- * this session's behalf, and `irc:relay` observations of agent↔agent
1093
- * traffic. Shares the tool renderer's glyph + quote-border conventions so
1094
- * cards and hub messaging output look identical in the transcript.
1095
- */
1096
- export function createIrcMessageCard(
1097
- card: {
1098
- kind: "incoming" | "autoreply" | "relay" | "workpool";
1099
- from?: string;
1100
- to?: string;
1101
- body?: string;
1102
- replyTo?: string;
1103
- timestamp?: number;
1104
- pool?: string;
1105
- mode?: string;
1106
- },
1107
- getExpanded: () => boolean,
1108
- uiTheme: Theme,
1109
- ): Component {
1110
- const from = card.from?.trim() || "?";
1111
- const title =
1112
- card.kind === "incoming"
1113
- ? `IRC ${uiTheme.nav.back} ${from}`
1114
- : card.kind === "autoreply"
1115
- ? `IRC ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
1116
- : card.kind === "workpool"
1117
- ? `Pool ${card.pool?.trim() || "?"} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`
1118
- : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
1119
- const body = card.body ?? "";
1120
- const meta: string[] = [];
1121
- if (card.kind === "autoreply") meta.push("auto");
1122
- if (card.kind === "workpool" && card.mode) meta.push(card.mode);
1123
- if (card.replyTo) meta.push("reply");
1124
- const age = messageAge(card.timestamp);
1125
- if (age) meta.push(age);
1126
- return createCachedComponent(
1127
- getExpanded,
1128
- (width, expanded) => {
1129
- const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
1130
- if (body.trim()) {
1131
- lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
1132
- }
1133
- return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
1134
- },
1135
- { paddingX: 1 },
1136
- );
1137
- }
1138
-
1139
- function renderSendResult(
1140
- result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
1141
- details: Partial<CoordinationDetails>,
1142
- args: HubRenderArgs | undefined,
1143
- expanded: boolean,
1144
- theme: Theme,
1145
- ): string[] {
1146
- const receipts = details.receipts ?? [];
1147
- const to = details.to ?? args?.to?.trim() ?? "?";
1148
- const title = `IRC ${theme.nav.selected} ${to}`;
1149
-
1150
- // Pre-delivery failures (validation) and empty broadcasts carry no receipts.
1151
- if (receipts.length === 0) {
1152
- const text = firstTextBlock(result) || (result.isError ? "Send failed." : "Nothing to deliver.");
1153
- return [
1154
- renderStatusLine({ icon: result.isError ? "error" : "warning", title }, theme),
1155
- result.isError ? formatErrorDetail(text, theme) : ` ${theme.fg("muted", replaceTabs(text))}`,
1156
- ];
1157
- }
1158
-
1159
- const delivered = receipts.filter(receipt => receipt.outcome !== "failed");
1160
- const failedCount = receipts.length - delivered.length;
1161
- const waited = details.waited;
1162
- const timedOut = waited === null;
1163
-
1164
- const meta: string[] = [];
1165
- if (to === "all") meta.push("broadcast");
1166
- if (receipts.length === 1) {
1167
- const receipt = receipts[0]!;
1168
- meta.push(theme.fg(outcomeColor(receipt.outcome), receipt.outcome));
1169
- } else {
1170
- if (delivered.length > 0) meta.push(theme.fg("success", `${delivered.length} delivered`));
1171
- if (failedCount > 0) meta.push(theme.fg("error", `${failedCount} failed`));
1172
- }
1173
- if (timedOut) meta.push(theme.fg("warning", "no reply"));
1174
-
1175
- const icon = result.isError
1176
- ? { icon: "error" as const }
1177
- : timedOut
1178
- ? { icon: "warning" as const }
1179
- : { iconOverride: ircGlyph(theme) };
1180
- const lines = [renderStatusLine({ ...icon, title, meta }, theme)];
1181
-
1182
- const sent = args?.message?.trim();
1183
- if (sent) lines.push(...bodyLines(sent, expanded, theme, { indent: " ", tone: "dim" }));
1184
-
1185
- if (receipts.length > 1 || failedCount > 0) {
1186
- lines.push(
1187
- ...renderTreeList<IrcDeliveryReceipt>(
1188
- {
1189
- items: receipts,
1190
- expanded,
1191
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
1192
- itemType: "recipient",
1193
- renderItem: receipt => {
1194
- const badge = formatBadge(receipt.outcome, outcomeColor(receipt.outcome), theme);
1195
- const error =
1196
- receipt.outcome === "failed" && receipt.error
1197
- ? ` ${theme.fg("error", `${theme.format.dash} ${receipt.error}`)}`
1198
- : "";
1199
- return `${theme.fg("toolOutput", receipt.to)} ${badge}${error}`;
1200
- },
1201
- },
1202
- theme,
1203
- ),
1204
- );
1205
- }
1206
-
1207
- if (waited) {
1208
- const age = messageAge(waited.ts);
1209
- lines.push(
1210
- ` ${theme.fg("dim", theme.nav.back)} ${theme.fg("accent", waited.from)}${age ? ` ${theme.fg("dim", age)}` : ""}`,
1211
- );
1212
- lines.push(...bodyLines(waited.body, expanded, theme, { indent: " " }));
1213
- } else if (timedOut) {
1214
- lines.push(` ${theme.fg("warning", "No reply yet — they may answer later; check inbox or wait again.")}`);
1215
- }
1216
- return lines;
1217
- }
1218
-
1219
- function renderWaitResult(
1220
- result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
1221
- details: Partial<CoordinationDetails>,
1222
- args: HubRenderArgs | undefined,
1223
- expanded: boolean,
1224
- theme: Theme,
1225
- ): string[] {
1226
- const waited = details.waited;
1227
- if (!waited) {
1228
- const text = firstTextBlock(result) || "No message arrived.";
1229
- return [
1230
- renderStatusLine(
1231
- { icon: "warning", title: `IRC ${theme.nav.back} ${args?.from?.trim() || "anyone"}`, meta: ["timed out"] },
1232
- theme,
1233
- ),
1234
- ` ${theme.fg("muted", replaceTabs(text))}`,
1235
- ];
1236
- }
1237
- const meta = [messageAge(waited.ts)];
1238
- if (waited.replyTo) meta.push("reply");
1239
- return [
1240
- renderStatusLine({ iconOverride: ircGlyph(theme), title: `IRC ${theme.nav.back} ${waited.from}`, meta }, theme),
1241
- ...bodyLines(waited.body, expanded, theme, { indent: " " }),
1242
- ];
1243
- }
1244
-
1245
- function renderInboxResult(
1246
- details: Partial<CoordinationDetails>,
1247
- args: HubRenderArgs | undefined,
1248
- expanded: boolean,
1249
- theme: Theme,
1250
- ): string[] {
1251
- const messages = details.inbox ?? [];
1252
- if (messages.length === 0) {
1253
- return [renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC inbox", meta: ["empty"] }, theme)];
1254
- }
1255
- const meta = [`${messages.length} ${messages.length === 1 ? "message" : "messages"}`];
1256
- if (args?.peek) meta.push("peek");
1257
- const header = renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC inbox", meta }, theme);
1258
- const items = renderTreeList<IrcMessage>(
1259
- {
1260
- items: messages,
1261
- expanded,
1262
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
1263
- itemType: "message",
1264
- renderItem: msg => {
1265
- const age = messageAge(msg.ts);
1266
- const replyBadge = msg.replyTo ? ` ${formatBadge("reply", "muted", theme)}` : "";
1267
- const head = `${theme.fg("accent", msg.from)}${age ? ` ${theme.fg("dim", age)}` : ""}${replyBadge}`;
1268
- return [head, ...bodyLines(msg.body, expanded, theme, { collapsedLines: 1 })];
1269
- },
1270
- },
1271
- theme,
1272
- );
1273
- return [header, ...items];
1274
- }
1275
-
1276
- function renderListResult(details: Partial<CoordinationDetails>, expanded: boolean, theme: Theme): string[] {
1277
- const peers = [...(details.peers ?? [])].sort(
1278
- (a, b) =>
1279
- (LIST_STATUS_ORDER[a.status] ?? 9) - (LIST_STATUS_ORDER[b.status] ?? 9) || b.lastActivity - a.lastActivity,
1280
- );
1281
- const rosterCounts = details.counts;
1282
- if (peers.length === 0) {
1283
- const meta =
1284
- rosterCounts && rosterCounts.running + rosterCounts.idle + rosterCounts.parked > 0
1285
- ? [
1286
- `${rosterCounts.running} running`,
1287
- `${rosterCounts.idle} idle`,
1288
- `${rosterCounts.parked} parked`,
1289
- ...(rosterCounts.truncated > 0 ? [`${rosterCounts.truncated} truncated`] : []),
1290
- ]
1291
- : ["no other agents"];
1292
- return [renderStatusLine({ icon: "info", title: "IRC peers", meta }, theme)];
1293
- }
1294
- const counts = new Map<string, number>();
1295
- for (const peer of peers) counts.set(peer.status, (counts.get(peer.status) ?? 0) + 1);
1296
- const meta = rosterCounts
1297
- ? [
1298
- `${rosterCounts.running} running`,
1299
- `${rosterCounts.idle} idle`,
1300
- `${rosterCounts.parked} parked`,
1301
- ...(rosterCounts.truncated > 0 ? [`${rosterCounts.truncated} truncated`] : []),
1302
- ]
1303
- : [...counts].map(([status, count]) => `${count} ${status}`);
1304
- const unreadTotal = peers.reduce((sum, peer) => sum + peer.unread, 0);
1305
- if (unreadTotal > 0) meta.push(theme.fg("warning", `${unreadTotal} unread`));
1306
- const header = renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC peers", meta }, theme);
1307
- const items = renderTreeList(
1308
- {
1309
- items: peers,
1310
- expanded,
1311
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
1312
- itemType: "peer",
1313
- renderItem: peer => {
1314
- const kindText = peer.parentId ? `${peer.kind}${theme.sep.dot}of ${peer.parentId}` : peer.kind;
1315
- const unread = peer.unread > 0 ? ` ${formatBadge(`${peer.unread} unread`, "warning", theme)}` : "";
1316
- const age = messageAge(peer.lastActivity);
1317
- const activity = peer.activity ? ` ${theme.fg("dim", replaceTabs(peer.activity))}` : "";
1318
- const name = theme.fg("dim", replaceTabs(peer.displayName));
1319
- return `${peerStatusBadge(peer.status, theme)} ${theme.bold(replaceTabs(peer.id))} ${name} ${theme.fg("dim", kindText)}${activity}${unread}${age ? ` ${theme.fg("dim", age)}` : ""}`;
1320
- },
1321
- },
1322
- theme,
1323
- );
1324
- return [header, ...items];
1325
- }
1326
- function buildResultLines(
1327
- result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
1328
- details: Partial<CoordinationDetails>,
1329
- args: HubRenderArgs | undefined,
1330
- expanded: boolean,
1331
- theme: Theme,
1332
- ): string[] {
1333
- switch (details.op ?? args?.op) {
1334
- case "send":
1335
- return renderSendResult(result, details, args, expanded, theme);
1336
- case "wait":
1337
- return renderWaitResult(result, details, args, expanded, theme);
1338
- case "inbox":
1339
- return result.isError
1340
- ? renderErrorResult(result, args, theme)
1341
- : renderInboxResult(details, args, expanded, theme);
1342
- case "list":
1343
- return result.isError ? renderErrorResult(result, args, theme) : renderListResult(details, expanded, theme);
1344
- default: {
1345
- const text = firstTextBlock(result) || (result.isError ? "Hub call failed." : "Done.");
1346
- return [
1347
- renderStatusLine({ icon: result.isError ? "error" : "success", title: callTitle(args, theme) }, theme),
1348
- result.isError ? formatErrorDetail(text, theme) : ` ${theme.fg("muted", replaceTabs(text))}`,
1349
- ];
1350
- }
1351
- }
1352
- }
1353
-
1354
- /** Pending-call frame for messaging ops (send/wait-from/inbox/list). */
1355
- export function messagingRenderCall(args: HubRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
1356
- const lines = [
1357
- renderStatusLine({ icon: "pending", title: callTitle(args, uiTheme), meta: messagingCallMeta(args) }, uiTheme),
1358
- ];
1359
- if (args?.op === "send" && args.message?.trim()) {
1360
- lines.push(...bodyLines(args.message, false, uiTheme, { indent: " ", tone: "dim", collapsedLines: 1 }));
1361
- }
1362
- return new Text(lines.join("\n"), 0, 0);
1363
- }
1364
-
1365
- /** Result frame for messaging ops and message-carrying `wait` results. */
1366
- export function messagingRenderResult(
1367
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
1368
- options: RenderResultOptions,
1369
- uiTheme: Theme,
1370
- args?: HubRenderArgs,
1371
- ): Component {
1372
- const details: Partial<CoordinationDetails> = result.details ?? {};
1373
- return createCachedComponent(
1374
- () => options.expanded,
1375
- (width, expanded) =>
1376
- buildResultLines(result, details, args, expanded, uiTheme).map(line =>
1377
- truncateToWidth(line, width, Ellipsis.Unicode),
1378
- ),
1379
- );
1380
- }
1381
-
1382
- // =============================================================================
1383
- // TUI Renderer — dispatches to the preserved messaging/job/launch renderings.
1384
- // =============================================================================
1385
-
1386
- const LAUNCH_OPS: Record<string, true> = {
1387
- start: true,
1388
- ps: true,
1389
- logs: true,
1390
- stop: true,
1391
- restart: true,
1392
- describe: true,
1393
- };
1394
-
1395
- /** Launch-style call: an explicit process op, or `send`/`wait` targeting a process `name`. */
1396
- function isLaunchStyleArgs(args: HubRenderArgs | undefined): boolean {
1397
- if (!args?.op) return false;
1398
- if (LAUNCH_OPS[args.op]) return true;
1399
- return (args.op === "send" || args.op === "wait") && !!args.name && !args.to && !args.from;
1400
- }
1401
-
1402
- /** Job-style call: job ops, or a `wait` that does not target a peer or process. */
1403
- function isJobStyleArgs(args: HubRenderArgs | undefined): boolean {
1404
- switch (args?.op) {
1405
- case "jobs":
1406
- case "cancel":
1407
- return true;
1408
- case "wait":
1409
- return !!args.ids?.length || (!args.from && !args.name);
1410
- default:
1411
- return false;
1412
- }
1413
- }
1414
-
1415
- /** Launch details carry process/broker state; coordination details never define these keys. */
1416
- function isLaunchDetails(details: HubDetails): details is LaunchToolDetails {
1417
- // `state`/`cursor` cover logs results, which may carry neither a daemon
1418
- // snapshot nor terminal rows; coordination details never define these keys.
1419
- return (
1420
- "daemon" in details ||
1421
- "daemons" in details ||
1422
- "terminalRows" in details ||
1423
- "spec" in details ||
1424
- "state" in details ||
1425
- "cursor" in details
1426
- );
1427
- }
1428
-
1429
- /** Hub args → launch renderer args: `ps` is the broker's `list`; everything else is verbatim. */
1430
- function toLaunchArgs(args: HubRenderArgs | undefined): LaunchRenderArgs {
1431
- if (!args) return {};
1432
- const { op, ...rest } = args;
1433
- return { ...rest, op: op === "ps" ? "list" : op };
1434
- }
1435
-
1436
- /** Renders hub messaging, jobs, and supervised processes. */
1437
- export const hubToolRenderer = {
1438
- inline: true,
1439
- mergeCallAndResult: true,
1440
- /** Compact one-line activity: op plus its peer, process, or job target. */
1441
- activitySummary(args: unknown): ToolActivitySummary {
1442
- const hubArgs = (args ?? {}) as HubRenderArgs;
1443
- const op = hubArgs.op;
1444
- if (!op) return { label: "Hub" };
1445
- let detail = op;
1446
- if (op === "send" && (hubArgs.to || hubArgs.name)) detail = `send → ${hubArgs.to ?? hubArgs.name}`;
1447
- else if (op === "wait" && (hubArgs.from || hubArgs.name)) detail = `wait ${hubArgs.from ?? hubArgs.name}`;
1448
- else if ((op === "wait" || op === "cancel") && hubArgs.ids?.length) {
1449
- detail = `${op} ${hubArgs.ids.length} job${hubArgs.ids.length === 1 ? "" : "s"}`;
1450
- } else if (hubArgs.name) detail = `${op} ${hubArgs.name}`;
1451
- return { label: "Hub", detail };
1452
- },
1453
- // Only launch pending frames consume the spinner (broker RPC in flight);
1454
- // messaging/job pending frames are static, exactly as before the merge.
1455
- animatedPendingPreview: (args: unknown): boolean => isLaunchStyleArgs(args as HubRenderArgs | undefined),
1456
-
1457
- renderCall(args: HubRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
1458
- if (isLaunchStyleArgs(args)) return launchRenderCall(toLaunchArgs(args), options, uiTheme);
1459
- return isJobStyleArgs(args)
1460
- ? jobsRenderCall(args, options, uiTheme)
1461
- : messagingRenderCall(args, options, uiTheme);
1462
- },
1463
-
1464
- renderResult(
1465
- result: { content: Array<{ type: string; text?: string }>; details?: HubDetails; isError?: boolean },
1466
- options: RenderResultOptions,
1467
- uiTheme: Theme,
1468
- args?: HubRenderArgs,
1469
- ): Component {
1470
- // Results dispatch on what actually happened, falling back to the call
1471
- // shape when details are absent (framework-generated errors).
1472
- const details = result.details;
1473
- if (details && isLaunchDetails(details)) {
1474
- return launchRenderResult({ ...result, details }, options, uiTheme, toLaunchArgs(args));
1475
- }
1476
- const coordination = details;
1477
- if (coordination && (Array.isArray(coordination.jobs) || Array.isArray(coordination.agents))) {
1478
- return jobsRenderResult({ ...result, details: coordination }, options, uiTheme, args);
1479
- }
1480
- if (
1481
- coordination &&
1482
- ("receipts" in coordination || "waited" in coordination || "inbox" in coordination || "peers" in coordination)
1483
- ) {
1484
- return messagingRenderResult({ ...result, details: coordination }, options, uiTheme, args);
1485
- }
1486
- // Detail-less or op-only results (validation errors, disabled gates).
1487
- if (isLaunchStyleArgs(args))
1488
- return launchRenderResult({ ...result, details: undefined }, options, uiTheme, toLaunchArgs(args));
1489
- if (isJobStyleArgs(args)) return jobsRenderResult({ ...result, details: coordination }, options, uiTheme, args);
1490
- return messagingRenderResult({ ...result, details: coordination }, options, uiTheme, args);
1491
- },
1492
- } satisfies ToolRenderer<HubRenderArgs, HubDetails>;