/** Small pure helpers shared across the extension. Kept dependency-free so they are trivial to test. */ /** * Collapses whitespace and clips to `max` characters, adding an ellipsis when clipped. * Used for list rows and session titles, where a stray newline would break the layout. */ export function truncate(text: string, max: number): string { const collapsed = text.replace(/\s+/g, " ").trim(); if (collapsed.length <= max) return collapsed; return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`; } /** "just now", "12m ago", "3h ago", then an absolute date once it is older than a day. */ export function formatRelativeTime(timestamp: number, now: number = Date.now()): string { const deltaSeconds = Math.round((now - timestamp) / 1000); if (!Number.isFinite(deltaSeconds) || deltaSeconds < 0) return "just now"; if (deltaSeconds < 45) return "just now"; const minutes = Math.round(deltaSeconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.round(hours / 24); if (days < 7) return `${days}d ago`; return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } export type RecencyGroup = "Today" | "Yesterday" | "This week" | "Older"; /** * Buckets a timestamp for the conversation sidebar. * * Raycast groups its sidebar this way, and it buys the grouping that per-row * timestamps were trying to convey, without putting a timestamp on every row. */ export function recencyGroup(timestamp: number, now: number = Date.now()): RecencyGroup { const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); const start = startOfToday.getTime(); const DAY = 86_400_000; if (timestamp >= start) return "Today"; if (timestamp >= start - DAY) return "Yesterday"; if (timestamp >= start - 7 * DAY) return "This week"; return "Older"; } /** * A byte count for a person to read: "6.6 GB", "326 B". * * Decimal units, not the 1024-based ones a memory figure would use, because * that is what every other tool in this ecosystem prints: `ollama list` shows * 6550813657 bytes as 6.6 GB, and a number that disagreed with the terminal the * user just looked at would read as a bug in this extension. * * `undefined` for a size that is absent or nonsense, so a caller can leave the * field out rather than print "NaN B". */ export function formatBytes(bytes: number | undefined): string | undefined { if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return undefined; if (bytes < 1000) return `${Math.round(bytes)} B`; const units = ["kB", "MB", "GB", "TB"]; let value = bytes / 1000; let unit = 0; while (value >= 1000 && unit < units.length - 1) { value /= 1000; unit += 1; } return `${value.toFixed(1)} ${units[unit]}`; } /** Pulls a human-readable message out of anything that can be thrown. */ export function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; try { return JSON.stringify(error); } catch { return String(error); } } /** True when a rejected promise came from an AbortController rather than a real failure. */ export function isAbortError(error: unknown): boolean { return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted")); } /** * Builds the first line of a prompt into a session title. * Prompt text often starts with markdown decoration, which reads badly in a list row. */ export function titleFromPrompt(prompt: string, max: number): string { const firstLine = prompt.split("\n").find((line) => line.trim().length > 0) ?? prompt; const cleaned = firstLine.replace(/^[#>*\-\s]+/, "").trim(); return truncate(cleaned || "New chat", max); }