@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.
- package/CHANGELOG.md +20 -0
- package/THIRD-PARTY-NOTICES.txt +1 -1
- package/dist/types/apps/ps-data.d.ts +1 -1
- package/dist/types/apps/ps-top.d.ts +1 -1
- package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
- package/dist/types/overlays/annotation-overlay.d.ts +30 -0
- package/dist/types/overlays/annotation-types.d.ts +84 -0
- package/dist/types/overlays/copy-selector.d.ts +30 -2
- package/dist/types/overlays/copy-targets.d.ts +2 -0
- package/dist/types/overlays/model-hub.d.ts +2 -2
- package/dist/types/overlays/oauth-selector.d.ts +3 -6
- package/dist/types/overlays/usage-dashboard.d.ts +2 -0
- package/dist/types/prompt/image-source.d.ts +9 -8
- package/dist/types/status-line/context-usage.d.ts +3 -1
- package/dist/types/theme/mermaid-cache.d.ts +4 -6
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/daemon.d.ts +46 -0
- package/dist/types/tools/irc.d.ts +30 -0
- package/dist/types/tools/line-ranges.d.ts +1 -1
- package/dist/types/tools/proc-render.d.ts +34 -0
- package/dist/types/tools/read.d.ts +4 -1
- package/dist/types/tools/wait.d.ts +131 -0
- package/dist/types/tools/write.d.ts +4 -0
- package/dist/types/tools/xd-url.d.ts +8 -0
- package/dist/types/windows-altgr.d.ts +54 -0
- package/package.json +13 -10
- package/src/apps/debug/protocol-probe.ts +1 -1
- package/src/apps/ps-data.ts +1 -1
- package/src/apps/ps-top.ts +1 -1
- package/src/chat/chat-transcript-builder.ts +4 -4
- package/src/chat/tool-execution.ts +8 -8
- package/src/chat/transcript-render-helpers.ts +3 -3
- package/src/components/markdown.ts +1 -1
- package/src/desktop-notify.ts +1 -1
- package/src/overlays/annotation-overlay.ts +1145 -0
- package/src/overlays/annotation-types.ts +78 -0
- package/src/overlays/copy-selector.ts +45 -19
- package/src/overlays/copy-targets.ts +3 -3
- package/src/overlays/model-hub.ts +5 -4
- package/src/overlays/oauth-selector.ts +8 -12
- package/src/overlays/plan-review-overlay.ts +213 -40
- package/src/overlays/usage-dashboard.ts +22 -1
- package/src/prompt/image-source.ts +9 -8
- package/src/setup/scenes/sign-in.ts +1 -1
- package/src/status-line/context-usage.ts +15 -3
- package/src/terminal-capabilities.ts +2 -2
- package/src/terminal.ts +8 -1
- package/src/theme/mermaid-cache.ts +83 -25
- package/src/tools/bash.ts +14 -1
- package/src/tools/daemon.ts +51 -0
- package/src/tools/index.ts +2 -2
- package/src/tools/irc.ts +33 -0
- package/src/tools/line-ranges.ts +13 -3
- package/src/tools/proc-render.ts +294 -0
- package/src/tools/read.ts +22 -1
- package/src/tools/wait.ts +595 -0
- package/src/tools/write.ts +76 -0
- package/src/tools/xd-url.ts +9 -0
- package/src/windows-altgr.ts +227 -0
- package/dist/types/tools/hub.d.ts +0 -342
- package/src/tools/hub.ts +0 -1492
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MermaidRenderOptions } from "@oh-my-pi/pi-natives";
|
|
2
|
-
import
|
|
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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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: ()
|
|
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
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
package/src/tools/irc.ts
ADDED
|
@@ -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 };
|
package/src/tools/line-ranges.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|