Something went wrong. Try again.
last activity notifications for opencode
Something went wrong. Try again.
6.4 kB · 152 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153/** * 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<Record<Exclude<Outcome, "succeeded">, 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;