@oqtopus-team/pi-qdash 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -71,6 +71,7 @@ See [Development Guide](./docs/DEVELOPMENT.md) for the fork workflow, local test
71
71
 
72
72
  ## Documentation
73
73
 
74
+ - [Architecture](./docs/ARCHITECTURE.md)
74
75
  - [Tool Reference](./docs/tools.md)
75
76
  - [Commands & Skills](./docs/commands.md)
76
77
  - [Development Guide](./docs/DEVELOPMENT.md)
@@ -0,0 +1,112 @@
1
+ type PlotlyTrace = Record<string, unknown>;
2
+
3
+ export type FigureAnalysis = {
4
+ path: string;
5
+ taskId?: string;
6
+ traceCount: number;
7
+ summaries: string[];
8
+ warnings: string[];
9
+ };
10
+
11
+ function numericArray(value: unknown): number[] {
12
+ return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number" && Number.isFinite(item)) : [];
13
+ }
14
+
15
+ function numericMatrix(value: unknown): number[][] {
16
+ return Array.isArray(value)
17
+ ? value.map((row) => numericArray(row)).filter((row) => row.length > 0)
18
+ : [];
19
+ }
20
+
21
+ function matrixValue(matrix: number[][], row: number, column: number): number | undefined {
22
+ return matrix[row]?.[column];
23
+ }
24
+
25
+ function formatNumber(value: number): string {
26
+ const abs = Math.abs(value);
27
+ if (abs >= 1000 || (abs > 0 && abs < 0.001)) return value.toExponential(2);
28
+ if (abs >= 100) return value.toFixed(1);
29
+ if (abs >= 10) return value.toFixed(3);
30
+ return value.toFixed(5).replace(/0+$/, "").replace(/\.$/, "");
31
+ }
32
+
33
+ function formatMaybeNumber(value: number | undefined): string {
34
+ return typeof value === "number" && Number.isFinite(value) ? formatNumber(value) : "n/a";
35
+ }
36
+
37
+ function compactStats(values: number[]): string {
38
+ if (!values.length) return "no numeric values";
39
+ const min = Math.min(...values);
40
+ const max = Math.max(...values);
41
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
42
+ return `n=${values.length} min=${formatNumber(min)} max=${formatNumber(max)} mean=${formatNumber(mean)}`;
43
+ }
44
+
45
+ function boxed(title: string, body: string[]): string[] {
46
+ const rawLines = [` ${title} `, ...body];
47
+ const width = Math.min(100, Math.max(...rawLines.map((line) => line.length), title.length + 4));
48
+ const top = `╭${` ${title} `.padEnd(width, "─")}╮`;
49
+ const bottom = `╰${"─".repeat(width)}╯`;
50
+ const middle = body.map((line) => `│ ${line.padEnd(width - 2)} │`);
51
+ return [top, ...middle, bottom];
52
+ }
53
+
54
+ export function analyzePlotlyFigure(path: string, figure: unknown, taskId?: string): FigureAnalysis {
55
+ const object = figure && typeof figure === "object" ? figure as Record<string, unknown> : {};
56
+ const traces = Array.isArray(object.data) ? object.data.filter((trace): trace is PlotlyTrace => Boolean(trace) && typeof trace === "object") : [];
57
+ const summaries: string[] = [];
58
+ const warnings: string[] = [];
59
+
60
+ traces.forEach((trace, index) => {
61
+ const type = typeof trace.type === "string" ? trace.type : "unknown";
62
+ const name = typeof trace.name === "string" ? trace.name : `trace ${index}`;
63
+ if (type === "scatter" || Array.isArray(trace.y)) {
64
+ const y = numericArray(trace.y);
65
+ const x = numericArray(trace.x);
66
+ const err = trace.error_y && typeof trace.error_y === "object" ? (trace.error_y as Record<string, unknown>).value : undefined;
67
+ summaries.push(`${index}: scatter ${name} y(${compactStats(y)}) first=${formatMaybeNumber(y[0])} last=${formatMaybeNumber(y[y.length - 1])}${typeof err === "number" ? ` err=${formatNumber(err)}` : ""}`);
68
+ if (y.length >= 5) {
69
+ const tail = y.slice(Math.floor(y.length / 2));
70
+ const tailSpan = Math.max(...tail) - Math.min(...tail);
71
+ if (tailSpan > 0.4) warnings.push(`scatter ${name}: late repetitions still have large span ${formatNumber(tailSpan)}; repeated-pulse response may be unstable`);
72
+ const firstStep = Math.abs((y[1] ?? y[0]) - y[0]);
73
+ if (firstStep > 0.6 && Math.abs(y[y.length - 1]) > 0.1) warnings.push(`scatter ${name}: large first-step contrast but non-zero final offset ${formatNumber(y[y.length - 1])}`);
74
+ }
75
+ if (x.length && y.length && x.length !== y.length) warnings.push(`scatter ${name}: x/y length mismatch ${x.length}/${y.length}`);
76
+ return;
77
+ }
78
+
79
+ if (type === "heatmap" || Array.isArray(trace.z)) {
80
+ const z = numericMatrix(trace.z);
81
+ const flat = z.flat();
82
+ summaries.push(`${index}: heatmap ${z.length}x${z[0]?.length ?? 0} z(${compactStats(flat)})`);
83
+ if (z.length === 4 && z.every((row) => row.length === 4)) {
84
+ const diag = [0, 1, 2, 3].map((i) => z[i][i]);
85
+ const pop10 = diag[2];
86
+ const coh0011Re = matrixValue(z, 0, 3);
87
+ summaries.push(` 4x4 diag=[${diag.map(formatNumber).join(", ")}] z00,11=${formatMaybeNumber(coh0011Re)}`);
88
+ if (pop10 > 0.15) warnings.push(`4x4 heatmap: |10> population/component is high (${formatNumber(pop10)}); suspect Bell/Zx angle, phase, or cancel error`);
89
+ }
90
+ return;
91
+ }
92
+
93
+ summaries.push(`${index}: ${type}`);
94
+ });
95
+
96
+ if (!traces.length) warnings.push("no Plotly data traces found");
97
+ return { path, taskId, traceCount: traces.length, summaries, warnings };
98
+ }
99
+
100
+ export function figureAnalysisText(analysis: FigureAnalysis): string {
101
+ return boxed("QDash Figure Analysis", [
102
+ `path ${analysis.path}`,
103
+ ...(analysis.taskId ? [`task ${analysis.taskId}`] : []),
104
+ `traces ${analysis.traceCount}`,
105
+ "",
106
+ "Summaries",
107
+ ...analysis.summaries.map((line) => ` - ${line}`),
108
+ "",
109
+ "Warnings",
110
+ ...(analysis.warnings.length ? analysis.warnings.map((line) => ` - ${line}`) : [" - none"]),
111
+ ]).join("\n");
112
+ }
@@ -0,0 +1,78 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Image, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { QDashClient } from "@oqtopus-team/qdash-client";
4
+
5
+ export type FigureDetails = {
6
+ tool: string;
7
+ path: string;
8
+ mediaType: string;
9
+ sizeBytes: number;
10
+ base64?: string;
11
+ text?: string;
12
+ taskId?: string;
13
+ figurePaths?: string[];
14
+ jsonFigurePaths?: string[];
15
+ };
16
+
17
+ export function mediaTypeForPath(path: string): string {
18
+ const lower = path.toLowerCase();
19
+ if (lower.endsWith(".png")) return "image/png";
20
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
21
+ if (lower.endsWith(".gif")) return "image/gif";
22
+ if (lower.endsWith(".webp")) return "image/webp";
23
+ if (lower.endsWith(".json")) return "application/json";
24
+ return "application/octet-stream";
25
+ }
26
+
27
+ export async function fetchFigureDetails(client: QDashClient, path: string): Promise<FigureDetails> {
28
+ const file = await client.getExecutionFigure(path);
29
+ const bytes = Buffer.from(file.data);
30
+ const mediaType = file.mediaType || mediaTypeForPath(path);
31
+ const details: FigureDetails = {
32
+ tool: "qdash_get_figure",
33
+ path,
34
+ mediaType,
35
+ sizeBytes: bytes.byteLength,
36
+ };
37
+ if (mediaType.startsWith("image/")) details.base64 = bytes.toString("base64");
38
+ else details.text = bytes.toString("utf8");
39
+ return details;
40
+ }
41
+
42
+ function boxed(title: string, body: string[]): string[] {
43
+ const rawLines = [` ${title} `, ...body];
44
+ const width = Math.min(100, Math.max(...rawLines.map((line) => line.length), title.length + 4));
45
+ const top = `╭${` ${title} `.padEnd(width, "─")}╮`;
46
+ const bottom = `╰${"─".repeat(width)}╯`;
47
+ const middle = body.map((line) => `│ ${line.padEnd(width - 2)} │`);
48
+ return [top, ...middle, bottom];
49
+ }
50
+
51
+ export function figureResultText(details: FigureDetails): string {
52
+ const lines = boxed("QDash Figure", [
53
+ `path ${details.path}`,
54
+ `type ${details.mediaType}`,
55
+ `size ${details.sizeBytes} bytes`,
56
+ ...(details.taskId ? [`task ${details.taskId}`] : []),
57
+ ...(details.figurePaths?.length ? [`figures ${details.figurePaths.length}`] : []),
58
+ ...(details.jsonFigurePaths?.length ? [`json figures ${details.jsonFigurePaths.length}`] : []),
59
+ ...(details.text ? ["", ...details.text.split("\n").slice(0, 12).map((line) => ` ${line}`)] : []),
60
+ ]);
61
+ return lines.join("\n");
62
+ }
63
+
64
+ function textComponent(lines: string[]) {
65
+ return {
66
+ render(width: number) {
67
+ return lines.map((line) => truncateToWidth(line, width));
68
+ },
69
+ invalidate() {},
70
+ };
71
+ }
72
+
73
+ export function figureComponent(details: FigureDetails, theme: Theme) {
74
+ if (details.base64 && details.mediaType.startsWith("image/")) {
75
+ return new Image(details.base64, details.mediaType, { fallbackColor: (text: string) => theme.fg("dim", text) }, { maxWidthCells: 90, maxHeightCells: 30 });
76
+ }
77
+ return textComponent(figureResultText(details).split("\n"));
78
+ }
@@ -0,0 +1,152 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { firstNumber, firstString, payloadTotal } from "./payload.js";
4
+ import { ansi, boxed, boxLinesToWidth } from "./render.js";
5
+
6
+ function shortId(value: string, length = 10): string {
7
+ return value.length > length ? `${value.slice(0, length)}…` : value;
8
+ }
9
+
10
+ export function forumPostsFromPayload(payload: unknown): Record<string, unknown>[] {
11
+ if (Array.isArray(payload)) return payload.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
12
+ if (payload && typeof payload === "object") {
13
+ const object = payload as Record<string, unknown>;
14
+ for (const key of ["posts", "items", "results", "data"]) {
15
+ if (Array.isArray(object[key])) return object[key].filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
16
+ }
17
+ }
18
+ return [];
19
+ }
20
+
21
+ function forumPostId(post: Record<string, unknown>): string {
22
+ return firstString(post, ["post_id", "id", "forum_post_id"]) ?? "unknown";
23
+ }
24
+
25
+ export function forumPostTitle(post: Record<string, unknown>): string {
26
+ return firstString(post, ["title", "subject", "summary"]) ?? firstString(post, ["content", "body", "text"])?.slice(0, 60) ?? "(untitled)";
27
+ }
28
+
29
+ function stringList(value: unknown): string[] {
30
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
31
+ }
32
+
33
+ function forumPostLine(post: Record<string, unknown>): string {
34
+ const number = firstNumber(post, ["number"]);
35
+ const id = shortId(forumPostId(post), 10);
36
+ const title = forumPostTitle(post);
37
+ const status = firstString(post, ["status"]);
38
+ const category = firstString(post, ["category"]);
39
+ const targetType = firstString(post, ["target_type"]);
40
+ const target = firstString(post, ["target_id", "chip_id", "task_id", "qid"]);
41
+ const replies = firstNumber(post, ["reply_count"]);
42
+ const assignee = firstString(post, ["assignee_username", "username"]);
43
+ return [
44
+ "•",
45
+ number ? `#${number}` : id,
46
+ title,
47
+ category ? `[${category}]` : undefined,
48
+ status ? `[${status}]` : undefined,
49
+ target ? `(${targetType ? `${targetType}:` : ""}${target})` : undefined,
50
+ replies && replies > 0 ? `↩${replies}` : undefined,
51
+ assignee ? `@${assignee}` : undefined,
52
+ ].filter(Boolean).join(" ");
53
+ }
54
+
55
+ function inlineContentText(value: unknown): string {
56
+ if (typeof value === "string") return value;
57
+ if (!Array.isArray(value)) return "";
58
+ return value.map((item) => {
59
+ if (typeof item === "string") return item;
60
+ if (!item || typeof item !== "object") return "";
61
+ const object = item as Record<string, unknown>;
62
+ if (typeof object.text === "string") return object.text;
63
+ if (object.type === "link") return inlineContentText(object.content);
64
+ return "";
65
+ }).join("");
66
+ }
67
+
68
+ function forumBlockLines(blocks: unknown): string[] {
69
+ if (!Array.isArray(blocks)) return [];
70
+ const lines: string[] = [];
71
+ for (const item of blocks) {
72
+ if (!item || typeof item !== "object") continue;
73
+ const block = item as Record<string, unknown>;
74
+ const type = firstString(block, ["type"]) ?? "block";
75
+ const props = block.props && typeof block.props === "object" ? block.props as Record<string, unknown> : {};
76
+ const text = inlineContentText(block.content).trimEnd();
77
+ if (type === "heading") {
78
+ const level = firstNumber(props, ["level"]) ?? 2;
79
+ lines.push(`${"#".repeat(Math.max(1, Math.min(6, level)))} ${text}`.trimEnd());
80
+ } else if (type === "image") {
81
+ const name = firstString(props, ["name", "caption"]) ?? "image";
82
+ const url = firstString(props, ["url"]);
83
+ lines.push(`🖼 ${name}${url ? ` ${url}` : ""}`);
84
+ } else if (type === "bulletListItem") {
85
+ lines.push(`- ${text}`);
86
+ } else if (type === "numberedListItem") {
87
+ lines.push(`1. ${text}`);
88
+ } else if (text) {
89
+ lines.push(...text.split("\n"));
90
+ }
91
+ const childLines = forumBlockLines(block.children);
92
+ if (childLines.length > 0) lines.push(...childLines.map((line) => ` ${line}`));
93
+ }
94
+ return lines;
95
+ }
96
+
97
+ function forumContentLines(object: Record<string, unknown>): string[] {
98
+ const blockLines = forumBlockLines(object.content_blocks);
99
+ if (blockLines.length > 0) return blockLines;
100
+ const content = firstString(object, ["content", "body", "text", "message", "description"]);
101
+ return content ? content.split("\n") : ["(no content)"];
102
+ }
103
+
104
+ export function forumListLines(payload: unknown, title = "QDash Forum", color = false): string[] {
105
+ const posts = forumPostsFromPayload(payload);
106
+ const total = payloadTotal(payload);
107
+ const accent = (text: string) => color ? ansi("1;36", text) : text;
108
+ const dim = (text: string) => color ? ansi("2", text) : text;
109
+ return boxed(title, [
110
+ `posts ${accent(`${posts.length}/${total}`)}`,
111
+ "",
112
+ ...(posts.length > 0 ? posts.map((post) => ` ${forumPostLine(post)}`) : [` ${dim("none")}`]),
113
+ ], color);
114
+ }
115
+
116
+ export function forumDetailBodyLines(post: unknown, color = false): string[] {
117
+ const object = post && typeof post === "object" ? post as Record<string, unknown> : {};
118
+ const accent = (text: string) => color ? ansi("1;36", text) : text;
119
+ const muted = (text: string) => color ? ansi("90", text) : text;
120
+ const labels = stringList(object.labels);
121
+ const replyCount = firstNumber(object, ["reply_count"]);
122
+ const meta = [
123
+ firstString(object, ["category"]) ? `${muted("category")} ${firstString(object, ["category"])}` : undefined,
124
+ firstString(object, ["status"]) ? `${muted("status")} ${firstString(object, ["status"])}` : undefined,
125
+ firstString(object, ["target_type"]) || firstString(object, ["target_id"]) ? `${muted("target")} ${[firstString(object, ["target_type"]), firstString(object, ["target_id"])].filter(Boolean).join(":")}` : undefined,
126
+ firstString(object, ["chip_id"]) ? `${muted("chip")} ${firstString(object, ["chip_id"])}` : undefined,
127
+ replyCount !== undefined ? `${muted("replies")} ${replyCount}` : undefined,
128
+ ].filter((line): line is string => typeof line === "string");
129
+ return [
130
+ `${muted("id")} ${accent(forumPostId(object))}${firstNumber(object, ["number"]) ? ` ${muted("#")} ${firstNumber(object, ["number"])}` : ""}`,
131
+ `${muted("title")} ${forumPostTitle(object)}`,
132
+ `${muted("author")} ${firstString(object, ["username", "user_id"]) ?? "unknown"}${firstString(object, ["assignee_username"]) ? ` ${muted("assignee")} ${firstString(object, ["assignee_username"])}` : ""}`,
133
+ meta.join(" "),
134
+ labels.length > 0 ? `${muted("labels")} ${labels.map((label) => `#${label}`).join(" ")}` : undefined,
135
+ firstString(object, ["created_at"]) ? `${muted("created")} ${firstString(object, ["created_at"])}${firstString(object, ["updated_at"]) ? ` ${muted("updated")} ${firstString(object, ["updated_at"])}` : ""}` : undefined,
136
+ "",
137
+ ...forumContentLines(object).map((line) => ` ${line}`),
138
+ ].filter((line): line is string => typeof line === "string");
139
+ }
140
+
141
+ export function forumDetailLines(post: unknown, title = "QDash Forum Post", color = false): string[] {
142
+ return boxed(title, forumDetailBodyLines(post, color), color);
143
+ }
144
+
145
+ export function forumDetailComponent(post: unknown, theme: Theme) {
146
+ return {
147
+ render(width: number) {
148
+ return boxLinesToWidth("QDash Forum Post", forumDetailBodyLines(post), width, theme);
149
+ },
150
+ invalidate() {},
151
+ };
152
+ }
@@ -0,0 +1,57 @@
1
+ import type { QDashClient } from "@oqtopus-team/qdash-client";
2
+
3
+ function firstString(object: Record<string, unknown>, keys: string[]): string | undefined {
4
+ for (const key of keys) {
5
+ const value = object[key];
6
+ if (typeof value === "string" && value.length > 0) return value;
7
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
8
+ }
9
+ return undefined;
10
+ }
11
+
12
+ export function qdashWebBaseUrl(client: QDashClient): string {
13
+ return client.config.baseUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
14
+ }
15
+
16
+ export function qdashWebUrl(client: QDashClient, path: string): string {
17
+ return `${qdashWebBaseUrl(client)}${path.startsWith("/") ? path : `/${path}`}`;
18
+ }
19
+
20
+ export function qdashObjectLinks(client: QDashClient, object: Record<string, unknown>): Record<string, string> {
21
+ const links: Record<string, string> = {};
22
+ const taskId = firstString(object, ["task_id", "taskId"]);
23
+ const executionId = firstString(object, ["execution_id", "executionId"]);
24
+ const postId = firstString(object, ["post_id", "forum_post_id", "id"]);
25
+ const issueId = firstString(object, ["issue_id"]);
26
+ const sessionId = firstString(object, ["session_id", "sessionId"]);
27
+ if (taskId) links.task_result = qdashWebUrl(client, `/task-results/${encodeURIComponent(taskId)}`);
28
+ if (executionId) links.execution = qdashWebUrl(client, `/executions/${encodeURIComponent(executionId)}`);
29
+ if (postId) links.forum_post = qdashWebUrl(client, `/forum/posts/${encodeURIComponent(postId)}`);
30
+ if (issueId) links.issue = qdashWebUrl(client, `/issues/${encodeURIComponent(issueId)}`);
31
+ if (sessionId) links.agent_session = qdashWebUrl(client, `/agent-sessions/${encodeURIComponent(sessionId)}`);
32
+ return links;
33
+ }
34
+
35
+ export function withQDashLinks(client: QDashClient, data: unknown): unknown {
36
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data;
37
+ const object = data as Record<string, unknown>;
38
+ const links = qdashObjectLinks(client, object);
39
+ if (Object.keys(links).length === 0) return data;
40
+ return { ...object, _links: links };
41
+ }
42
+
43
+ export function safeConfig(client: QDashClient, source: string) {
44
+ const config = client.config;
45
+ return {
46
+ source,
47
+ baseUrl: config.baseUrl,
48
+ projectId: config.projectId ?? null,
49
+ timeoutSeconds: config.timeoutSeconds,
50
+ verifyTls: config.verifyTls,
51
+ proxyConfigured: Boolean(config.proxy),
52
+ apiTokenConfigured: Boolean(config.apiToken),
53
+ cloudflareAccessConfigured: Boolean(config.cfAccessClientId || config.cfAccessClientSecret),
54
+ userAgent: config.userAgent,
55
+ retry: config.retry,
56
+ };
57
+ }
@@ -0,0 +1,70 @@
1
+ function shortId(value: string, length = 10): string {
2
+ if (value.length <= length) return value;
3
+ return `${value.slice(0, length)}…`;
4
+ }
5
+
6
+ export function arrayFromPayload(payload: unknown): unknown[] {
7
+ if (Array.isArray(payload)) return payload;
8
+ if (payload && typeof payload === "object") {
9
+ const object = payload as Record<string, unknown>;
10
+ for (const key of ["items", "results", "data", "issues", "executions", "chips", "tasks", "insights"]) {
11
+ if (Array.isArray(object[key])) return object[key];
12
+ }
13
+ }
14
+ return [];
15
+ }
16
+
17
+ export function compactItems(payload: unknown, keys: string[], limit: number): Record<string, unknown>[] {
18
+ return arrayFromPayload(payload).slice(0, limit).map((item) => {
19
+ if (!item || typeof item !== "object") return { value: item };
20
+ const object = item as Record<string, unknown>;
21
+ return Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]]));
22
+ });
23
+ }
24
+
25
+ export function payloadTotal(payload: unknown): number {
26
+ const fallback = arrayFromPayload(payload).length;
27
+ if (!payload || typeof payload !== "object") return fallback;
28
+ const object = payload as Record<string, unknown>;
29
+ for (const key of ["total", "total_count", "count", "total_items"]) {
30
+ const value = object[key];
31
+ if (typeof value === "number" && Number.isFinite(value)) return value;
32
+ }
33
+ return fallback;
34
+ }
35
+
36
+ export function firstString(object: Record<string, unknown>, keys: string[]): string | undefined {
37
+ for (const key of keys) {
38
+ const value = object[key];
39
+ if (typeof value === "string" && value.length > 0) return value;
40
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function firstNumber(object: Record<string, unknown>, keys: string[]): number | undefined {
46
+ for (const key of keys) {
47
+ const value = object[key];
48
+ if (typeof value === "number" && Number.isFinite(value)) return value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ export function statusIcon(status: string | undefined): string {
54
+ const normalized = status?.toLowerCase() ?? "";
55
+ if (["completed", "success", "succeeded", "active", "applied"].includes(normalized)) return "✓";
56
+ if (["failed", "error", "crashed"].includes(normalized)) return "✗";
57
+ if (["running", "pending", "queued", "in_progress"].includes(normalized)) return "…";
58
+ if (["cancelled", "canceled"].includes(normalized)) return "-";
59
+ return "•";
60
+ }
61
+
62
+ export function formatItem(item: Record<string, unknown>, fallbackId: string): string {
63
+ const id = firstString(item, ["issue_id", "execution_id", "flow_run_id", "task_id", "id"]) ?? fallbackId;
64
+ const title = firstString(item, ["title", "task_name", "flow_name", "name"]);
65
+ const status = firstString(item, ["status", "execution_status", "activity_status"]);
66
+ const target = firstString(item, ["qid", "coupling_id"]);
67
+ return [statusIcon(status), shortId(id, 14), title, target ? `(${target})` : undefined, status ? `[${status}]` : undefined]
68
+ .filter(Boolean)
69
+ .join(" ");
70
+ }
@@ -0,0 +1,110 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ export function ansi(code: string, text: string): string {
5
+ return `\u001b[${code}m${text}\u001b[0m`;
6
+ }
7
+
8
+ export function stripAnsi(text: string): string {
9
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
10
+ }
11
+
12
+ function displayWidth(text: string): number {
13
+ return visibleWidth(stripAnsi(text));
14
+ }
15
+
16
+ export function padAnsi(text: string, width: number): string {
17
+ return text + " ".repeat(Math.max(0, width - displayWidth(text)));
18
+ }
19
+
20
+ export function truncateDisplay(text: string, width: number): string {
21
+ if (displayWidth(text) <= width) return text;
22
+ const ellipsis = "…";
23
+ const target = Math.max(0, width - visibleWidth(ellipsis));
24
+ let out = "";
25
+ let used = 0;
26
+ for (const char of Array.from(text)) {
27
+ const charWidth = visibleWidth(char);
28
+ if (used + charWidth > target) break;
29
+ out += char;
30
+ used += charWidth;
31
+ }
32
+ return out + ellipsis;
33
+ }
34
+
35
+ export function boxed(title: string, body: string[], color = false): string[] {
36
+ const plainTitle = ` ${title} `;
37
+ const contentWidth = Math.min(
38
+ 92,
39
+ Math.max(36, ...body.map((line) => displayWidth(line)), visibleWidth(plainTitle)),
40
+ );
41
+ const innerWidth = contentWidth + 2;
42
+ const borderColor = (text: string) => color ? ansi("90", text) : text;
43
+ const titleText = color ? ansi("1;36", plainTitle) : plainTitle;
44
+ const top = borderColor("╭") + titleText + borderColor("─".repeat(Math.max(0, innerWidth - plainTitle.length))) + borderColor("╮");
45
+ const bottom = borderColor("╰" + "─".repeat(innerWidth) + "╯");
46
+ return [
47
+ top,
48
+ ...body.map((line) => {
49
+ const clipped = color ? truncateToWidth(line, contentWidth) : truncateDisplay(line, contentWidth);
50
+ return `${borderColor("│")} ${padAnsi(clipped, contentWidth)} ${borderColor("│")}`;
51
+ }),
52
+ bottom,
53
+ ];
54
+ }
55
+
56
+ export function wrapPlainLine(line: string, width: number): string[] {
57
+ if (width <= 0 || visibleWidth(line) <= width) return [line];
58
+ const output: string[] = [];
59
+ let rest = line;
60
+ while (visibleWidth(rest) > width) {
61
+ let slice = "";
62
+ for (const char of rest) {
63
+ if (visibleWidth(slice + char) > width) break;
64
+ slice += char;
65
+ }
66
+ output.push(slice);
67
+ rest = rest.slice(slice.length);
68
+ }
69
+ if (rest.length > 0) output.push(rest);
70
+ return output;
71
+ }
72
+
73
+ export function boxLinesToWidth(title: string, body: string[], width: number, theme?: Theme): string[] {
74
+ const contentWidth = Math.max(24, Math.min(100, width - 4));
75
+ const border = (text: string) => theme ? theme.fg("borderMuted", text) : text;
76
+ const titleText = ` ${title} `;
77
+ const top = `${border("╭")}${theme ? theme.fg("accent", theme.bold(titleText)) : titleText}${border("─".repeat(Math.max(0, contentWidth + 2 - visibleWidth(titleText))))}${border("╮")}`;
78
+ const bottom = border(`╰${"─".repeat(contentWidth + 2)}╯`);
79
+ const rows = body.flatMap((line) => wrapPlainLine(line, contentWidth));
80
+ return [
81
+ top,
82
+ ...rows.map((line) => `${border("│")} ${padAnsi(truncateDisplay(line, contentWidth), contentWidth)} ${border("│")}`),
83
+ bottom,
84
+ ];
85
+ }
86
+
87
+ export function textComponent(lines: string[], _theme: Theme, wrap = false) {
88
+ return {
89
+ render(width: number) {
90
+ if (!wrap) return lines.map((line) => truncateToWidth(line, width));
91
+ return lines.flatMap((line) => wrapPlainLine(line, width));
92
+ },
93
+ invalidate() {},
94
+ };
95
+ }
96
+
97
+ export function formatNumber(value: number): string {
98
+ const abs = Math.abs(value);
99
+ if (abs >= 1000 || (abs > 0 && abs < 0.001)) return value.toExponential(2);
100
+ if (abs >= 100) return value.toFixed(1);
101
+ if (abs >= 10) return value.toFixed(3);
102
+ return value.toFixed(5).replace(/0+$/, "").replace(/\.$/, "");
103
+ }
104
+
105
+ export function compactDate(value: string | undefined): string {
106
+ if (!value) return "";
107
+ const date = new Date(value.endsWith("Z") ? value : `${value}Z`);
108
+ if (Number.isNaN(date.getTime())) return value.slice(0, 10);
109
+ return `${String(date.getUTCMonth() + 1).padStart(2, "0")}/${String(date.getUTCDate()).padStart(2, "0")} ${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}`;
110
+ }
@@ -0,0 +1,30 @@
1
+ export function redact(value: unknown): unknown {
2
+ if (Array.isArray(value)) return value.map(redact);
3
+ if (value && typeof value === "object") {
4
+ return Object.fromEntries(
5
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => {
6
+ if (/token|password|secret|authorization|api[_-]?key/i.test(key)) return [key, "[redacted]"];
7
+ return [key, redact(item)];
8
+ }),
9
+ );
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export function toToolResult(data: unknown, details: Record<string, unknown> = {}) {
15
+ const safeData = redact(data);
16
+ let text = JSON.stringify(safeData, null, 2);
17
+ if (text.length > 20_000) text = `${text.slice(0, 20_000)}\n... [truncated]`;
18
+ return {
19
+ content: [{ type: "text" as const, text }],
20
+ details: { ...details, data: safeData },
21
+ };
22
+ }
23
+
24
+ export function toTextToolResult(text: string, data: unknown, details: Record<string, unknown> = {}) {
25
+ const safeData = redact(data);
26
+ return {
27
+ content: [{ type: "text" as const, text }],
28
+ details: { ...details, data: safeData },
29
+ };
30
+ }