/** * Visual rendering helpers for obsidian-cli tool calls and results. * * Obsidian returns most listings as TSV; we align those into readable columns * for the TUI and keep the raw text for the LLM. */ import type { Risk } from "./catalog.ts"; export const RISK_BADGE: Record = { read: { icon: "◦", label: "read" }, write: { icon: "●", label: "write" }, danger: { icon: "▲", label: "danger" }, }; /** True when every non-empty line has the same number of tab columns (>1). */ export function isTsv(text: string): boolean { const lines = text.split("\n").filter((l) => l.trim().length > 0); if (lines.length < 2) return false; const cols = lines[0].split("\t").length; if (cols < 2) return false; return lines.every((l) => l.split("\t").length === cols); } /** Align TSV rows into space-padded columns (capped per-column width). */ export function formatTsv(text: string, maxColWidth = 48): string[] { const lines = text.split("\n").filter((l) => l.trim().length > 0); const rows = lines.map((l) => l.split("\t").map((c) => c.trim())); const colCount = rows[0]?.length ?? 0; const widths: number[] = Array.from({ length: colCount }, () => 0); for (const row of rows) { for (let i = 0; i < colCount; i++) { const len = (row[i] ?? "").length; if (len > widths[i]) widths[i] = Math.min(len, maxColWidth); } } return rows.map((row) => row .map((cell, i) => { const truncated = cell.length > maxColWidth ? `${cell.slice(0, maxColWidth - 1)}…` : cell; return i === colCount - 1 ? truncated : truncated.padEnd(widths[i]); }) .join(" ") .trimEnd(), ); } /** Build the compact, human-facing summary for a result. */ export function summarizeOutput(stdout: string, collapsedLines = 10): { lines: string[]; truncated: boolean; total: number } { const formatted = isTsv(stdout) ? formatTsv(stdout) : stdout.split("\n"); return { lines: formatted.slice(0, collapsedLines), truncated: formatted.length > collapsedLines, total: formatted.length, }; } /** True when text looks like a JSON array or object at the top level. */ export function isJson(text: string): boolean { const t = text.trim(); return (t.startsWith("[") || t.startsWith("{")) && (t.endsWith("]") || t.endsWith("}")); } /** Helper to convert cell values (primitives, arrays, objects) to readable strings. */ export function formatCell(cell: unknown): string { if (cell === null || cell === undefined) return ""; if (Array.isArray(cell)) return cell.map(formatCell).join(", "); if (typeof cell === "object") { const obj = cell as Record; if (obj.path !== undefined) return String(obj.path); if (obj.name !== undefined) return String(obj.name); try { return JSON.stringify(cell); } catch { return String(cell); } } return String(cell); } /** Align rows of uniform objects or cell arrays into space-padded columns. */ export function formatArrayOfObjects(parsed: Record[], maxColWidth = 48): string[] { if (parsed.length === 0) return ["(empty array)"]; const first = parsed[0]; if (typeof first !== "object" || first === null) { return parsed.map((v) => formatCell(v)); } const keySet = new Set(); for (const row of parsed) { if (row && typeof row === "object") { for (const k of Object.keys(row)) keySet.add(k); } } const keys = [...keySet]; const widths: number[] = keys.map((k) => Math.min(k.length, maxColWidth)); for (const row of parsed) { if (!row || typeof row !== "object") continue; for (let i = 0; i < keys.length; i++) { const cellVal = formatCell(row[keys[i]]); widths[i] = Math.min(Math.max(widths[i], cellVal.length), maxColWidth); } } const header = keys.map((k, i) => (i === keys.length - 1 ? k : k.padEnd(widths[i]))).join(" "); const lines = [header]; for (const row of parsed) { if (!row || typeof row !== "object") continue; const cells = keys.map((k, i) => { let v = formatCell(row[k]); if (v.length > maxColWidth) v = `${v.slice(0, maxColWidth - 1)}…`; return i === keys.length - 1 ? v : v.padEnd(widths[i]); }); lines.push(cells.join(" ")); } return lines; } /** Parse a JSON array or object into aligned columns or structured markdown lists/tables. */ export function formatJsonTable(text: string, maxColWidth = 48): string[] { try { const parsed = JSON.parse(text); // 1. Array of uniform objects / items if (Array.isArray(parsed)) { return formatArrayOfObjects(parsed, maxColWidth); } if (typeof parsed === "object" && parsed !== null) { const p = parsed as Record; // 2. Dataview Table Query Result ({ type: "table", headers: [...], values: [...] }) if (p.type === "table" && Array.isArray(p.headers) && Array.isArray(p.values)) { const headers = p.headers.map((h) => String(h)); const rows = (p.values as unknown[][]).map((row) => Array.isArray(row) ? row.map(formatCell) : [formatCell(row)], ); if (headers.length === 0 && rows.length === 0) return ["(empty dataview table)"]; const colCount = Math.max(headers.length, ...rows.map((r) => r.length)); const widths: number[] = Array.from({ length: colCount }, (_, i) => Math.min((headers[i] ?? "").length, maxColWidth), ); for (const row of rows) { for (let i = 0; i < colCount; i++) { const len = (row[i] ?? "").length; if (len > widths[i]) widths[i] = Math.min(len, maxColWidth); } } const headerLine = headers .map((h, i) => (i === colCount - 1 ? h : h.padEnd(widths[i] ?? h.length))) .join(" "); const lines = [headerLine]; for (const row of rows) { const line = row .map((cell, i) => { const truncated = cell.length > maxColWidth ? `${cell.slice(0, maxColWidth - 1)}…` : cell; return i === colCount - 1 ? truncated : truncated.padEnd(widths[i] ?? truncated.length); }) .join(" "); lines.push(line); } return lines; } // 3. Dataview List Query Result ({ type: "list", values: [...] }) if (p.type === "list" && Array.isArray(p.values)) { if (p.values.length === 0) return ["(empty list)"]; return (p.values as unknown[]).map((val) => `- ${formatCell(val)}`); } // 4. Dataview Task Query Result ({ type: "task", values: [...] }) if (p.type === "task" && Array.isArray(p.values)) { if (p.values.length === 0) return ["(no tasks)"]; return (p.values as Record[]).map((t) => { const mark = t.completed ? "x" : " "; const path = t.path ? ` (${t.path}${t.line ? `:${t.line}` : ""})` : ""; const due = t.due ? ` [due: ${formatCell(t.due)}]` : ""; return `- [${mark}] ${t.text ?? ""}${due}${path}`; }); } // 5. Tasks Plugin Result ({ success: true, tasks: [...] }) if (Array.isArray(p.tasks)) { const tasks = p.tasks as Record[]; if (tasks.length === 0) return ["(no matching tasks)"]; const total = p.total !== undefined ? ` (showing ${tasks.length} of ${p.total})` : ""; const lines: string[] = [`Tasks${total}:`]; for (const t of tasks) { const symbol = String(t.status ?? ""); const isDone = symbol === "DONE" || symbol === "x" || t.status === "done"; const mark = isDone ? "x" : " "; const prio = t.priority && t.priority !== "none" ? ` [prio: ${t.priority}]` : ""; const due = t.due ? ` [due: ${formatCell(t.due).slice(0, 10)}]` : ""; const path = t.path ? ` · ${t.path}${t.line ? `:${t.line}` : ""}` : ""; lines.push(`- [${mark}] ${t.description ?? ""}${prio}${due}${path}`); } return lines; } // 6. DataviewJS Run Result ({ success: true, outputs: [...] }) if (Array.isArray(p.outputs)) { const outputs = p.outputs as Record[]; if (outputs.length === 0) return ["(no dataviewjs outputs)"]; const lines: string[] = []; for (const out of outputs) { if (out.type === "table" && Array.isArray(out.headers) && Array.isArray(out.values)) { lines.push(...formatJsonTable(JSON.stringify(out), maxColWidth)); } else if (out.type === "list" && Array.isArray(out.values)) { lines.push(...(out.values as unknown[]).map((v) => `- ${formatCell(v)}`)); } else if (out.type === "taskList" && Array.isArray(out.values)) { lines.push( ...(out.values as Record[]).map((t) => { const mark = t.completed ? "x" : " "; return `- [${mark}] ${t.text ?? ""}${t.path ? ` (${t.path})` : ""}`; }), ); } else if (out.text !== undefined) { lines.push(String(out.text)); } else { lines.push(formatCell(out)); } } return lines; } // 7. Object wrapping an inner array property (files, items, results, data, records) const ARRAY_KEYS = ["items", "files", "results", "records", "data", "nodes", "links", "vaults"]; for (const key of ARRAY_KEYS) { if (Array.isArray(p[key])) { return formatArrayOfObjects(p[key] as Record[], maxColWidth); } } // 8. Single object → key: value pairs, sorted. const sorted = Object.entries(p).sort(([a], [b]) => a.localeCompare(b)); const maxKey = Math.max(...sorted.map(([k]) => k.length)); return sorted.map(([k, v]) => `${k.padEnd(maxKey)} ${formatCell(v)}`); } return [String(parsed)]; } catch { return text.split("\n"); } } /** Format CLI output for the LLM: detect JSON → formatJsonTable, * TSV → formatTsv, else raw lines. Returns formatted lines. */ export function formatCliOutput(stdout: string): { lines: string[]; source: "json" | "tsv" | "raw" } { if (isJson(stdout)) { return { lines: formatJsonTable(stdout), source: "json" }; } if (isTsv(stdout)) { return { lines: formatTsv(stdout), source: "tsv" }; } return { lines: stdout.split("\n"), source: "raw" }; } /** Truncate text for the LLM (head-biased) with an explicit notice. */ export function truncateForLlm(text: string, maxBytes: number): { text: string; truncated: boolean; totalBytes: number } { const totalBytes = Buffer.byteLength(text, "utf8"); if (totalBytes <= maxBytes) return { text, truncated: false, totalBytes }; let end = maxBytes; // Avoid splitting a UTF-8 sequence. while (end > 0 && (Buffer.from(text).at(end - 1)! & 0xc0) === 0x80) end--; const sliced = Buffer.from(text).subarray(0, end).toString("utf8"); return { text: sliced, truncated: true, totalBytes }; }