import { type StatusPillState, renderStatusGlyph, renderStatusPill, statusPillWidth, } from "./tool-status.ts"; import { muted, inlineThemeText } from "./tui.ts"; import type { RenderTheme } from "./types.ts"; function paintAccentUrl(url: string, width: number, theme?: RenderTheme): string { const t = url.length <= width ? url.padEnd(width, " ") : width <= 1 ? "…" : `${url.slice(0, Math.ceil((width - 1) / 2))}…${url.slice(url.length - Math.floor((width - 1) / 2))}`; return inlineThemeText("accent", t, theme) ?? t; } /** Data model for expanded crawl/batch per-resource detail lists. */ export interface ResourceListItem { readonly ok: boolean; readonly url: string; readonly finalUrl?: string; readonly title?: string; readonly excerpt?: string; readonly fields: { status?: number | string; mode?: string; format?: string; contentType?: string; downloadedBytes?: number; durationMs?: number; cached?: boolean; staleness?: string; truncated?: boolean; }; readonly error?: { code?: string; phase?: string; message?: string }; } /** * Renders expanded per-resource details for crawl/batch result cards. * * Example output: * * ```txt * └─ Per-page details: * ✓ https://example.com * status 200 · fast · markdown · 1.2 KB * ``` */ export function renderResourceItemList( items: readonly ResourceListItem[], options: { header: string; maxItems?: number; metadata?: { jobId?: unknown; packageResponseId?: unknown }; }, ): string { const max = options.maxItems ?? 20; const lines = [`\u2514\u2500 ${options.header}`]; for (const item of items.slice(0, max)) lines.push(...renderResourceItemLines(item)); if (items.length > max) lines.push(`… ${items.length - max} more item(s)`); const { jobId, packageResponseId: pkg } = options.metadata ?? {}; if (typeof jobId === "string" || typeof pkg === "string") lines.push("", "Stored handles:"); if (typeof jobId === "string") lines.push(`jobId: ${jobId}`); if (typeof pkg === "string") lines.push(`packageResponseId: ${pkg}`); return lines.join("\n"); } function renderResourceItemLines(item: ResourceListItem): string[] { if (!item.ok) return [ `✕ ${item.url || "unknown URL"}`, ` ${[item.error?.code, item.error?.phase, item.error?.message ?? "failed"].filter(Boolean).join(" · ")}`, ]; const f = item.fields; const fieldsStr = [ f.status ? `status ${f.status}` : undefined, f.mode, f.format, f.contentType, formatBytes(f.downloadedBytes), formatDuration(f.durationMs), f.cached ? `cache hit${f.staleness ? ` ${f.staleness}` : ""}` : undefined, f.truncated ? "truncated" : undefined, ] .filter(Boolean) .join(" · ") || "fetched"; const lines = [`✓ ${item.url}`, ` ${fieldsStr}`]; if (item.finalUrl && item.finalUrl !== item.url) lines.push(` final: ${item.finalUrl}`); if (item.title) lines.push(` title: ${item.title}`); if (item.excerpt) lines.push(` excerpt: ${item.excerpt}`); return lines; } /** Formats bytes as `B` or one-decimal `KB`, returning undefined for missing values. */ export function formatBytes(bytes: number | undefined): string | undefined { if (typeof bytes !== "number") return; return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`; } /** Formats durations as rounded milliseconds or one-decimal seconds. */ export function formatDuration(ms: number | undefined): string | undefined { if (typeof ms !== "number") return; return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; } export type ToolResourceStatusState = StatusPillState; export type ToolResourceState = "ok" | "error" | "pending" | "loading"; /** Inputs for a full-width resource row with status glyph and pill. */ export interface ToolResourceStatusRow { url: string; state: ToolResourceStatusState; width: number; theme?: RenderTheme; label?: string; startedAtMs?: number; statusBox?: string; restoreBg?: string; } /** Inputs for compact resource rows, badge rows, and full status rows. */ export interface ToolResourceOptions extends Partial< Omit > { url: string; state?: ToolResourceState | ToolResourceStatusState; badge?: string; detail?: string; } /** * Renders a full-width URL status row with a glyph and right-side pill. * * Example output, with terminal padding omitted: * * ```txt * ✓ https://example.com [ done ] * ``` */ export function toolResourceStatus(row: ToolResourceStatusRow): string { const statusWidth = statusPillWidth(row.width); const box = row.statusBox ?? renderStatusPill({ label: row.label ?? row.state, state: row.state, width: statusWidth, theme: row.theme, startedAtMs: row.startedAtMs, restoreBg: row.restoreBg, }); return `${renderStatusGlyph(row.state, row.theme)} ${paintAccentUrl(row.url, Math.max(12, row.width - statusWidth - 3), row.theme)} ${box}`; } /** * Renders the most compact resource form required by the caller. * * Examples: * * ```txt * ✓ https://example.com * https://example.com [ sitemap ] * ``` */ export function toolResource(o: ToolResourceOptions): string { if (o.badge !== undefined && o.width !== undefined) { const badgeText = o.badge ? `[ ${o.badge} ]` : ""; const urlWidth = Math.max(12, o.width - badgeText.length - 2); const renderedUrl = paintAccentUrl(o.url, urlWidth, o.theme); const badge = badgeText ? (inlineThemeText("muted", badgeText, o.theme) ?? badgeText) : ""; return badge ? `${renderedUrl} ${badge}` : renderedUrl; } const state = o.state ?? "pending"; const glyphState: StatusPillState = state === "ok" || state === "done" ? "done" : state === "error" ? "error" : state === "loading" ? "loading" : "waiting"; return `${renderStatusGlyph(glyphState, o.theme)} ${o.url}${o.detail ? ` ${muted(o.detail, o.theme)}` : ""}`; }