@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
@@ -1,5 +1,5 @@
1
1
  import type { MermaidRenderOptions } from "@oh-my-pi/pi-natives";
2
- import { renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils/mermaid-ascii";
2
+ import * as mermaidAscii from "@oh-my-pi/pi-utils/mermaid-ascii";
3
3
 
4
4
  /**
5
5
  * Options controlling how fenced Mermaid source is resolved to terminal ASCII.
@@ -8,12 +8,10 @@ import { renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils/mermaid-ascii";
8
8
  */
9
9
  export interface MermaidResolveOptions extends MermaidRenderOptions {
10
10
  /**
11
- * Maximum display width (terminal columns) the diagram should occupy. A
12
- * layout that overflows this width is re-rendered in the perpendicular
13
- * orientation — a wide horizontal chain collapses to a tall vertical column
14
- * (which the terminal can scroll), and a wide vertical fan-out collapses to a
15
- * tall horizontal column. Omit to keep the source's own layout regardless of
16
- * width.
11
+ * Maximum display width (terminal columns) the diagram should occupy.
12
+ * Flowcharts are also rendered top-down and left-to-right. A resize picks
13
+ * the shortest variant that fits; if none fit, the narrowest, which the
14
+ * caller may still clip. Omit to keep the source's own layout.
17
15
  */
18
16
  maxWidth?: number;
19
17
  }
@@ -23,16 +21,29 @@ export interface MermaidResolveOptions extends MermaidRenderOptions {
23
21
  // the cached renders, so a terminal resize re-decides without re-rendering.
24
22
  const cache = new Map<string, string | null>();
25
23
 
26
- /** Widest rendered row in display columns (ANSI- and CJK-aware). */
24
+ /** Display columns, ignoring ANSI so themed diagrams are measured as drawn. */
25
+ const DISPLAY_WIDTH = { countAnsiEscapeCodes: false } as const;
26
+
27
27
  function asciiDisplayWidth(ascii: string): number {
28
28
  let max = 0;
29
29
  for (const line of ascii.split("\n")) {
30
- const width = Bun.stringWidth(line);
30
+ const width = Bun.stringWidth(line, DISPLAY_WIDTH);
31
31
  if (width > max) max = width;
32
32
  }
33
33
  return max;
34
34
  }
35
35
 
36
+ interface LayoutCandidate {
37
+ ascii: string;
38
+ width: number;
39
+ height: number;
40
+ authored: boolean;
41
+ }
42
+
43
+ function measureLayout(ascii: string, authored: boolean): LayoutCandidate {
44
+ return { ascii, width: asciiDisplayWidth(ascii), height: ascii.split("\n").length, authored };
45
+ }
46
+
36
47
  function renderVariant(
37
48
  source: string,
38
49
  baseOptions: MermaidRenderOptions,
@@ -43,11 +54,39 @@ function renderVariant(
43
54
  const cached = cache.get(key);
44
55
  if (cached !== undefined) return cached;
45
56
 
46
- const ascii = renderMermaidAsciiSafe(source, direction ? { ...baseOptions, direction } : baseOptions);
57
+ const ascii = mermaidAscii.renderMermaidAsciiSafe(source, direction ? { ...baseOptions, direction } : baseOptions);
47
58
  cache.set(key, ascii);
48
59
  return ascii;
49
60
  }
50
61
 
62
+ const FORCED_DIRECTIONS = ["TD", "LR"] as const;
63
+ type ForcedDirection = (typeof FORCED_DIRECTIONS)[number];
64
+
65
+ /**
66
+ * Forced TD/LR renders that can differ from the authored one. Sequence, class,
67
+ * ER, and xychart ignore direction, so they get none; header match mirrors
68
+ * native `detect_diagram_kind`, including its JavaScript `\w` boundary
69
+ * (`[A-Za-z0-9_]`). A `graph`/`flowchart` header already authored in one of
70
+ * the forced layouts (`TD`/`TB` lay out as TD, `LR`/`RL` as LR) skips that
71
+ * variant: the native override replaces the header direction, so the render
72
+ * would be byte-identical to the authored one.
73
+ */
74
+ function forcedDirections(source: string): readonly ForcedDirection[] {
75
+ const header = (source.split("\n", 1)[0] ?? "").trim().toLowerCase();
76
+ for (const prefix of ["xychart-beta", "xychart"] as const) {
77
+ if (!header.startsWith(prefix)) continue;
78
+ const next = header.charCodeAt(prefix.length);
79
+ const wordChar =
80
+ (next >= 48 && next <= 57) || (next >= 65 && next <= 90) || (next >= 97 && next <= 122) || next === 95;
81
+ if (!wordChar) return [];
82
+ }
83
+ if (header === "sequencediagram" || header === "classdiagram" || header === "erdiagram") return [];
84
+ const authored = /^(?:graph|flowchart)\s+(td|tb|lr|rl)$/.exec(header)?.[1];
85
+ if (authored === "td" || authored === "tb") return ["LR"];
86
+ if (authored === "lr" || authored === "rl") return ["TD"];
87
+ return FORCED_DIRECTIONS;
88
+ }
89
+
51
90
  /**
52
91
  * Resolve mermaid ASCII from fenced block source text.
53
92
  * Returns null when rendering fails, while memoizing failures to avoid repeated work.
@@ -65,24 +104,43 @@ export function resolveMermaidAscii(source: string, options?: MermaidResolveOpti
65
104
  if (base === null) return null;
66
105
  if (maxWidth === undefined) return base;
67
106
 
68
- let best = base;
69
- let bestWidth = asciiDisplayWidth(base);
70
- if (bestWidth <= maxWidth) return base;
71
-
72
- // The as-authored layout overflows. Render both forced orientations and keep
73
- // the narrowest (clipping at the call site handles any residual overflow).
74
- // Re-rendering the already-authored orientation is a cache hit, so this stays
75
- // cheap, and one of the two will be the perpendicular fit.
76
- for (const direction of ["TD", "LR"] as const) {
107
+ // Width selection is against cached renders, so a later resize re-decides
108
+ // without drawing again. Only forced layouts that can differ from the
109
+ // authored one are rendered (see `forcedDirections`).
110
+ const candidates = [measureLayout(base, true)];
111
+ for (const direction of forcedDirections(normalizedSource)) {
77
112
  const variant = renderVariant(normalizedSource, baseOptions, baseKey, direction);
78
- if (variant === null) continue;
79
- const variantWidth = asciiDisplayWidth(variant);
80
- if (variantWidth < bestWidth) {
81
- best = variant;
82
- bestWidth = variantWidth;
113
+ if (variant !== null) candidates.push(measureLayout(variant, false));
114
+ }
115
+
116
+ const fitting = candidates.filter(candidate => candidate.width <= maxWidth);
117
+ const pool = fitting.length > 0 ? fitting : candidates;
118
+ let best = pool[0]!;
119
+ for (let i = 1; i < pool.length; i++) {
120
+ const next = pool[i]!;
121
+ if (fitting.length > 0) {
122
+ if (next.height !== best.height) {
123
+ if (next.height < best.height) best = next;
124
+ continue;
125
+ }
126
+ if (next.authored !== best.authored) {
127
+ if (next.authored) best = next;
128
+ continue;
129
+ }
130
+ if (next.width < best.width) best = next;
131
+ continue;
132
+ }
133
+ if (next.width !== best.width) {
134
+ if (next.width < best.width) best = next;
135
+ continue;
136
+ }
137
+ if (next.height !== best.height) {
138
+ if (next.height < best.height) best = next;
139
+ continue;
83
140
  }
141
+ if (next.authored) best = next;
84
142
  }
85
- return best;
143
+ return best.ascii;
86
144
  }
87
145
 
88
146
  /**
package/src/tools/bash.ts CHANGED
@@ -41,6 +41,13 @@ export interface BashToolDetails {
41
41
  timedOut?: boolean;
42
42
  /** Live ACP update only; completed results refer to released terminals. */
43
43
  terminalId?: string;
44
+ service?: {
45
+ name: string;
46
+ state: string;
47
+ ready: boolean;
48
+ timedOut: boolean;
49
+ pid?: number;
50
+ };
44
51
  async?: {
45
52
  state: "running" | "completed" | "failed";
46
53
  jobId: string;
@@ -362,6 +369,12 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
362
369
  if (details?.async?.state === "running") {
363
370
  statsParts.push(`Backgrounded: ${details.async.jobId}`);
364
371
  }
372
+ if (details?.service) {
373
+ const service = details.service;
374
+ statsParts.push(`Service: ${service.name}`, `State: ${service.state}`);
375
+ statsParts.push(`Ready: ${service.ready ? "yes" : service.timedOut ? "timed out" : "no"}`);
376
+ if (service.pid !== undefined) statsParts.push(`PID: ${service.pid}`);
377
+ }
365
378
  if (wallTimeMs !== undefined) {
366
379
  statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
367
380
  }
@@ -460,7 +473,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
460
473
 
461
474
  /** Renders bash command previews and output. */
462
475
  export const bashToolRenderer = createShellRenderer<BashRenderArgs>({
463
- resolveTitle: () => "Bash",
476
+ resolveTitle: args => (args?.name ? `Bash · ${String(args.name)}` : "Bash"),
464
477
  resolveCommand: args => args?.command,
465
478
  resolveCwd: args => args?.cwd,
466
479
  resolveEnv: args => args?.env,
@@ -0,0 +1,51 @@
1
+ /** Launch-broker daemon types shared by services, `proc://`, and `omp ps`. */
2
+
3
+ /** Stable lifecycle states exposed by the launch broker. */
4
+ export type DaemonState = "starting" | "running" | "ready" | "restarting" | "stopping" | "exited" | "failed";
5
+
6
+ /** Restart behavior applied after an unexpected daemon exit. */
7
+ export type DaemonRestartPolicy = "no" | "on-failure" | "always";
8
+
9
+ /** Readiness conditions; every configured condition must pass. */
10
+ export interface DaemonReadySpec {
11
+ log?: string;
12
+ port?: number;
13
+ host?: string;
14
+ timeoutMs: number;
15
+ }
16
+
17
+ /** Immutable launch specification retained for restart and inspection. */
18
+ export interface DaemonSpec {
19
+ name: string;
20
+ application: string;
21
+ args: string[];
22
+ env: Record<string, string>;
23
+ cwd: string;
24
+ pty: boolean;
25
+ ready?: DaemonReadySpec;
26
+ restart: DaemonRestartPolicy;
27
+ persist: boolean;
28
+ detached: boolean;
29
+ }
30
+
31
+ /** Serializable daemon state visible to every client in one broker scope. */
32
+ export interface DaemonSnapshot {
33
+ name: string;
34
+ id: string;
35
+ state: DaemonState;
36
+ pid?: number;
37
+ createdAt: number;
38
+ startedAt: number;
39
+ readyAt?: number;
40
+ exitedAt?: number;
41
+ exitCode?: number;
42
+ exitReason?: string;
43
+ restartCount: number;
44
+ outputBytes: number;
45
+ owner?: string;
46
+ readyMatch?: string;
47
+ /** Readiness conditions still unmet while `state` is `starting`; absent once ready or without a ready spec. */
48
+ readyPending?: ("log" | "port")[];
49
+ persist: boolean;
50
+ detached: boolean;
51
+ }
@@ -15,7 +15,7 @@ import { githubToolRenderer } from "./github";
15
15
  import { globToolRenderer } from "./glob";
16
16
  import { goalToolRenderer } from "./goal";
17
17
  import { grepToolRenderer } from "./grep";
18
- import { hubToolRenderer } from "./hub";
18
+ import { waitToolRenderer } from "./wait";
19
19
  import { lspToolRenderer } from "./lsp";
20
20
  import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory";
21
21
  import { readToolRenderer } from "./read";
@@ -45,7 +45,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
45
45
  glob: globToolRenderer,
46
46
  grep: grepToolRenderer,
47
47
  lsp: lspToolRenderer,
48
- hub: hubToolRenderer,
48
+ wait: waitToolRenderer,
49
49
  read: readToolRenderer,
50
50
  // Keyed by xd:// resolution-device names: the write dispatch delegates here
51
51
  // by dispatch tool, and historical `resolve` tool transcripts still render
@@ -0,0 +1,33 @@
1
+ /** Peer-message types and roster constants shared by messaging, `wait`, and child prompts. */
2
+
3
+ /** Maximum live peer rows embedded in child prompts. */
4
+ export const DEFAULT_PEER_ROSTER_LIMIT = 32;
5
+
6
+ /** Serializable peer message retained in coordination result snapshots. */
7
+ export interface IrcMessage {
8
+ id: string;
9
+ /** Sender agent id. */
10
+ from: string;
11
+ /** Recipient agent id (resolved; "all" is expanded by the tool, not stored). */
12
+ to: string;
13
+ body: string;
14
+ ts: number;
15
+ /** Message id being answered. */
16
+ replyTo?: string;
17
+ /**
18
+ * Automated wake-turn relay of a woken subagent's stop output (task executor
19
+ * `relayWakeTurnOutput`). Relays are answers, never wake sources: the
20
+ * recipient's own wake-turn relay must skip them or two idle peers
21
+ * ping-pong forever.
22
+ */
23
+ wakeRelay?: boolean;
24
+ }
25
+
26
+ /** Delivery outcome for one peer recipient. */
27
+ export interface IrcDeliveryReceipt {
28
+ to: string;
29
+ outcome: "injected" | "woken" | "revived" | "failed";
30
+ error?: string;
31
+ }
32
+ /** Status ordering for peer rosters in child prompts. */
33
+ export const LIST_STATUS_ORDER: Record<string, number> = { running: 0, idle: 1, parked: 2 };
@@ -15,6 +15,11 @@ const LINE_RANGE_CHUNK_RE = new RegExp(`^${LINE_RANGE_CHUNK_SOURCE}$`, "i");
15
15
 
16
16
  /** Parse a single `N`, `N-M`, `N-`, `N+K`, or `..`-aliased (`N..M`, `N..`) chunk. Throws via {@link ToolError} on invalid bounds. */
17
17
  export function parseLineRangeChunk(sel: string): LineRange | null {
18
+ return parseChunk(sel, false);
19
+ }
20
+
21
+ /** `pinBare` makes a separator-less `N` the single line `N` instead of "from `N` onward". */
22
+ function parseChunk(sel: string, pinBare: boolean): LineRange | null {
18
23
  const lineMatch = LINE_RANGE_CHUNK_RE.exec(sel);
19
24
  if (!lineMatch) return null;
20
25
  const rawStart = Number.parseInt(lineMatch[1]!, 10);
@@ -31,27 +36,32 @@ export function parseLineRangeChunk(sel: string): LineRange | null {
31
36
  }
32
37
  rawEnd = rawStart + rhs - 1;
33
38
  } else if (sep === "-") {
34
- // `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
39
+ // `301-` is shorthand for "from 301 onward" — equivalent to a lone bare `301`.
35
40
  if (rhs !== undefined) {
36
41
  if (rhs < rawStart) {
37
42
  throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
38
43
  }
39
44
  rawEnd = rhs;
40
45
  }
46
+ } else if (pinBare) {
47
+ rawEnd = rawStart;
41
48
  }
42
49
  return { startLine: rawStart, endLine: rawEnd };
43
50
  }
44
51
 
45
52
  /**
46
- * Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
53
+ * Parse a comma-separated list of line ranges (e.g. `5-16,960-973` or `19,59`). Returns
47
54
  * the ranges in ascending order with overlapping/adjacent ranges merged so
48
55
  * downstream consumers can stream the file in a single forward pass per range.
49
56
  */
50
57
  export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
51
58
  const chunks = sel.split(",");
59
+ // A lone `:50` means "from line 50". Inside a comma list, a bare number is
60
+ // that one line; otherwise `:19,59` collapses to "from 19 through EOF".
61
+ const pinBare = chunks.length > 1;
52
62
  const parsed: LineRange[] = [];
53
63
  for (const chunk of chunks) {
54
- const range = parseLineRangeChunk(chunk);
64
+ const range = parseChunk(chunk, pinBare);
55
65
  if (!range) return null;
56
66
  parsed.push(range);
57
67
  }
@@ -0,0 +1,294 @@
1
+ import type { Component } from "../tui";
2
+ import { createCachedComponent, Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../render";
3
+ import {
4
+ cappedHeadLines,
5
+ formatBadge,
6
+ formatDuration,
7
+ formatErrorDetail,
8
+ formatStatusIcon,
9
+ PREVIEW_LIMITS,
10
+ TRUNCATE_LENGTHS,
11
+ replaceTabs,
12
+ type ToolUIColor,
13
+ } from "../render/render-utils";
14
+ import type { Theme } from "../theme/theme";
15
+ import type { RenderResultOptions } from "./renderer";
16
+ import type { AgentActivitySnapshot, CoordinationDetails, JobSnapshot } from "./wait";
17
+ import type { IrcDeliveryReceipt } from "./irc";
18
+ import type { DaemonSnapshot } from "./daemon";
19
+ import { styleTerminalRow } from "./terminal-output";
20
+
21
+ export interface ProcReadDetails {
22
+ jobs?: JobSnapshot[];
23
+ agents?: AgentActivitySnapshot[];
24
+ daemons?: DaemonSnapshot[];
25
+ job?: JobSnapshot;
26
+ daemon?: DaemonSnapshot;
27
+ log?: string;
28
+ terminalRows?: string[];
29
+ }
30
+
31
+ export type ProcWriteDetails =
32
+ | CoordinationDetails
33
+ | { action: "stop" | "stdin" | "mode"; daemon: DaemonSnapshot; input?: string; mode?: string };
34
+
35
+ /** Process operation selected by a write URL, independent of its content. */
36
+ export type ProcWriteAction = "stdin" | "mode" | "kill";
37
+
38
+ interface ToolResult {
39
+ content: Array<{ type: string; text?: string }>;
40
+ isError?: boolean;
41
+ }
42
+
43
+ function firstText(result: ToolResult): string {
44
+ return result.content.find(item => item.type === "text")?.text ?? "";
45
+ }
46
+
47
+ function safe(value: string): string {
48
+ return replaceTabs(value).replace(/\r/g, "");
49
+ }
50
+
51
+ function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component {
52
+ return createCachedComponent(
53
+ () => Boolean(options.expanded),
54
+ (width, expanded) =>
55
+ lines(width, expanded)
56
+ .flatMap(line => line.split("\n"))
57
+ .map(line => truncateToWidth(safe(line), width, Ellipsis.Unicode)),
58
+ { paddingX: 1 },
59
+ );
60
+ }
61
+
62
+ function preview(body: string, expanded: boolean, theme: Theme, tone: "dim" | "toolOutput" = "dim"): string[] {
63
+ if (!body.trim()) return [];
64
+ const limit = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
65
+ const shown = cappedHeadLines(
66
+ body.split("\n").filter(line => line.trim()),
67
+ limit,
68
+ );
69
+ const quote = theme.fg("dim", theme.md.quoteBorder);
70
+ const lines = shown.lines.map(
71
+ line =>
72
+ ` ${quote} ${theme.fg(tone, truncateToWidth(safe(line.trim()), TRUNCATE_LENGTHS.LINE, Ellipsis.Unicode))}`,
73
+ );
74
+ if (shown.hidden) lines.push(` ${quote} ${theme.fg("dim", `… +${shown.hidden} more lines`)}`);
75
+ return lines;
76
+ }
77
+
78
+ function receiptColor(outcome: IrcDeliveryReceipt["outcome"]): ToolUIColor {
79
+ return outcome === "failed" ? "error" : outcome === "revived" ? "warning" : "success";
80
+ }
81
+
82
+ export function renderAgentWrite(
83
+ to: string,
84
+ body: string,
85
+ result: ToolResult | undefined,
86
+ details: CoordinationDetails | undefined,
87
+ options: RenderResultOptions,
88
+ theme: Theme,
89
+ ): Component {
90
+ return card((width, expanded) => {
91
+ const recipient = safe(details?.to || to || "…");
92
+ const title = `IRC ${theme.nav.selected} ${recipient}`;
93
+ const receipts = details?.receipts ?? [];
94
+ const delivered = receipts.filter(receipt => receipt.outcome !== "failed").length;
95
+ const failed = receipts.length - delivered;
96
+ const error = result?.isError || (failed > 0 && delivered === 0);
97
+ const meta: string[] = [];
98
+ if (recipient === "all") meta.push("broadcast");
99
+ if (receipts.length === 1) meta.push(theme.fg(receiptColor(receipts[0]!.outcome), receipts[0]!.outcome));
100
+ else if (receipts.length > 1) {
101
+ if (delivered) meta.push(theme.fg("success", `${delivered} delivered`));
102
+ if (failed) meta.push(theme.fg("error", `${failed} failed`));
103
+ }
104
+ const header = renderStatusLine(
105
+ result === undefined
106
+ ? { icon: "pending", title, meta }
107
+ : error
108
+ ? { icon: "error", title, meta }
109
+ : { iconOverride: theme.styledSymbol("tool.irc", "accent"), title, meta },
110
+ theme,
111
+ );
112
+ if (result?.isError && receipts.length === 0)
113
+ return [header, formatErrorDetail(firstText(result) || "Message delivery failed.", theme)];
114
+ const lines = [header, ...preview(body, expanded, theme)];
115
+ if (result && receipts.length === 0)
116
+ lines.push(theme.fg("muted", firstText(result) || "No live peers to broadcast to."));
117
+ if (receipts.length > 1 || failed > 0) {
118
+ lines.push(
119
+ ...renderTreeList(
120
+ {
121
+ items: receipts,
122
+ expanded,
123
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
124
+ itemType: "recipient",
125
+ renderItem: receipt =>
126
+ `${theme.fg("toolOutput", safe(receipt.to))} ${formatBadge(receipt.outcome, receiptColor(receipt.outcome), theme)}${receipt.error ? ` ${theme.fg("error", `${theme.format.dash} ${safe(receipt.error)}`)}` : ""}`,
127
+ },
128
+ theme,
129
+ ),
130
+ );
131
+ }
132
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
133
+ }, options);
134
+ }
135
+
136
+ function daemonMeta(daemon: DaemonSnapshot, theme: Theme): string[] {
137
+ const stateColor =
138
+ daemon.state === "ready" || daemon.state === "running"
139
+ ? "success"
140
+ : daemon.state === "failed"
141
+ ? "error"
142
+ : "warning";
143
+ const meta = [theme.fg(stateColor, daemon.state)];
144
+ if (daemon.pid !== undefined) meta.push(`pid ${daemon.pid}`);
145
+ meta.push(
146
+ `${daemon.exitedAt === undefined ? "up" : "ran"} ${formatDuration(Math.max(0, (daemon.exitedAt ?? Date.now()) - daemon.startedAt))}`,
147
+ );
148
+ if (daemon.detached) meta.push("detached");
149
+ else if (daemon.persist) meta.push("persistent");
150
+ return meta;
151
+ }
152
+
153
+ function jobRow(job: JobSnapshot, theme: Theme): string {
154
+ const icon = formatStatusIcon(
155
+ job.status === "cancelled"
156
+ ? "aborted"
157
+ : job.status === "failed"
158
+ ? "error"
159
+ : job.status === "running"
160
+ ? "running"
161
+ : "done",
162
+ theme,
163
+ );
164
+ return `${icon} ${formatBadge(job.type, job.status === "failed" ? "error" : job.status === "cancelled" ? "warning" : "accent", theme)} ${theme.fg("toolOutput", safe(job.id))} ${theme.fg("dim", safe(job.label))} ${theme.fg("dim", formatDuration(job.durationMs))}`;
165
+ }
166
+
167
+ /** Render live and completed process writes with the URL-selected operation. */
168
+ export function renderProcWrite(
169
+ id: string,
170
+ action: ProcWriteAction,
171
+ content: string | undefined,
172
+ result: ToolResult | undefined,
173
+ details: ProcWriteDetails | undefined,
174
+ options: RenderResultOptions,
175
+ theme: Theme,
176
+ ): Component {
177
+ return card((_width, expanded) => {
178
+ const title = `Proc ${action} ${safe(id || "…")}`;
179
+ const daemon = details && "daemon" in details ? details.daemon : undefined;
180
+ const header = renderStatusLine(
181
+ {
182
+ icon:
183
+ result === undefined ? "pending" : result.isError ? "error" : action === "kill" ? "aborted" : "success",
184
+ title,
185
+ meta: daemon ? daemonMeta(daemon, theme) : action === "mode" && content ? [safe(content)] : [],
186
+ },
187
+ theme,
188
+ );
189
+ if (result?.isError) return [header, formatErrorDetail(firstText(result) || "Process operation failed.", theme)];
190
+ const lines = [header];
191
+ if (content && action === "stdin") lines.push(...preview(content, expanded, theme));
192
+ if (details && "op" in details && details.op === "cancel") {
193
+ const jobs = details.jobs ?? [];
194
+ const outcomes = details.cancelled ?? [];
195
+ lines.push(
196
+ ...renderTreeList(
197
+ {
198
+ items: outcomes,
199
+ expanded,
200
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
201
+ itemType: "job",
202
+ renderItem: outcome => {
203
+ const job = jobs.find(item => item.id === outcome.id);
204
+ return `${job ? `${jobRow(job, theme)} ` : `${theme.fg("toolOutput", safe(outcome.id))} `}${formatBadge(outcome.status, outcome.status === "cancelled" ? "warning" : "error", theme)}`;
205
+ },
206
+ },
207
+ theme,
208
+ ),
209
+ );
210
+ }
211
+ return lines;
212
+ }, options);
213
+ }
214
+
215
+ export function renderProcRead(
216
+ id: string,
217
+ result: ToolResult | undefined,
218
+ details: ProcReadDetails | undefined,
219
+ options: RenderResultOptions,
220
+ theme: Theme,
221
+ ): Component {
222
+ return card((_width, expanded) => {
223
+ const title = id ? `Proc ${safe(id)}` : "Proc jobs & services";
224
+ const daemon = details?.daemon;
225
+ const header = renderStatusLine(
226
+ {
227
+ icon: result === undefined ? "pending" : result.isError ? "error" : "info",
228
+ title,
229
+ meta: daemon ? daemonMeta(daemon, theme) : [],
230
+ },
231
+ theme,
232
+ );
233
+ if (result?.isError) return [header, formatErrorDetail(firstText(result) || "Process read failed.", theme)];
234
+ if (!result) return [header];
235
+ if (details?.job)
236
+ return [
237
+ header,
238
+ jobRow(details.job, theme),
239
+ ...preview(
240
+ details.log ?? details.job.errorText ?? details.job.resultText ?? "",
241
+ expanded,
242
+ theme,
243
+ "toolOutput",
244
+ ),
245
+ ];
246
+ if (daemon) {
247
+ const output = details.terminalRows ?? (details.log ?? "").split("\n").filter(Boolean);
248
+ const limit = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
249
+ const visible = output
250
+ .slice(-limit)
251
+ .map(
252
+ line =>
253
+ ` ${styleTerminalRow(truncateToWidth(safe(line), TRUNCATE_LENGTHS.LINE, Ellipsis.Unicode), theme.fg("toolOutput", ""))}`,
254
+ );
255
+ if (output.length > limit) visible.unshift(theme.fg("dim", ` … ${output.length - limit} earlier lines`));
256
+ return [header, ...visible];
257
+ }
258
+ if (id && !details?.jobs && !details?.daemons && !details?.agents) {
259
+ return [header, ...preview(firstText(result), expanded, theme, "toolOutput")];
260
+ }
261
+ const jobs = details?.jobs ?? [];
262
+ const services = details?.daemons ?? [];
263
+ const agents = details?.agents ?? [];
264
+ const meta = [
265
+ `${jobs.length} jobs`,
266
+ `${services.length} services`,
267
+ ...(agents.length ? [`${agents.length} agents`] : []),
268
+ ];
269
+ const listHeader = renderStatusLine({ icon: "info", title, meta }, theme);
270
+ const items = [
271
+ ...jobs.map(job => ({ label: jobRow(job, theme) })),
272
+ ...services.map(service => ({
273
+ label: `${formatBadge("service", "accent", theme)} ${theme.fg("toolOutput", safe(service.name))} ${formatBadge(service.state, service.state === "failed" ? "error" : service.state === "ready" || service.state === "running" ? "success" : "warning", theme)} ${daemonMeta(service, theme).slice(1).join(theme.sep.dot)}`,
274
+ })),
275
+ ...agents.map(agent => ({
276
+ label: `${formatBadge("agent", agent.live ? "accent" : "warning", theme)} ${theme.fg("toolOutput", safe(agent.id))} ${theme.fg("dim", formatDuration(agent.ageMs))}`,
277
+ })),
278
+ ];
279
+ return [
280
+ listHeader,
281
+ ...renderTreeList(
282
+ {
283
+ items,
284
+ expanded,
285
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
286
+ itemType: "process",
287
+ renderItem: item => item.label,
288
+ },
289
+ theme,
290
+ ),
291
+ ...(items.length ? [] : [theme.fg("dim", "No background jobs or services.")]),
292
+ ];
293
+ }, options);
294
+ }