/** * Pure render functions: turn an {@link ActivitySnapshot} + {@link Config} * into the single short status line this plugin paints. * * Two clocks drive the line — the last **prompt** (`promptAtMs`) and the * last **part of any kind from the LLM** (`partAtMs`). The {@link Config.view} * mode picks the layout when both are known: * * - `diff` (default) — the fresher age plus the span between the clocks: * `20m + 7m`; * - `condensed` — both bare ages: `20m / 27m`; * - `expanded` — labeled text: `part 20m ago · prompt 27m ago`. * * With only one clock known, every mode degrades to the settled-clock line * ("5.4m ago" / "now" / "14:32"), still bare by the opencode-quota-finer * compact-line convention. */ import type { Outcome } from "../activity/tracker.ts"; import type { Config } from "../config/config.ts"; import { formatClock, formatCompact, formatElapsed } from "../time/format.ts"; /** A point-in-time observation of the activity ledger + durable store. */ export interface ActivitySnapshot { readonly nowMs: number; /** Last user prompt (ms epoch), when known. */ readonly promptAtMs?: number; /** Last LLM part of any kind (ms epoch), when known. */ readonly partAtMs?: number; /** Last turn settle point (ms epoch), when known. */ readonly settledAtMs?: number; /** How the last settled turn ended; non-success earns a marker. */ readonly outcome?: Outcome; /** The session is running a turn (holds `now` only while the part clock is unknown). */ readonly working?: boolean; } /** * The single-clock fallback point: the part clock when known, else the * freshest durable proxy (settle point or prompt). */ export function primaryAtMs(snap: ActivitySnapshot): number | undefined { if (snap.partAtMs !== undefined) return snap.partAtMs; const candidates = [snap.settledAtMs, snap.promptAtMs].filter( (atMs): atMs is number => typeof atMs === "number" && Number.isFinite(atMs) && atMs > 0, ); return candidates.length > 0 ? Math.max(...candidates) : undefined; } /** Elapsed milliseconds since a snapshot's primary activity. */ export function elapsedMs(snap: ActivitySnapshot): number { const atMs = primaryAtMs(snap); return atMs === undefined ? 0 : Math.max(0, snap.nowMs - atMs); } /** Below this elapsed time a duration reads "now" instead of a number. */ const NOW_THRESHOLD_MS = 2_000; /** Spans below this read as zero and are dropped from the diff view. */ const SPAN_FLOOR_MS = NOW_THRESHOLD_MS; /** A bare elapsed duration: "now", "5.4m" — a span has no wall-clock form, so the `clock` style falls back to compact. */ function bareElapsed(elapsedMs: number, config: Config): string { if (config.formatStyle === "clock") return formatCompact(elapsedMs); if (elapsedMs < NOW_THRESHOLD_MS) return "now"; return formatElapsed(elapsedMs, config.formatStyle, { decimals: config.decimals }); } /** The bare duration since a timestamp: "now", "5.4m", or a wall-clock "14:32". */ function bareAt(snap: ActivitySnapshot, atMs: number, config: Config): string { if (config.formatStyle === "clock") return formatClock(atMs, snap.nowMs); return bareElapsed(Math.max(0, snap.nowMs - atMs), config); } /** Marker appended for settle outcomes other than success. */ const OUTCOME_MARKERS: Readonly, string>> = { failed: "✗", interrupted: "⋯", }; /** The marker for an outcome, or `""` when there is nothing to flag. */ function outcomeMarker(outcome: Outcome | undefined): string { if (!outcome || outcome === "succeeded") return ""; return ` ${OUTCOME_MARKERS[outcome]}`; } /** The single-clock phrase — the settled-clock line: "5.4m ago", "now", or the `clock` style's wall-clock. */ function agoAt(snap: ActivitySnapshot, atMs: number, config: Config): string { const bare = bareAt(snap, atMs, config); return config.formatStyle === "clock" || bare === "now" ? bare : `${bare} ago`; } /** * The single-clock phrase — "5.4m ago", "now", or the `clock` style's * wall-clock. A working session holds "now" (the part clock is unknown * here, so there is nothing fresher to show); a non-success outcome earns a * trailing marker. */ export function renderShort(snap: ActivitySnapshot, config: Config): string { const atMs = primaryAtMs(snap); if (atMs === undefined) return ""; const marker = outcomeMarker(snap.outcome); if (snap.working && snap.partAtMs === undefined) return `now${marker}`; return `${agoAt(snap, atMs, config)}${marker}`; } /** * The two-clock line, laid out per the configured view mode. Both clocks * are known by the time this is called. */ function renderTwoClock(snap: ActivitySnapshot, config: Config): string { const prompt = snap.promptAtMs as number; const part = snap.partAtMs as number; const marker = outcomeMarker(snap.outcome); switch (config.view) { case "condensed": // both bare ages: "20m / 27m" return `${bareAt(snap, part, config)} / ${bareAt(snap, prompt, config)}${marker}`; case "diff": { // the fresher age plus the span between the clocks: "20m + 7m" const span = Math.abs(prompt - part); if (span <= SPAN_FLOOR_MS) return `${bareAt(snap, Math.max(part, prompt), config)}${marker}`; const fresherAge = Math.min(snap.nowMs - part, snap.nowMs - prompt); return `${bareElapsed(fresherAge, config)} + ${bareElapsed(span, config)}${marker}`; } case "expanded": return `part ${agoAt(snap, part, config)} · prompt ${agoAt(snap, prompt, config)}${marker}`; } } /** * The full line for a snapshot: the two-clock view when both clocks are * known, the settled-clock phrase otherwise, or `""` when nothing is known * (the caller paints nothing — quiet degradation, no fake clock). */ export function renderLine( snap: ActivitySnapshot, config: Config, maxWidth = DEFAULT_MAX_WIDTH, ): string { const full = snap.promptAtMs !== undefined && snap.partAtMs !== undefined ? renderTwoClock(snap, config) : renderShort(snap, config); if (!full) return ""; if (full.length <= maxWidth) return full; if (maxWidth <= 1) return "…"; return `${full.slice(0, Math.max(0, maxWidth - 1)).trimEnd()}…`; } /** Default cap for the rendered line before it gets an ellipsis (sized for the expanded two-clock view). */ export const DEFAULT_MAX_WIDTH = 40;