From 74911d4af438ff68b60c6bcf0fbeaac7b50335a7 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Mon, 1 Jun 2026 23:59:46 +0530 Subject: [PATCH 1/7] feat(cli): CH 03 utilities channel + status-line append fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusline was clobbering the user's existing custom status line — Claude's statusLine is a single command slot and we overwrote it. now setStatusLine stashes any pre-existing command, `distro statusline` runs it (same stdin) and appends our line below, uninstall restores it. same stdin parse feeds CH 03: a locally-built instrument panel rotating alongside news/markets showing claude usage (limits/reset, ctx, cost, cache, burn, time-to-limit), git state, machine vitals, service health. local-only, every-3rd-slot injection, full vs complement layout, gated by utilitiesEnabled pref (distro preferences → utilities panel). --- gitbook-docs/SUMMARY.md | 1 + gitbook-docs/cli/utilities-channel.md | 93 ++++++++ packages/cli/src/commands/daemon.ts | 10 + packages/cli/src/commands/preferences.ts | 48 ++++ packages/cli/src/commands/statusline.ts | 65 ++++- packages/cli/src/lib/claude-settings.ts | 66 ++++-- packages/cli/src/lib/claude-usage.ts | 208 ++++++++++++++++ packages/cli/src/lib/daemon/orchestrator.ts | 26 +- packages/cli/src/lib/render-line.ts | 168 ++++++++++++- packages/cli/src/lib/slot-cache.ts | 4 +- packages/cli/src/lib/utility-slot.ts | 250 ++++++++++++++++++++ packages/cli/src/lib/wrapped-statusline.ts | 53 +++++ packages/shared/src/constants/index.ts | 17 ++ packages/shared/src/types/SlotPayload.ts | 8 +- packages/shared/src/types/UtilityPayload.ts | 72 ++++++ packages/shared/src/types/index.ts | 15 ++ packages/shared/src/types/news.ts | 9 +- 17 files changed, 1087 insertions(+), 26 deletions(-) create mode 100644 gitbook-docs/cli/utilities-channel.md create mode 100644 packages/cli/src/lib/claude-usage.ts create mode 100644 packages/cli/src/lib/utility-slot.ts create mode 100644 packages/cli/src/lib/wrapped-statusline.ts create mode 100644 packages/shared/src/types/UtilityPayload.ts diff --git a/gitbook-docs/SUMMARY.md b/gitbook-docs/SUMMARY.md index baa580d6..4856058a 100644 --- a/gitbook-docs/SUMMARY.md +++ b/gitbook-docs/SUMMARY.md @@ -26,6 +26,7 @@ - [News and Reading (CLI)](cli/news-and-reading.md) - [Slot Cache](cli/ad-cache.md) - [Local Ledger](cli/local-ledger.md) +- [Utilities Channel (CH 03)](cli/utilities-channel.md) - [Dashboard Current State](dashboard/current-state.md) - [Reading List (Dashboard)](dashboard/reading.md) - [Dev Workflow](engineering/dev-workflow.md) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md new file mode 100644 index 00000000..47d05adb --- /dev/null +++ b/gitbook-docs/cli/utilities-channel.md @@ -0,0 +1,93 @@ +# CH 03 — Utilities (dev instrument panel) + +CH 03 is a third rotating slot alongside **CH 01 NEWS** and **CH 02 MARKETS**. +Where those surface _external_ feeds, Utilities is _inward-looking_: a compact +instrument panel of local, real-time stats a developer wants to glance at while +Claude Code works — Claude usage/limits, git state, machine vitals, and service +health. It rotates in the same flow (news → markets → utils → …). + +Unlike news/ticker slots, the utility slot is **built locally by the daemon** +every time it's that slot's turn — it never round-trips `/me/content/next`, and +usage/cost data never leaves the machine. + +## The telemetry feed (and the status-line fix it rode in on) + +Claude Code pipes a rich JSON blob to its `statusLine` command on stdin every +render: `rate_limits.five_hour/.seven_day.{used_percentage,resets_at}`, +`context_window.{used_percentage,context_window_size}`, `cost.total_cost_usd`, +cache token counts, `model.display_name`, `effort.level`, and +`workspace.current_dir`. We previously discarded this. + +`distro statusline` now parses it into a local snapshot +(`~/.distro/claude-usage.json`, latest fields + a short rolling history) which +the utility slot reads. See `lib/claude-usage.ts`. + +### Status-line chaining (append, never clobber) + +Claude Code's `statusLine` is a **single command slot**. When Distro takes it +over, `setStatusLine` (`lib/claude-settings.ts`) now first stashes any +pre-existing user command in `~/.distro/wrapped-statusline.json` +(`lib/wrapped-statusline.ts`). `distro statusline` then: + +1. snapshots the stdin telemetry, +2. runs the wrapped command (feeding it the same stdin, 800 ms timeout) and + emits its output, +3. appends our current slot line **below** it. + +`removeStatusLine` (uninstall) restores the wrapped command instead of deleting +the entry. Net effect: a user's custom status line keeps working untouched, with +the Distro line added beneath it. + +## Buckets + +All buckets are optional — an unavailable source is omitted, never shown as a +fake zeroed gauge (`UtilityPayload` in `@distrotv/shared`). + +| Bucket | Source | Fields | +| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **ai** | statusline snapshot + derived history | 5h/7d limit % + reset, ctx %, cost, cache %, burn $/min, time-to-limit, model/effort, lines ± | +| **git** | `git` in the snapshot's `cwd` (cached 10 s) | branch, ahead/behind, dirty files, uncommitted lines, last-commit age | +| **machine** | `os` + `pmset` + `statfs` (cached 15 s) | cpu %, mem %, battery %, disk free % | +| **health** | status.anthropic.com summary + latency (cached 60 s) | Anthropic status (ok/degraded/down), API latency, online | + +Burn rate and time-to-limit are derived from the rolling cost/limit history in +`deriveUsage`. + +## Generation & rotation + +`createUtilityProvider` (`lib/utility-slot.ts`) caches probe results and +refreshes stale ones **in the background** so `build()` stays synchronous and +fast. The orchestrator (`lib/daemon/orchestrator.ts`) injects a utility slot +every `UTILITY_SLOT_EVERY_N` (=3) non-suppressed picks, gated on the +`utilitiesEnabled` pref. If the provider has no data yet (cold start, no Claude +session), `build()` returns `null` and the rotation falls through to a normal +slot rather than skipping a beat. + +The slot is tagged `cacheSource: "local"`; the orchestrator skips the +`writeNowPlaying` API mirror for it, so usage data stays on-device. + +## Layouts + +- **full** — the complete gauge grid (ctx / 5h / 7d) plus econ, git, machine, + health lines. +- **complement** — drops the raw ctx/5h/7d/cost gauges (a user's own status line + likely already shows them) and leads with the derived stats (burn, time-to- + limit) instead. + +Layout is the `utilitiesLayout` pref: `auto` (default) → `complement` when a +custom status line was wrapped at install, else `full`; `full`/`complement` +force it. Gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%) for +limits / `UTILITY_CTX_WARN_PCT` (90%) for context. + +## Preferences + +`distro preferences` → **utilities panel** toggles `utilitiesEnabled` and picks +the layout. Both are CLI-local (never uploaded), so they write straight to the +config file. The daemon reads `utilitiesLayout` live via the file-watch reload — +no restart needed. + +## Out of scope (v1) + +Backend sync / dashboard usage history; a persistent always-on grid; cross-tool +(Cursor/Copilot) aggregation. CI/build + deploy status are reserved for their own +future channels. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 436878e3..91e76f3d 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -5,6 +5,7 @@ import { platform } from "node:os" import { Command } from "commander" import { configPath, readConfig, writeConfig } from "../lib/config.js" import { openSlotCache } from "../lib/slot-cache.js" +import { createUtilityProvider } from "../lib/utility-slot.js" import { openLedger } from "../lib/ledger.js" import { showAd } from "../lib/daemon/display.js" import { cliVersion, refreshDeviceMetadata } from "../lib/device.js" @@ -357,8 +358,16 @@ export async function runDaemon(): Promise { await writeConfig({ ...current, preferences: next }) } + // CH 03: locally-built utility panel injected into the rotation. Layout pref + // is read live so file-watch reloads take effect without a daemon restart. + let livePrefs = cfg.preferences + const utilityProvider = createUtilityProvider({ + getLayoutPref: () => livePrefs.utilitiesLayout, + }) + orchestrator = createOrchestrator({ slotCache, + utilityProvider, ledger, display: { show: showAd }, keyCapture, @@ -382,6 +391,7 @@ export async function runDaemon(): Promise { try { const next = await readConfig() if (!next) return + livePrefs = next.preferences orchestrator.updatePreferences(next.preferences) const nextFp = fingerprintPrefs(next.preferences) if (nextFp !== lastPrefsFingerprint) { diff --git a/packages/cli/src/commands/preferences.ts b/packages/cli/src/commands/preferences.ts index 4cb95439..72fa6e71 100644 --- a/packages/cli/src/commands/preferences.ts +++ b/packages/cli/src/commands/preferences.ts @@ -16,6 +16,7 @@ type Action = | "channels" | "watchlist" | "alerts" + | "utilities" | "quiet-hours" | "tz" | "topics" @@ -51,6 +52,7 @@ async function showMenu(currentMode: ChannelMode): Promise { { value: "channels", label: "channels (tech / finance / crypto / …)" }, { value: "watchlist", label: "watchlist (add / remove tickers)" }, { value: "alerts", label: "alerts (global threshold)" }, + { value: "utilities", label: "utilities panel (CH 03 — usage, git, machine, health)" }, { value: "quiet-hours", label: "quiet hours (set start / end, blank to disable)" }, { value: "tz", label: "tz offset (auto-detect or manual)" }, { value: "topics", label: "news topics (v1.1 — coming soon)" }, @@ -118,6 +120,47 @@ async function tzAction(current: SyncedPreferences): Promise { log.success(`tz offset set to ${manual} minutes`) } +// utilitiesEnabled / utilitiesLayout are CLI-local (never uploaded), so this +// writes straight to the config file rather than through the preferences API. +async function utilitiesAction(): Promise { + const cfg = await readConfig() + if (!cfg) { + log.warn("not initialized — run `distro init` first") + return + } + const cur = cfg.preferences + const enabled = await confirm({ + message: `utilities panel is ${cur.utilitiesEnabled ? "on" : "off"} — keep it on?`, + initialValue: cur.utilitiesEnabled, + }) + if (isCancel(enabled)) return + + let layout = cur.utilitiesLayout + if (enabled) { + const pick = await select({ + message: "panel layout", + initialValue: cur.utilitiesLayout, + options: [ + { value: "auto", label: "auto (complement if you have a custom status line, else full)" }, + { value: "full", label: "full (all gauges)" }, + { value: "complement", label: "complement (skip gauges your status line already shows)" }, + ], + }) + if (isCancel(pick)) return + layout = pick as "auto" | "full" | "complement" + } + + await writeConfig({ + apiUrl: cfg.apiUrl, + auth: cfg.auth, + user: cfg.user, + device: cfg.device, + cli: cfg.cli, + preferences: { ...cur, utilitiesEnabled: enabled === true, utilitiesLayout: layout }, + }) + log.success(`utilities ${enabled ? `on · ${layout}` : "off"}`) +} + async function runPreferences(): Promise { intro("distro preferences") let prefs = await getPreferences() @@ -205,6 +248,11 @@ async function runPreferences(): Promise { continue } + if (action === "utilities") { + await utilitiesAction() + continue + } + if (action === "quiet-hours") { await quietHoursAction(prefs) prefs = await getPreferences() diff --git a/packages/cli/src/commands/statusline.ts b/packages/cli/src/commands/statusline.ts index dec2d939..71f21030 100644 --- a/packages/cli/src/commands/statusline.ts +++ b/packages/cli/src/commands/statusline.ts @@ -1,16 +1,71 @@ +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" import { Command } from "commander" import { readStatusLine } from "../lib/statusline-state.js" +import { parseStatuslineInput, recordUsage } from "../lib/claude-usage.js" +import { readWrappedStatusLine } from "../lib/wrapped-statusline.js" + +// Invoked by Claude Code's statusLine on a cadence. Claude pipes session JSON +// on stdin; we (1) snapshot that telemetry for CH 03, (2) run the user's +// original status line (if Distro wrapped one) and emit its output, then +// (3) append our current slot line BELOW it. Must be fast and never throw. + +// Read stdin only when it's piped — reading fd 0 on an interactive tty blocks. +function readStdin(): string { + if (process.stdin.isTTY) return "" + try { + return readFileSync(0, "utf8") + } catch { + return "" + } +} -// Invoked by Claude Code's statusLine hook on a cadence. Claude pipes session -// JSON on stdin (ignored) and renders our stdout at the bottom. Must be fast -// and must never throw — print the current slot line (or nothing) and exit. export const statuslineCmd = new Command("statusline") .description("print the current Distro TV slot as one line (used by Claude Code's statusLine)") .action(() => { + const input = readStdin() + + // snapshot telemetry for the utilities channel — best-effort. + if (input) { + try { + recordUsage(parseStatuslineInput(input, Date.now())) + } catch { + /* ignore */ + } + } + + const parts: string[] = [] + + // run the wrapped (user's original) status line first, feeding it the same + // stdin. Tight timeout so a slow custom line can't stall Claude's render. + const wrapped = readWrappedStatusLine() + if (wrapped) { + try { + const res = spawnSync("sh", ["-c", wrapped.command], { + input, + encoding: "utf8", + timeout: 800, + maxBuffer: 1024 * 1024, + }) + const out = (res.stdout ?? "").replace(/\n+$/, "") + if (out.length > 0) parts.push(out) + } catch { + /* fall through — show our line alone */ + } + } + + // our slot line below the wrapped output (empty when nothing is playing). + try { + const ours = readStatusLine() + if (ours.length > 0) parts.push(ours) + } catch { + /* ignore */ + } + try { - process.stdout.write(readStatusLine()) + process.stdout.write(parts.join("\n")) } catch { - /* print nothing on any failure */ + /* ignore */ } process.exit(0) }) diff --git a/packages/cli/src/lib/claude-settings.ts b/packages/cli/src/lib/claude-settings.ts index 35701ca5..5ae3cfd0 100644 --- a/packages/cli/src/lib/claude-settings.ts +++ b/packages/cli/src/lib/claude-settings.ts @@ -1,6 +1,11 @@ import { randomBytes } from "node:crypto" import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises" import { basename, dirname, join } from "node:path" +import { + clearWrappedStatusLine, + readWrappedStatusLine, + writeWrappedStatusLine, +} from "./wrapped-statusline.js" export interface HookCommand { type: "command" @@ -212,39 +217,72 @@ function buildStatusLineCommand(binPath: string): string { // matches " statusline" (bare / single / double quoted), capturing the bin const STATUSLINE_COMMAND_RE = /^\s*(?:"((?:\\.|[^"])*)"|'([^']*)'|(\S+))\s+statusline\s*$/ +// True when `command` is our own " statusline". +function isOurStatusLineCommand(command: unknown): boolean { + if (typeof command !== "string") return false + const m = STATUSLINE_COMMAND_RE.exec(command) + if (!m) return false + const bin = m[1] !== undefined ? unescapeDoubleQuoted(m[1]) : (m[2] ?? m[3] ?? "") + return DISTRO_BIN_RE.test(basename(bin)) +} + // Point Claude Code's statusLine at `distro statusline` so it renders the // current slot at the bottom. Idempotent — no-op if already pointed at binPath. +// +// Claude's statusLine is a SINGLE command slot, so before we take it over we +// stash any pre-existing user command (see wrapped-statusline.ts); `distro +// statusline` then runs it and appends our line below, and uninstall restores +// it. Re-captures if the user later swaps in a new custom command. export function setStatusLine( settings: Settings, binPath: string ): { next: Settings; changed: boolean } { const command = buildStatusLineCommand(binPath) - const current = settings.statusLine as { type?: unknown; command?: unknown } | undefined - if ( - current && - typeof current === "object" && - current.type === "command" && - current.command === command - ) { - return { next: settings, changed: false } + const current = settings.statusLine as + | { type?: unknown; command?: unknown; padding?: unknown } + | undefined + + if (current && typeof current === "object" && typeof current.command === "string") { + if (isOurStatusLineCommand(current.command)) { + // already ours — leave any previously-captured wrapped command intact. + if (current.command === command) return { next: settings, changed: false } + } else { + // foreign command — preserve it so we can chain + later restore it. + writeWrappedStatusLine({ + type: typeof current.type === "string" ? current.type : "command", + command: current.command, + ...(typeof current.padding === "number" ? { padding: current.padding } : {}), + }) + } } + return { next: { ...settings, statusLine: { type: "command", command, padding: 0 } }, changed: true, } } -// Strip the statusLine entry only when it's ours (" statusline"), -// so we never clobber a user's custom status line. +// On uninstall, restore the user's original status line if we wrapped one; +// otherwise strip the entry — but only when it's ours, so we never clobber a +// custom status line the user added after us. export function removeStatusLine(settings: Settings): { next: Settings; changed: boolean } { const current = settings.statusLine as { command?: unknown } | undefined if (!current || typeof current !== "object" || typeof current.command !== "string") { return { next: settings, changed: false } } - const m = STATUSLINE_COMMAND_RE.exec(current.command) - if (!m) return { next: settings, changed: false } - const bin = m[1] !== undefined ? unescapeDoubleQuoted(m[1]) : (m[2] ?? m[3] ?? "") - if (!DISTRO_BIN_RE.test(basename(bin))) return { next: settings, changed: false } + if (!isOurStatusLineCommand(current.command)) return { next: settings, changed: false } + + const wrapped = readWrappedStatusLine() + if (wrapped) { + const restored: StatusLineConfig = { + type: "command", + command: wrapped.command, + ...(typeof wrapped.padding === "number" ? { padding: wrapped.padding } : {}), + } + clearWrappedStatusLine() + return { next: { ...settings, statusLine: restored }, changed: true } + } + const next = { ...settings } delete next.statusLine return { next, changed: true } diff --git a/packages/cli/src/lib/claude-usage.ts b/packages/cli/src/lib/claude-usage.ts new file mode 100644 index 00000000..79b63628 --- /dev/null +++ b/packages/cli/src/lib/claude-usage.ts @@ -0,0 +1,208 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { join } from "node:path" +import { configDir } from "./config.js" + +// Telemetry feed for CH 03. Claude Code pipes a rich JSON blob to the status +// line command on stdin every render; `distro statusline` parses it here and +// persists a snapshot + a short rolling history. The daemon reads the snapshot +// when it builds a utility slot. Stays entirely local — never uploaded. + +const SNAPSHOT_VERSION = 1 +const HISTORY_MAX = 30 + +// Fields we lift straight from the latest stdin payload. +export interface UsageLatest { + ts: number + cwd?: string + repoName?: string + costUsd?: number + linesAdded?: number + linesRemoved?: number + model?: string + effort?: string + ctxPct?: number + ctxSize?: number + cachePct?: number + fiveHourPct?: number + fiveHourResetAt?: number // epoch ms + sevenDayPct?: number + sevenDayResetAt?: number // epoch ms + version?: string +} + +// Trimmed sample kept in the ring buffer for rate derivation. +interface UsageSample { + ts: number + costUsd?: number + fiveHourPct?: number + sevenDayPct?: number +} + +interface UsageSnapshot { + version: number + latest: UsageLatest + history: UsageSample[] +} + +export interface UsageDerived { + burnUsdPerMin?: number + timeToLimitMin?: number +} + +function snapshotPath(): string { + return join(configDir(), "claude-usage.json") +} + +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined +} + +// resets_at comes through as epoch seconds; normalize to ms. Guard values that +// already look like ms so we don't double-scale. +function toMs(v: unknown): number | undefined { + const n = num(v) + if (n === undefined) return undefined + return n < 1e12 ? Math.round(n * 1000) : Math.round(n) +} + +function clampPct(v: number | undefined): number | undefined { + if (v === undefined) return undefined + return Math.max(0, Math.min(100, Math.round(v))) +} + +// Strip "Claude " + any trailing parenthetical, mirroring the reference script. +function cleanModel(name: string | undefined): string | undefined { + if (!name) return undefined + return name + .replace(/^Claude /, "") + .replace(/\s*\([^)]*\)\s*$/, "") + .trim() +} + +// Parse the Claude Code status-line stdin JSON into our latest-fields shape. +// Tolerant: every field is optional and bad input yields an empty-ish object. +export function parseStatuslineInput(raw: string, now: number): UsageLatest { + let j: Record = {} + try { + j = JSON.parse(raw) as Record + } catch { + return { ts: now } + } + const pick = (path: string[]): unknown => { + let cur: unknown = j + for (const k of path) { + if (cur === null || typeof cur !== "object") return undefined + cur = (cur as Record)[k] + } + return cur + } + + const cacheRead = num(pick(["context_window", "current_usage", "cache_read_input_tokens"])) ?? 0 + const cacheCreate = + num(pick(["context_window", "current_usage", "cache_creation_input_tokens"])) ?? 0 + const inTok = num(pick(["context_window", "current_usage", "input_tokens"])) ?? 0 + const cacheDenom = cacheRead + cacheCreate + inTok + const cachePct = cacheDenom > 0 ? Math.round((cacheRead * 100) / cacheDenom) : undefined + + return { + ts: now, + cwd: str(pick(["workspace", "current_dir"])) ?? str(pick(["cwd"])), + repoName: str(pick(["workspace", "repo", "name"])), + costUsd: num(pick(["cost", "total_cost_usd"])), + linesAdded: num(pick(["cost", "total_lines_added"])), + linesRemoved: num(pick(["cost", "total_lines_removed"])), + model: cleanModel(str(pick(["model", "display_name"]))), + effort: str(pick(["effort", "level"])) ?? str(pick(["model", "reasoning_effort"])), + ctxPct: clampPct(num(pick(["context_window", "used_percentage"]))), + ctxSize: num(pick(["context_window", "context_window_size"])), + cachePct, + fiveHourPct: clampPct(num(pick(["rate_limits", "five_hour", "used_percentage"]))), + fiveHourResetAt: toMs(pick(["rate_limits", "five_hour", "resets_at"])), + sevenDayPct: clampPct(num(pick(["rate_limits", "seven_day", "used_percentage"]))), + sevenDayResetAt: toMs(pick(["rate_limits", "seven_day", "resets_at"])), + version: str(pick(["version"])), + } +} + +function readSnapshot(): UsageSnapshot | null { + try { + const parsed = JSON.parse(readFileSync(snapshotPath(), "utf8")) as UsageSnapshot + if (parsed?.version !== SNAPSHOT_VERSION) return null + return parsed + } catch { + return null + } +} + +function writeSnapshot(snap: UsageSnapshot): void { + try { + const dir = configDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = join(dir, `.claude-usage.${randomBytes(6).toString("hex")}.tmp`) + writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 }) + renameSync(tmp, snapshotPath()) + } catch { + /* never throw from the status-line path */ + } +} + +// Append a freshly-parsed sample, capped at HISTORY_MAX. Called once per +// status-line render — must be fast and never throw. +export function recordUsage(latest: UsageLatest): void { + const prev = readSnapshot() + const history = prev?.history ?? [] + history.push({ + ts: latest.ts, + costUsd: latest.costUsd, + fiveHourPct: latest.fiveHourPct, + sevenDayPct: latest.sevenDayPct, + }) + while (history.length > HISTORY_MAX) history.shift() + writeSnapshot({ version: SNAPSHOT_VERSION, latest, history }) +} + +// Read the latest snapshot, or null when none/stale. +export function readUsageSnapshot(now: number, staleMs: number): UsageLatest | null { + const snap = readSnapshot() + if (!snap) return null + if (now - snap.latest.ts > staleMs) return null + return snap.latest +} + +// Burn rate ($/min) from cumulative cost over the history window, and a +// projection of minutes until the nearest rate-limit window hits 100%. +export function deriveUsage(now: number, staleMs: number): UsageDerived { + const snap = readSnapshot() + if (!snap) return {} + const hist = snap.history.filter((s) => now - s.ts <= Math.max(staleMs, 10 * 60_000)) + if (hist.length < 2) return {} + const first = hist[0] + const last = hist[hist.length - 1] + if (!first || !last) return {} + const dtMin = (last.ts - first.ts) / 60_000 + if (dtMin <= 0) return {} + + const out: UsageDerived = {} + if (first.costUsd !== undefined && last.costUsd !== undefined) { + const dCost = last.costUsd - first.costUsd + if (dCost > 0) out.burnUsdPerMin = dCost / dtMin + } + + // project minutes-to-100% for whichever window is climbing fastest. + const proj = (firstPct?: number, lastPct?: number): number | undefined => { + if (firstPct === undefined || lastPct === undefined) return undefined + const rate = (lastPct - firstPct) / dtMin // pct per min + if (rate <= 0) return undefined + return Math.max(0, (100 - lastPct) / rate) + } + const five = proj(first.fiveHourPct, last.fiveHourPct) + const seven = proj(first.sevenDayPct, last.sevenDayPct) + const candidates = [five, seven].filter((v): v is number => v !== undefined) + if (candidates.length > 0) out.timeToLimitMin = Math.round(Math.min(...candidates)) + return out +} diff --git a/packages/cli/src/lib/daemon/orchestrator.ts b/packages/cli/src/lib/daemon/orchestrator.ts index f621cd8a..60088e6b 100644 --- a/packages/cli/src/lib/daemon/orchestrator.ts +++ b/packages/cli/src/lib/daemon/orchestrator.ts @@ -9,10 +9,12 @@ import { PROGRESS_CAP, PROGRESS_SNAP_HOLD_MS, PROGRESS_TICK_MS, + UTILITY_SLOT_EVERY_N, type DevdripPreferences, type SlotKind, } from "@distrotv/shared" import type { SlotCache, CachedSlot } from "../slot-cache.js" +import type { UtilityProvider } from "../utility-slot.js" import type { Ledger, LocalNewsImpression } from "../ledger.js" import type { KeyCapture } from "./input.js" import { step, type Effect, type Event, type State } from "./state-machine.js" @@ -44,6 +46,8 @@ export type OpenUrl = (url: string) => void export interface OrchestratorDeps { slotCache: SlotCache + // CH 03: builds the locally-generated utility panel injected into rotation. + utilityProvider?: UtilityProvider ledger: Ledger display: DisplayApi keyCapture: KeyCapture @@ -175,6 +179,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { // double the ads a user sees in an hour/day. const hourlyTimestamps: number[] = [] const dailyTimestamps: number[] = [] + // counts non-suppressed picks so we can inject a utility panel every Nth one + // (news → ticker → utils → …). Resets on daemon restart (acceptable for v1). + let pickCount = 0 function getOrCreateSession(key: string, tty: string | null): Session { let s = sessions.get(key) @@ -422,7 +429,12 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } return case "displayAd": { - const payloadId = effect.ad.kind === "news" ? effect.ad.id : effect.ad.symbol + const payloadId = + effect.ad.kind === "news" + ? effect.ad.id + : effect.ad.kind === "ticker" + ? effect.ad.symbol + : "utility" if (!effect.tty) { deps.log.warn("display skipped: no tty path", { payloadId }) queueMicrotask(() => dispatch({ kind: "dismiss", now: now(), tty: session.tty })) @@ -686,6 +698,18 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { deps.log.debug("slot suppressed", { context, reason, tty: session.tty }) return null } + pickCount += 1 + // every Nth pick is the CH 03 utility panel — but only when enabled and the + // provider actually has data (cold start / no Claude session → falls through + // to a normal slot rather than skipping a rotation). + if ( + deps.utilityProvider && + preferences.utilitiesEnabled && + pickCount % UTILITY_SLOT_EVERY_N === 0 + ) { + const util = deps.utilityProvider.build() + if (util) return util + } const slot = deps.slotCache.next() if (!slot) { deps.log.debug("cache empty", { context }) diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index 514ea060..d2c73e78 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -1,5 +1,6 @@ +import { UTILITY_CTX_WARN_PCT, UTILITY_LIMIT_WARN_PCT, type UtilityPayload } from "@distrotv/shared" import type { CachedSlot } from "./slot-cache.js" -import { color, type ColorMode } from "./ansi.js" +import { bgRgb, color, rgb, type ColorMode } from "./ansi.js" import { renderChip, getBrandName, chipLabelFor } from "./brand-colors.js" import { renderChart, directionFor } from "./sparkline.js" @@ -95,6 +96,10 @@ export function renderSlotLine( const dot = color("muted", "·", mode) const nudge = updateLatest ? [updateNudge(updateLatest, mode)] : [] + if (slot.kind === "utility") { + return renderUtilityPanel(slot, mode, nudge, dot) + } + if (slot.kind === "ticker") { const header = `${color("indigo", "▍", mode)} ${color("indigo", "markets", mode)} ${dot} ${color("muted", "live", mode)}` @@ -144,3 +149,164 @@ export function renderSlotLine( const footer = `${LEFT_PAD}${color("muted", "distro tv · news", mode)}` return [...nudge, header, metaLine, ...hlLines, footer].join("\n") } + +// ── CH 03 utility panel ───────────────────────────────────────────────────── + +type Rgb = readonly [number, number, number] + +// per-gauge accent hues (echoing an instrument-panel feel, in our palette) +const GAUGE_CTX: Rgb = [255, 150, 40] // amber — context (focal) +const GAUGE_5H: Rgb = [132, 165, 240] // periwinkle — 5h limit +const GAUGE_7D: Rgb = [94, 200, 188] // teal — weekly limit +const TRACK: Rgb = [60, 64, 74] // shared neutral track +const WARN: Rgb = [240, 97, 109] // red — at/over threshold + +const BAR_W = 8 + +// Colored progress bar: bg blocks in truecolor, fg block chars otherwise. +function renderBar(pct: number, width: number, fill: Rgb, mode: ColorMode): string { + const p = Math.max(0, Math.min(100, pct)) + const filled = Math.min(width, Math.round((p * width) / 100)) + const empty = width - filled + if (mode === "truecolor") { + return ( + bgRgb(" ".repeat(filled), fill[0], fill[1], fill[2], mode) + + bgRgb(" ".repeat(empty), TRACK[0], TRACK[1], TRACK[2], mode) + ) + } + return ( + rgb("█".repeat(filled), fill[0], fill[1], fill[2], mode) + + color("muted", "░".repeat(empty), mode) + ) +} + +// "1h 1m" / "4d 18h" / "12m" / "-" countdown to an epoch-ms reset. +function fmtReset(at: number | undefined, now: number): string { + if (at === undefined) return "-" + const diff = Math.max(0, at - now) + const d = Math.floor(diff / 86_400_000) + const h = Math.floor((diff % 86_400_000) / 3_600_000) + const m = Math.floor((diff % 3_600_000) / 60_000) + if (d > 0) return `${d}d ${h}h` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} + +// label + bar + "NN%[ · suffix]"; turns red + ⚠ at/over the warn threshold. +function gauge( + label: string, + pct: number | undefined, + warnAt: number, + accent: Rgb, + suffix: string, + mode: ColorMode +): string | null { + if (pct === undefined) return null + const warned = pct >= warnAt + const fill = warned ? WARN : accent + const bar = renderBar(pct, BAR_W, fill, mode) + const pctTok = warned ? "negative" : "muted" + const pre = warned ? color("warning", "⚠ ", mode) : "" + const tail = suffix ? ` ${color("muted", `· ${suffix}`, mode)}` : "" + return `${pre}${color("muted", label, mode)} ${bar} ${color(pctTok, `${pct}%`, mode)}${tail}` +} + +function renderUtilityPanel( + slot: UtilityPayload, + mode: ColorMode, + nudge: string[], + dot: string +): string { + const now = Date.now() + const sep = ` ${dot} ` + const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)} ${dot} ${color("muted", "live", mode)}` + const lines: string[] = [...nudge, header] + const complement = slot.layout === "complement" + + const { ai, git, machine, health } = slot + + if (ai) { + if (!complement) { + // full: raw gauges the user doesn't already have on screen + const gauges = [ + gauge("ctx", ai.ctxPct, UTILITY_CTX_WARN_PCT, GAUGE_CTX, "", mode), + gauge( + "5h", + ai.fiveHourPct, + UTILITY_LIMIT_WARN_PCT, + GAUGE_5H, + fmtReset(ai.fiveHourResetAt, now), + mode + ), + gauge( + "7d", + ai.sevenDayPct, + UTILITY_LIMIT_WARN_PCT, + GAUGE_7D, + fmtReset(ai.sevenDayResetAt, now), + mode + ), + ].filter((g): g is string => g !== null) + if (gauges.length > 0) lines.push(`${LEFT_PAD}${gauges.join(sep)}`) + } + + // derived/econ line — useful in both layouts (basic status lines rarely show these) + const econ: string[] = [] + if (ai.costUsd !== undefined) econ.push(`$${ai.costUsd.toFixed(2)}`) + if (ai.cachePct !== undefined) econ.push(`cache ${ai.cachePct}%`) + if (ai.burnUsdPerMin !== undefined) econ.push(`burn $${ai.burnUsdPerMin.toFixed(2)}/m`) + if (ai.timeToLimitMin !== undefined) econ.push(`~${fmtMins(ai.timeToLimitMin)} to limit`) + if (ai.model) econ.push(ai.effort ? `${ai.model}/${ai.effort}` : ai.model) + if (econ.length > 0) { + lines.push(`${LEFT_PAD}${color("muted", econ.join(" · "), mode)}`) + } + } + + if (git) { + const parts: string[] = [] + if (git.branch) parts.push(color("fg", git.branch, mode)) + const ab: string[] = [] + if (git.ahead) ab.push(`↑${git.ahead}`) + if (git.behind) ab.push(`↓${git.behind}`) + if (ab.length > 0) parts.push(color("muted", ab.join(" "), mode)) + if (git.dirtyFiles !== undefined) { + parts.push(color(git.dirtyFiles > 0 ? "warning" : "muted", `${git.dirtyFiles} dirty`, mode)) + } + if (git.uncommittedLines) parts.push(color("muted", `±${git.uncommittedLines}`, mode)) + if (git.lastCommitAgeSec !== undefined) { + parts.push(color("muted", `${age(git.lastCommitAgeSec)} ago`, mode)) + } + if (parts.length > 0) lines.push(`${LEFT_PAD}${parts.join(` ${dot} `)}`) + } + + const tail: string[] = [] + if (machine) { + const m: string[] = [] + if (machine.cpuPct !== undefined) m.push(`cpu ${machine.cpuPct}%`) + if (machine.memPct !== undefined) m.push(`mem ${machine.memPct}%`) + if (machine.battPct !== undefined) m.push(`batt ${machine.battPct}%`) + if (machine.diskFreePct !== undefined) m.push(`disk ${machine.diskFreePct}%`) + if (m.length > 0) tail.push(color("muted", m.join(" · "), mode)) + } + if (health) { + const h: string[] = [] + if (health.anthropic) { + const tok = health.anthropic === "ok" ? "positive" : "warning" + h.push(`${color("muted", "api", mode)} ${color(tok, health.anthropic, mode)}`) + } else if (health.online === false) { + h.push(color("negative", "offline", mode)) + } + if (health.apiLatencyMs !== undefined) h.push(color("muted", `${health.apiLatencyMs}ms`, mode)) + if (h.length > 0) tail.push(h.join(" ")) + } + if (tail.length > 0) lines.push(`${LEFT_PAD}${tail.join(sep)}`) + + lines.push(`${LEFT_PAD}${color("muted", "distro tv · utils", mode)}`) + return lines.join("\n") +} + +// "90m" → "1h 30m", "45m" → "45m" +function fmtMins(min: number): string { + if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m` + return `${min}m` +} diff --git a/packages/cli/src/lib/slot-cache.ts b/packages/cli/src/lib/slot-cache.ts index 66e7cd66..e1893a30 100644 --- a/packages/cli/src/lib/slot-cache.ts +++ b/packages/cli/src/lib/slot-cache.ts @@ -40,7 +40,9 @@ const BATCH_SIZE = 20 // Old "ad-cache.json" (version 2) will fail the check and be silently dropped. const CACHE_FILE_VERSION = 3 -export type CachedSlot = SlotPayload & { cacheSource: "api" | "demo" } +// "local" tags slots the daemon builds itself (CH 03 utility panel) — never +// fetched from /me/content/next and never written to the cache file. +export type CachedSlot = SlotPayload & { cacheSource: "api" | "demo" | "local" } interface CacheFile { version: 3 diff --git a/packages/cli/src/lib/utility-slot.ts b/packages/cli/src/lib/utility-slot.ts new file mode 100644 index 00000000..d11f5fe6 --- /dev/null +++ b/packages/cli/src/lib/utility-slot.ts @@ -0,0 +1,250 @@ +import { execFile } from "node:child_process" +import { statfs } from "node:fs/promises" +import { cpus, freemem, loadavg, platform, totalmem } from "node:os" +import { promisify } from "node:util" +import { + UTILITY_GIT_CACHE_MS, + UTILITY_HEALTH_CACHE_MS, + UTILITY_MACHINE_CACHE_MS, + UTILITY_SNAPSHOT_STALE_MS, + type UtilityGit, + type UtilityHealth, + type UtilityLayout, + type UtilityMachine, + type UtilityPayload, +} from "@distrotv/shared" +import type { CachedSlot } from "./slot-cache.js" +import { deriveUsage, readUsageSnapshot, type UsageLatest } from "./claude-usage.js" +import { hasWrappedStatusLine } from "./wrapped-statusline.js" + +const execFileAsync = promisify(execFile) + +export type LayoutPref = "auto" | "full" | "complement" + +export interface UtilityProviderDeps { + now?: () => number + // CLI-local pref: "auto" picks complement when a custom status line was + // wrapped at install, else full. + getLayoutPref?: () => LayoutPref +} + +export interface UtilityProvider { + // Synchronous: assembles the panel from the latest cached probe values + the + // usage snapshot. Kicks stale probes off in the background (never awaits), so + // the render path stays fast. Returns null when there's nothing to show. + build(): CachedSlot | null +} + +// ── git probe ─────────────────────────────────────────────────────────────── + +async function git(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd, timeout: 1500 }) + return stdout.trim() + } catch { + return null + } +} + +async function probeGit(cwd: string, now: number): Promise { + const branch = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + if (branch === null) return null // not a repo / git unavailable + + const [counts, porcelain, numstat, lastCommit] = await Promise.all([ + git(cwd, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]), + git(cwd, ["status", "--porcelain"]), + git(cwd, ["diff", "--numstat", "HEAD"]), + git(cwd, ["log", "-1", "--format=%ct"]), + ]) + + const out: UtilityGit = { branch } + + if (counts) { + const [behind, ahead] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10)) + if (Number.isFinite(behind)) out.behind = behind + if (Number.isFinite(ahead)) out.ahead = ahead + } + if (porcelain !== null) { + out.dirtyFiles = porcelain.length === 0 ? 0 : porcelain.split("\n").filter(Boolean).length + } + if (numstat) { + let lines = 0 + for (const row of numstat.split("\n")) { + const [add, del] = row.split(/\s+/) + const a = Number.parseInt(add ?? "", 10) + const d = Number.parseInt(del ?? "", 10) + if (Number.isFinite(a)) lines += a + if (Number.isFinite(d)) lines += d + } + out.uncommittedLines = lines + } + if (lastCommit) { + const ct = Number.parseInt(lastCommit, 10) + if (Number.isFinite(ct)) out.lastCommitAgeSec = Math.max(0, Math.round(now / 1000 - ct)) + } + return out +} + +// ── machine probe ───────────────────────────────────────────────────────── + +async function probeBattery(): Promise { + if (platform() !== "darwin") return undefined + try { + const { stdout } = await execFileAsync("pmset", ["-g", "batt"], { timeout: 1500 }) + const m = /(\d+)%/.exec(stdout) + return m ? Number.parseInt(m[1] as string, 10) : undefined + } catch { + return undefined + } +} + +async function probeMachine(cwd: string | undefined): Promise { + const out: UtilityMachine = {} + + // 1-min load average normalized by core count — a cheap CPU-pressure proxy. + const cores = cpus().length || 1 + const load1 = loadavg()[0] ?? 0 + if (load1 > 0) out.cpuPct = Math.min(100, Math.round((load1 / cores) * 100)) + + const total = totalmem() + if (total > 0) out.memPct = Math.round(((total - freemem()) / total) * 100) + + out.battPct = await probeBattery() + + try { + const stats = await statfs(cwd || process.env["HOME"] || "/") + const blocks = Number(stats.blocks) + if (blocks > 0) out.diskFreePct = Math.round((Number(stats.bavail) / blocks) * 100) + } catch { + /* omit */ + } + return out +} + +// ── service-health probe ──────────────────────────────────────────────────── + +async function fetchWithTimeout(url: string, ms: number): Promise { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), ms) + try { + return await fetch(url, { signal: ctrl.signal, method: "GET" }) + } catch { + return null + } finally { + clearTimeout(t) + } +} + +async function probeHealth(now: () => number): Promise { + const out: UtilityHealth = {} + const start = now() + // Atlassian Statuspage summary for status.anthropic.com. + const res = await fetchWithTimeout("https://status.anthropic.com/api/v2/status.json", 2500) + out.apiLatencyMs = now() - start + out.online = res !== null + if (res && res.ok) { + try { + const body = (await res.json()) as { status?: { indicator?: string } } + const ind = body.status?.indicator ?? "none" + out.anthropic = + ind === "none" ? "ok" : ind === "critical" || ind === "major" ? "down" : "degraded" + } catch { + /* leave anthropic unset */ + } + } + return out +} + +// ── provider: caches probe results, refreshes stale ones in the background ── + +interface Cell { + value: T | null + at: number + inFlight: boolean +} + +export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityProvider { + const now = deps.now ?? (() => Date.now()) + const getLayoutPref = deps.getLayoutPref ?? (() => "auto" as LayoutPref) + + const gitCell: Cell = { value: null, at: 0, inFlight: false } + const machineCell: Cell = { value: null, at: 0, inFlight: false } + const healthCell: Cell = { value: null, at: 0, inFlight: false } + + function refresh(cell: Cell, ttl: number, run: () => Promise): void { + if (cell.inFlight) return + if (cell.value !== null && now() - cell.at < ttl) return + cell.inFlight = true + run() + .then((v) => { + cell.value = v + cell.at = now() + }) + .catch(() => {}) + .finally(() => { + cell.inFlight = false + }) + } + + function resolveLayout(): UtilityLayout { + const pref = getLayoutPref() + if (pref === "full" || pref === "complement") return pref + return hasWrappedStatusLine() ? "complement" : "full" + } + + function aiFrom(usage: UsageLatest | null): UtilityPayload["ai"] | undefined { + if (!usage) return undefined + const derived = deriveUsage(now(), UTILITY_SNAPSHOT_STALE_MS) + const ai: NonNullable = { + fiveHourPct: usage.fiveHourPct, + fiveHourResetAt: usage.fiveHourResetAt, + sevenDayPct: usage.sevenDayPct, + sevenDayResetAt: usage.sevenDayResetAt, + ctxPct: usage.ctxPct, + ctxSize: usage.ctxSize, + costUsd: usage.costUsd, + cachePct: usage.cachePct, + model: usage.model, + effort: usage.effort, + linesAdded: usage.linesAdded, + linesRemoved: usage.linesRemoved, + burnUsdPerMin: derived.burnUsdPerMin, + timeToLimitMin: derived.timeToLimitMin, + } + return ai + } + + return { + build() { + const t = now() + const usage = readUsageSnapshot(t, UTILITY_SNAPSHOT_STALE_MS) + + // kick stale probes off in the background; use whatever's cached now. + refresh(machineCell, UTILITY_MACHINE_CACHE_MS, () => probeMachine(usage?.cwd)) + refresh(healthCell, UTILITY_HEALTH_CACHE_MS, () => probeHealth(now)) + if (usage?.cwd) { + const cwd = usage.cwd + refresh(gitCell, UTILITY_GIT_CACHE_MS, () => probeGit(cwd, t)) + } + + const ai = aiFrom(usage) + const git = gitCell.value ?? undefined + const machine = machineCell.value ?? undefined + const health = healthCell.value ?? undefined + + // nothing useful yet (cold start, no Claude session) — skip this rotation. + if (!ai && !git && !machine && !health) return null + + const payload: UtilityPayload = { + kind: "utility", + generatedAt: t, + layout: resolveLayout(), + ...(ai ? { ai } : {}), + ...(git ? { git } : {}), + ...(machine ? { machine } : {}), + ...(health ? { health } : {}), + } + return { ...payload, cacheSource: "local" } + }, + } +} diff --git a/packages/cli/src/lib/wrapped-statusline.ts b/packages/cli/src/lib/wrapped-statusline.ts new file mode 100644 index 00000000..544266e8 --- /dev/null +++ b/packages/cli/src/lib/wrapped-statusline.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { join } from "node:path" +import { configDir } from "./config.js" + +// When Distro takes over Claude Code's (single-slot) statusLine, we stash the +// user's pre-existing command here so `distro statusline` can run it and append +// our line BELOW its output — and so uninstall can restore it. Lives in our own +// config dir, never inside Claude's settings.json. + +export interface WrappedStatusLine { + type?: string + command: string + padding?: number +} + +function wrappedPath(): string { + return join(configDir(), "wrapped-statusline.json") +} + +export function readWrappedStatusLine(): WrappedStatusLine | null { + try { + const parsed = JSON.parse(readFileSync(wrappedPath(), "utf8")) as WrappedStatusLine + if (!parsed || typeof parsed.command !== "string" || parsed.command.length === 0) return null + return parsed + } catch { + return null + } +} + +export function writeWrappedStatusLine(entry: WrappedStatusLine): void { + try { + const dir = configDir() + mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = join(dir, `.wrapped-statusline.${randomBytes(6).toString("hex")}.tmp`) + writeFileSync(tmp, JSON.stringify(entry, null, 2), { mode: 0o600 }) + renameSync(tmp, wrappedPath()) + } catch { + /* non-fatal */ + } +} + +export function clearWrappedStatusLine(): void { + try { + unlinkSync(wrappedPath()) + } catch { + /* already gone */ + } +} + +export function hasWrappedStatusLine(): boolean { + return readWrappedStatusLine() !== null +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 09a40cec..50bd2ae2 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -7,6 +7,21 @@ export const GRACE_PERIOD_MS = 0 // every slot (news + ticker) shows for this long before rotating. export const MAX_AD_DURATION_MS = 12_000 +// ── CH 03 Utilities ──────────────────────────────────────────────────────── +// Inject a locally-built utility panel every Nth slot pick (news → ticker → +// utils → …). 3 keeps it present without crowding out news/markets. +export const UTILITY_SLOT_EVERY_N = 3 +// AI telemetry snapshot is considered stale past this — Claude only writes it +// while it's actively rendering the status line, so an idle gap goes stale. +export const UTILITY_SNAPSHOT_STALE_MS = 5 * 60_000 +// probe caches — keep the render path cheap, refresh in the background. +export const UTILITY_GIT_CACHE_MS = 10_000 +export const UTILITY_MACHINE_CACHE_MS = 15_000 +export const UTILITY_HEALTH_CACHE_MS = 60_000 +// gauge turns red + ⚠ at/above these thresholds. +export const UTILITY_LIMIT_WARN_PCT = 90 +export const UTILITY_CTX_WARN_PCT = 90 + // A tty session only renders slots if it saw a real hook/key event within this // window. Stops idle/stalled/background terminals from rotating slots (and the // now-playing + content API traffic that drives) when the user isn't on them. @@ -97,6 +112,8 @@ export function defaultPreferences(): DevdripPreferences { // Sentinel "never synced" — first GET /me/preferences will replace it. updatedAt: new Date(0).toISOString(), muteUntil: null, + utilitiesEnabled: true, + utilitiesLayout: "auto", } } diff --git a/packages/shared/src/types/SlotPayload.ts b/packages/shared/src/types/SlotPayload.ts index 9e54beeb..c1cc9cb7 100644 --- a/packages/shared/src/types/SlotPayload.ts +++ b/packages/shared/src/types/SlotPayload.ts @@ -1,11 +1,13 @@ import type { NewsPayload } from "./NewsPayload.js" import type { TickerPayload } from "./TickerPayload.js" +import type { UtilityPayload } from "./UtilityPayload.js" -export type SlotKind = "news" | "ticker" | "sponsored" | "portfolio" +export type SlotKind = "news" | "ticker" | "utility" | "sponsored" | "portfolio" export type SlotLayout = "single" | "grid" // SlotKind includes "sponsored" and "portfolio" as reserved future kinds. // Their payload types do not exist yet, so a discriminant check on those // values will narrow SlotPayload to `never`. Add the payload types when -// these kinds become live. -export type SlotPayload = NewsPayload | TickerPayload +// these kinds become live. "utility" (CH 03) is locally generated by the +// daemon — never returned by /me/content/next. +export type SlotPayload = NewsPayload | TickerPayload | UtilityPayload diff --git a/packages/shared/src/types/UtilityPayload.ts b/packages/shared/src/types/UtilityPayload.ts new file mode 100644 index 00000000..47db017b --- /dev/null +++ b/packages/shared/src/types/UtilityPayload.ts @@ -0,0 +1,72 @@ +// CH 03 "Utilities" — an ambient dev instrument panel rendered as one rotating +// slot alongside news + markets. Unlike news/ticker, this payload is built +// LOCALLY by the daemon (never fetched from the API) from the telemetry Claude +// Code pipes to `distro statusline` on stdin plus cheap local probes (git, +// machine, service health). Every bucket is optional so an unavailable source +// is simply omitted rather than rendered as a fake zeroed gauge. + +// Claude session telemetry, sourced from the status-line stdin snapshot. +export interface UtilityAi { + // rolling rate-limit windows (0-100), with epoch-ms reset times when known + fiveHourPct?: number + fiveHourResetAt?: number + sevenDayPct?: number + sevenDayResetAt?: number + // context window + ctxPct?: number + ctxSize?: number + // % at which auto-compact kicks in (for the "compaction imminent" cue) + compactAtPct?: number + // session economics + costUsd?: number + cachePct?: number + // derived from the rolling snapshot history + burnUsdPerMin?: number + burnTokPerMin?: number + // projected minutes until the nearest limit (5h or 7d) is hit at current burn + timeToLimitMin?: number + // identity / session deltas + model?: string + effort?: string + linesAdded?: number + linesRemoved?: number +} + +export interface UtilityGit { + branch?: string + ahead?: number + behind?: number + dirtyFiles?: number + uncommittedLines?: number + lastCommitAgeSec?: number +} + +export interface UtilityMachine { + cpuPct?: number + memPct?: number + battPct?: number + diskFreePct?: number +} + +export type AnthropicHealth = "ok" | "degraded" | "down" + +export interface UtilityHealth { + anthropic?: AnthropicHealth + apiLatencyMs?: number + online?: boolean +} + +// "full" shows the complete gauge grid; "complement" drops the gauges a user's +// own custom status line likely already shows (ctx/5h/7d/cost) and leads with +// the derived/extra stats instead. +export type UtilityLayout = "full" | "complement" + +export interface UtilityPayload { + kind: "utility" + generatedAt: number + layout: UtilityLayout + ai?: UtilityAi + git?: UtilityGit + machine?: UtilityMachine + health?: UtilityHealth +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8e687adf..5a930665 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -63,6 +63,12 @@ export interface SyncedPreferences { export interface LocalPreferences { // epoch ms; null = not muted. cleared when now >= muteUntil. muteUntil: number | null + // CH 03 Utilities: gate the locally-injected utility slot. Local-first + + // session-independent — not derived from a server subscription in v1. + utilitiesEnabled: boolean + // "auto" → "complement" when a custom status line was detected at install, + // else "full". Explicit values override the auto detection. + utilitiesLayout: "auto" | "full" | "complement" } export type DevdripPreferences = SyncedPreferences & LocalPreferences @@ -82,6 +88,15 @@ export interface Device { export type { NewsPayload } from "./NewsPayload.js" export type { TickerPayload, TickerStats } from "./TickerPayload.js" export type { SlotPayload, SlotKind, SlotLayout } from "./SlotPayload.js" +export type { + UtilityPayload, + UtilityLayout, + UtilityAi, + UtilityGit, + UtilityMachine, + UtilityHealth, + AnthropicHealth, +} from "./UtilityPayload.js" export type { WatchlistDto, WatchlistTickerDto, AssetClass } from "./WatchlistDto.js" export type { AlertDto, AlertScope, PendingAlert, AlertReplacement } from "./AlertDto.js" export type { ChannelDto } from "./ChannelDto.js" diff --git a/packages/shared/src/types/news.ts b/packages/shared/src/types/news.ts index a06ea4fa..58bdf0f2 100644 --- a/packages/shared/src/types/news.ts +++ b/packages/shared/src/types/news.ts @@ -18,4 +18,11 @@ export enum NewsTopic { } // Stable user-facing channel identifier. -export type ChannelKey = "tech" | "finance" | "crypto" | "ai-papers" | "design" | "gaming" +export type ChannelKey = + | "tech" + | "finance" + | "crypto" + | "ai-papers" + | "design" + | "gaming" + | "utilities" From a30f1dc7d1c7fd6a109ae9456e7881d57d5aafea Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 16:33:02 +0530 Subject: [PATCH 2/7] feat(cli): redesign utils panel as an aligned instrument-panel grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flat dot-separated text → reference-style gauges. context/gauge/machine rows share one 4-column grid (teal·amber·violet·periwinkle) with fill+track bars that line up across rows and │ separators that run straight down. - bars always shown (no more text-only complement); branch keeps name + ↑ahead ↓behind vs remote; api line hidden unless degraded/down - mem bar reddens ≥90%; 5h/7d/ctx redden + ⚠ at warn threshold - bar width scales with tty width; narrow terminals degrade gracefully (full → drop machine → slim context → gauges-only) so core gauges fit --- gitbook-docs/cli/utilities-channel.md | 47 +++- packages/cli/src/lib/render-line.ts | 306 +++++++++++++++++--------- 2 files changed, 239 insertions(+), 114 deletions(-) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md index 47d05adb..c79f4998 100644 --- a/gitbook-docs/cli/utilities-channel.md +++ b/gitbook-docs/cli/utilities-channel.md @@ -66,18 +66,41 @@ slot rather than skipping a beat. The slot is tagged `cacheSource: "local"`; the orchestrator skips the `writeNowPlaying` API mirror for it, so usage data stays on-device. -## Layouts - -- **full** — the complete gauge grid (ctx / 5h / 7d) plus econ, git, machine, - health lines. -- **complement** — drops the raw ctx/5h/7d/cost gauges (a user's own status line - likely already shows them) and leads with the derived stats (burn, time-to- - limit) instead. - -Layout is the `utilitiesLayout` pref: `auto` (default) → `complement` when a -custom status line was wrapped at install, else `full`; `full`/`complement` -force it. Gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%) for -limits / `UTILITY_CTX_WARN_PCT` (90%) for context. +## Layout — the instrument-panel grid + +`renderUtilityPanel` (`render-line.ts`) draws three rows on **one shared +4-column grid**, so bars line up vertically and the `│` separators run straight +down. Each column owns a hue (fill + a dark "unfilled" track tint): + +``` +▍ utils · live +⎇ organic-bread ↑3 ↓1 · 5 dirty │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded +7d ███░░░░░ 22% · 6d │ ctx ███░░░░░ 41% │ cache ██████░ 79% │ 5h ███░░ 36% · 1h 9m +cpu ██░░░░░░ 30 │ mem ████████ 99 │ disk █████░░ 57 │ batt ████████ 100 +distro tv · utils +``` + +- **col 1 · teal** — 7d limit (gauge) / git: `⎇ branch ↑ahead ↓behind · N dirty` +- **col 2 · amber** — ctx (gauge) / cost · burn · `~time-to-limit` +- **col 3 · violet** — cache (gauge) / model · effort +- **col 4 · periwinkle** — 5h limit (gauge) / **api line only on an incident** +- machine row (cpu/mem/disk/batt) uses gray bars; **mem turns red ≥90%** + +Per-column label fields (`COL_FIELD`) are uniform so every bar starts at the same +x in its column. Bars turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for +5h/7d) and `UTILITY_CTX_WARN_PCT` (90%, ctx). The **API health line is hidden +when Anthropic is healthy** and only appears (red) during a `degraded`/`down` +incident. + +Bar width scales with the terminal width the daemon reads off the tty, and the +panel degrades progressively on narrow terminals: full → drop machine row → drop +context detail → **gauges-only** (the four core gauges always fit). Absent +buckets (no git repo, stale snapshot) are omitted, never shown as zeroed bars. + +The `utilitiesLayout` pref (`auto`/`full`/`complement`) and the `layout` field on +`UtilityPayload` are retained but the renderer currently draws the same grid +regardless — bars are always shown, including when appended below a user's own +status line. ## Preferences diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index d2c73e78..f0220017 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -97,7 +97,7 @@ export function renderSlotLine( const nudge = updateLatest ? [updateNudge(updateLatest, mode)] : [] if (slot.kind === "utility") { - return renderUtilityPanel(slot, mode, nudge, dot) + return renderUtilityPanel(slot, mode, W, nudge) } if (slot.kind === "ticker") { @@ -151,32 +151,43 @@ export function renderSlotLine( } // ── CH 03 utility panel ───────────────────────────────────────────────────── +// "instrument panel": context / gauge / machine rows share one 4-column grid +// (col hues teal · amber · violet · periwinkle), fill+track bars that line up +// across rows, branch + ahead/behind, and an api line only on an incident. type Rgb = readonly [number, number, number] +interface Hue { + fill: Rgb + track: Rgb +} -// per-gauge accent hues (echoing an instrument-panel feel, in our palette) -const GAUGE_CTX: Rgb = [255, 150, 40] // amber — context (focal) -const GAUGE_5H: Rgb = [132, 165, 240] // periwinkle — 5h limit -const GAUGE_7D: Rgb = [94, 200, 188] // teal — weekly limit -const TRACK: Rgb = [60, 64, 74] // shared neutral track -const WARN: Rgb = [240, 97, 109] // red — at/over threshold - -const BAR_W = 8 - -// Colored progress bar: bg blocks in truecolor, fg block chars otherwise. -function renderBar(pct: number, width: number, fill: Rgb, mode: ColorMode): string { +// per-column hue + its dark "unfilled" track tint (tuned for a dark terminal). +const H_TEAL: Hue = { fill: [94, 200, 188], track: [34, 65, 62] } +const H_AMBER: Hue = { fill: [255, 150, 40], track: [67, 51, 30] } +const H_VIOLET: Hue = { fill: [170, 132, 235], track: [51, 42, 71] } +const H_PERI: Hue = { fill: [132, 165, 240], track: [38, 52, 86] } +const H_GRAY: Hue = { fill: [107, 114, 128], track: [35, 39, 47] } +const H_RED: Hue = { fill: [240, 97, 109], track: [65, 34, 42] } +const FG_RGB: Rgb = [233, 235, 242] + +// per-column label field (max label + a space) so bars start at the same x in +// their column on every row. +const COL_FIELD = [4, 4, 6, 5] as const + +// fill + track bar. truecolor → bg blocks; else █/░ fallback. +function renderBar(pct: number, width: number, h: Hue, mode: ColorMode): string { const p = Math.max(0, Math.min(100, pct)) const filled = Math.min(width, Math.round((p * width) / 100)) const empty = width - filled if (mode === "truecolor") { return ( - bgRgb(" ".repeat(filled), fill[0], fill[1], fill[2], mode) + - bgRgb(" ".repeat(empty), TRACK[0], TRACK[1], TRACK[2], mode) + bgRgb(" ".repeat(filled), h.fill[0], h.fill[1], h.fill[2], mode) + + bgRgb(" ".repeat(empty), h.track[0], h.track[1], h.track[2], mode) ) } return ( - rgb("█".repeat(filled), fill[0], fill[1], fill[2], mode) + - color("muted", "░".repeat(empty), mode) + rgb("█".repeat(filled), h.fill[0], h.fill[1], h.fill[2], mode) + + rgb("░".repeat(empty), h.track[0], h.track[1], h.track[2], mode) ) } @@ -192,117 +203,208 @@ function fmtReset(at: number | undefined, now: number): string { return `${m}m` } -// label + bar + "NN%[ · suffix]"; turns red + ⚠ at/over the warn threshold. -function gauge( +// right-pad a (possibly colored) string to `n` visible columns. +function padEndVis(s: string, n: number): string { + return s + " ".repeat(Math.max(0, n - visLen(s))) +} + +// one grid cell: label(padded to its column field) + bar + value(+suffix). +function gcell( label: string, - pct: number | undefined, - warnAt: number, - accent: Rgb, - suffix: string, - mode: ColorMode -): string | null { - if (pct === undefined) return null - const warned = pct >= warnAt - const fill = warned ? WARN : accent - const bar = renderBar(pct, BAR_W, fill, mode) - const pctTok = warned ? "negative" : "muted" - const pre = warned ? color("warning", "⚠ ", mode) : "" - const tail = suffix ? ` ${color("muted", `· ${suffix}`, mode)}` : "" - return `${pre}${color("muted", label, mode)} ${bar} ${color(pctTok, `${pct}%`, mode)}${tail}` + col: number, + pct: number, + barW: number, + h: Hue, + value: string, + valueRgb: Rgb, + mode: ColorMode, + suffix?: string +): string { + const lab = color("muted", label.padEnd(COL_FIELD[col] ?? 4), mode) + const val = rgb(value, valueRgb[0], valueRgb[1], valueRgb[2], mode) + const suf = suffix ? color("muted", ` · ${suffix}`, mode) : "" + return `${lab}${renderBar(pct, barW, h, mode)} ${val}${suf}` } function renderUtilityPanel( slot: UtilityPayload, mode: ColorMode, - nudge: string[], - dot: string + W: number, + nudge: string[] ): string { const now = Date.now() - const sep = ` ${dot} ` - const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)} ${dot} ${color("muted", "live", mode)}` - const lines: string[] = [...nudge, header] - const complement = slot.layout === "complement" - + const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)} ${color("muted", "· live", mode)}` + const footer = `${LEFT_PAD}${color("muted", "distro tv · utils", mode)}` + const sep = color("muted", " │ ", mode) const { ai, git, machine, health } = slot - if (ai) { - if (!complement) { - // full: raw gauges the user doesn't already have on screen - const gauges = [ - gauge("ctx", ai.ctxPct, UTILITY_CTX_WARN_PCT, GAUGE_CTX, "", mode), - gauge( - "5h", - ai.fiveHourPct, - UTILITY_LIMIT_WARN_PCT, - GAUGE_5H, - fmtReset(ai.fiveHourResetAt, now), - mode - ), - gauge( - "7d", - ai.sevenDayPct, - UTILITY_LIMIT_WARN_PCT, - GAUGE_7D, - fmtReset(ai.sevenDayResetAt, now), - mode - ), - ].filter((g): g is string => g !== null) - if (gauges.length > 0) lines.push(`${LEFT_PAD}${gauges.join(sep)}`) - } + // bar width scales with the terminal width the daemon read off the tty. + const barW = W >= 110 ? 8 : W >= 90 ? 7 : W >= 72 ? 6 : 5 - // derived/econ line — useful in both layouts (basic status lines rarely show these) + // ── context row ── git | cost/burn/proj | model | api(incident only) + const ctx: string[] = ["", "", "", ""] + if (git?.branch) { + const ab: string[] = [] + if (git.ahead) ab.push(`↑${git.ahead}`) + if (git.behind) ab.push(`↓${git.behind}`) + const metaParts: string[] = [] + if (ab.length) metaParts.push(ab.join(" ")) + if (git.dirtyFiles !== undefined) metaParts.push(`${git.dirtyFiles} dirty`) + const meta = metaParts.length ? color("muted", ` ${metaParts.join(" · ")}`, mode) : "" + ctx[0] = rgb(`⎇ ${git.branch}`, H_TEAL.fill[0], H_TEAL.fill[1], H_TEAL.fill[2], mode) + meta + } + if (ai) { const econ: string[] = [] if (ai.costUsd !== undefined) econ.push(`$${ai.costUsd.toFixed(2)}`) - if (ai.cachePct !== undefined) econ.push(`cache ${ai.cachePct}%`) - if (ai.burnUsdPerMin !== undefined) econ.push(`burn $${ai.burnUsdPerMin.toFixed(2)}/m`) - if (ai.timeToLimitMin !== undefined) econ.push(`~${fmtMins(ai.timeToLimitMin)} to limit`) - if (ai.model) econ.push(ai.effort ? `${ai.model}/${ai.effort}` : ai.model) - if (econ.length > 0) { - lines.push(`${LEFT_PAD}${color("muted", econ.join(" · "), mode)}`) + if (ai.burnUsdPerMin !== undefined) econ.push(`$${ai.burnUsdPerMin.toFixed(2)}/m`) + let e = econ.length ? color("muted", econ.join(" · "), mode) : "" + if (ai.timeToLimitMin !== undefined) { + const proj = rgb( + `~${fmtMins(ai.timeToLimitMin)}`, + H_AMBER.fill[0], + H_AMBER.fill[1], + H_AMBER.fill[2], + mode + ) + e += (e ? color("muted", " · ", mode) : "") + proj + } + ctx[1] = e + if (ai.model) { + ctx[2] = rgb( + ai.effort ? `${ai.model} / ${ai.effort}` : ai.model, + H_VIOLET.fill[0], + H_VIOLET.fill[1], + H_VIOLET.fill[2], + mode + ) } } + if (health?.anthropic && health.anthropic !== "ok") { + ctx[3] = color("negative", `⚠ api ${health.anthropic}`, mode) + } else if (health && health.online === false) { + ctx[3] = color("negative", "⚠ offline", mode) + } - if (git) { - const parts: string[] = [] - if (git.branch) parts.push(color("fg", git.branch, mode)) - const ab: string[] = [] - if (git.ahead) ab.push(`↑${git.ahead}`) - if (git.behind) ab.push(`↓${git.behind}`) - if (ab.length > 0) parts.push(color("muted", ab.join(" "), mode)) - if (git.dirtyFiles !== undefined) { - parts.push(color(git.dirtyFiles > 0 ? "warning" : "muted", `${git.dirtyFiles} dirty`, mode)) + // ── gauge row ── 7d | ctx | cache | 5h + const gau: string[] = ["", "", "", ""] + if (ai) { + if (ai.sevenDayPct !== undefined) { + const w = ai.sevenDayPct >= UTILITY_LIMIT_WARN_PCT + gau[0] = gcell( + "7d", + 0, + ai.sevenDayPct, + barW, + w ? H_RED : H_TEAL, + `${ai.sevenDayPct}%`, + w ? H_RED.fill : H_TEAL.fill, + mode, + ai.sevenDayResetAt ? fmtReset(ai.sevenDayResetAt, now) : undefined + ) + } + if (ai.ctxPct !== undefined) { + const w = ai.ctxPct >= UTILITY_CTX_WARN_PCT + gau[1] = gcell( + "ctx", + 1, + ai.ctxPct, + barW, + w ? H_RED : H_AMBER, + `${ai.ctxPct}%`, + w ? H_RED.fill : H_AMBER.fill, + mode + ) } - if (git.uncommittedLines) parts.push(color("muted", `±${git.uncommittedLines}`, mode)) - if (git.lastCommitAgeSec !== undefined) { - parts.push(color("muted", `${age(git.lastCommitAgeSec)} ago`, mode)) + if (ai.cachePct !== undefined) { + gau[2] = gcell( + "cache", + 2, + ai.cachePct, + barW, + H_VIOLET, + `${ai.cachePct}%`, + H_VIOLET.fill, + mode + ) + } + if (ai.fiveHourPct !== undefined) { + const w = ai.fiveHourPct >= UTILITY_LIMIT_WARN_PCT + const pre = w ? color("negative", "⚠ ", mode) : "" + gau[3] = + pre + + gcell( + "5h", + 3, + ai.fiveHourPct, + barW, + w ? H_RED : H_PERI, + `${ai.fiveHourPct}%`, + w ? H_RED.fill : H_PERI.fill, + mode, + ai.fiveHourResetAt ? fmtReset(ai.fiveHourResetAt, now) : undefined + ) } - if (parts.length > 0) lines.push(`${LEFT_PAD}${parts.join(` ${dot} `)}`) } - const tail: string[] = [] + // ── machine row ── cpu | mem | disk | batt (gray; turns red ≥90%) + const mac: string[] = ["", "", "", ""] if (machine) { - const m: string[] = [] - if (machine.cpuPct !== undefined) m.push(`cpu ${machine.cpuPct}%`) - if (machine.memPct !== undefined) m.push(`mem ${machine.memPct}%`) - if (machine.battPct !== undefined) m.push(`batt ${machine.battPct}%`) - if (machine.diskFreePct !== undefined) m.push(`disk ${machine.diskFreePct}%`) - if (m.length > 0) tail.push(color("muted", m.join(" · "), mode)) + const mc = (label: string, col: number, pct: number | undefined): string => { + if (pct === undefined) return "" + const hot = pct >= 90 + return gcell( + label, + col, + pct, + barW, + hot ? H_RED : H_GRAY, + String(pct), + hot ? H_RED.fill : FG_RGB, + mode + ) + } + mac[0] = mc("cpu", 0, machine.cpuPct) + mac[1] = mc("mem", 1, machine.memPct) + mac[2] = mc("disk", 2, machine.diskFreePct) + mac[3] = mc("batt", 3, machine.battPct) } - if (health) { - const h: string[] = [] - if (health.anthropic) { - const tok = health.anthropic === "ok" ? "positive" : "warning" - h.push(`${color("muted", "api", mode)} ${color(tok, health.anthropic, mode)}`) - } else if (health.online === false) { - h.push(color("negative", "offline", mode)) + + // pad cols 0-2 to their widest cell across the rendered rows (col 3 is the + // tail — its cells already start at the same x, so they align unpadded). + const build = (rows: string[][]): { lines: string[]; max: number } => { + const present = rows.filter((r) => r.some((c) => c.length > 0)) + const w = [0, 0, 0] + for (const r of present) + for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r[i] ?? "")) + const lines = present.map((r) => { + let s = + padEndVis(r[0] ?? "", w[0] ?? 0) + + sep + + padEndVis(r[1] ?? "", w[1] ?? 0) + + sep + + padEndVis(r[2] ?? "", w[2] ?? 0) + if ((r[3] ?? "").length > 0) s += sep + r[3] + return `${LEFT_PAD}${s}` + }) + const max = lines.reduce((mx, l) => Math.max(mx, visLen(l)), 0) + return { lines, max } + } + + // progressive degradation so the core gauges always fit: full → drop machine + // → drop context detail (keep git + api) → gauges only. + const slimCtx = [ctx[0] ?? "", "", "", ctx[3] ?? ""] + const attempts = [[ctx, gau, mac], [ctx, gau], [slimCtx, gau], [gau]] + let chosen = build(attempts[attempts.length - 1] as string[][]) + for (const rows of attempts) { + const r = build(rows) + if (r.max <= W) { + chosen = r + break } - if (health.apiLatencyMs !== undefined) h.push(color("muted", `${health.apiLatencyMs}ms`, mode)) - if (h.length > 0) tail.push(h.join(" ")) + chosen = r } - if (tail.length > 0) lines.push(`${LEFT_PAD}${tail.join(sep)}`) - lines.push(`${LEFT_PAD}${color("muted", "distro tv · utils", mode)}`) - return lines.join("\n") + return [...nudge, header, ...chosen.lines, footer].join("\n") } // "90m" → "1h 30m", "45m" → "45m" From 63a52e1c869b8dc487379f4e309e90a1ca4b87c2 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 16:48:57 +0530 Subject: [PATCH 3/7] fix(cli): accurate machine stats + utils panel polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit machine stats were wrong on macOS — os.freemem reported ~97% used (it's meaningless when RAM is used for cache) and load-avg overstated cpu. now: - cpu: real 250ms sample of cpu times (instantaneous % busy) - mem: real pressure via memory_pressure (macOS) / MemAvailable (linux); omitted, never faked, when unavailable - disk: % used (100 - free) so a fuller disk reads as a fuller bar - battery reddens only when LOW (≤20%), not at full charge panel polish: drop the "distro tv · utils" footer + "· live" suffix, relabel the weekly gauge "7d" → "week" (it's the limit users hit first), and add a blank line between the gauge and machine rows for readability. rotation verified working (news → markets → utils, 1-in-3); the freeze users saw is the intended idle-pin when no prompt fires for ACTIVE_WINDOW_MS. --- gitbook-docs/cli/utilities-channel.md | 39 ++++++------ packages/cli/src/lib/render-line.ts | 36 ++++++----- packages/cli/src/lib/utility-slot.ts | 70 ++++++++++++++++----- packages/shared/src/types/UtilityPayload.ts | 8 +-- 4 files changed, 101 insertions(+), 52 deletions(-) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md index c79f4998..79354cbf 100644 --- a/gitbook-docs/cli/utilities-channel.md +++ b/gitbook-docs/cli/utilities-channel.md @@ -43,12 +43,12 @@ the Distro line added beneath it. All buckets are optional — an unavailable source is omitted, never shown as a fake zeroed gauge (`UtilityPayload` in `@distrotv/shared`). -| Bucket | Source | Fields | -| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| **ai** | statusline snapshot + derived history | 5h/7d limit % + reset, ctx %, cost, cache %, burn $/min, time-to-limit, model/effort, lines ± | -| **git** | `git` in the snapshot's `cwd` (cached 10 s) | branch, ahead/behind, dirty files, uncommitted lines, last-commit age | -| **machine** | `os` + `pmset` + `statfs` (cached 15 s) | cpu %, mem %, battery %, disk free % | -| **health** | status.anthropic.com summary + latency (cached 60 s) | Anthropic status (ok/degraded/down), API latency, online | +| Bucket | Source | Fields | +| ----------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **ai** | statusline snapshot + derived history | 5h/7d limit % + reset, ctx %, cost, cache %, burn $/min, time-to-limit, model/effort, lines ± | +| **git** | `git` in the snapshot's `cwd` (cached 10 s) | branch, ahead/behind, dirty files, uncommitted lines, last-commit age | +| **machine** | sampled cpu + `memory_pressure`/`MemAvailable` + `pmset` + `statfs` (15 s) | cpu % busy (sampled), mem % used (real pressure), battery %, disk % used | +| **health** | status.anthropic.com summary (cached 60 s) | Anthropic status (ok/degraded/down), online | Burn rate and time-to-limit are derived from the rolling cost/limit history in `deriveUsage`. @@ -73,23 +73,26 @@ The slot is tagged `cacheSource: "local"`; the orchestrator skips the down. Each column owns a hue (fill + a dark "unfilled" track tint): ``` -▍ utils · live -⎇ organic-bread ↑3 ↓1 · 5 dirty │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded -7d ███░░░░░ 22% · 6d │ ctx ███░░░░░ 41% │ cache ██████░ 79% │ 5h ███░░ 36% · 1h 9m -cpu ██░░░░░░ 30 │ mem ████████ 99 │ disk █████░░ 57 │ batt ████████ 100 -distro tv · utils +▍ utils +⎇ organic-bread ↑3 ↓1 · 2 dirty │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded +week █░░░░░░░ 14% · 3d 16h │ ctx ███░░░░░ 42% │ cache ████████ 99% │ 5h ███░░ 36% · 1h 9m + +cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 │ batt ████████ 100 ``` -- **col 1 · teal** — 7d limit (gauge) / git: `⎇ branch ↑ahead ↓behind · N dirty` +- **col 1 · teal** — weekly limit (gauge, labeled `week`) / git: `⎇ branch ↑ahead ↓behind · N dirty` - **col 2 · amber** — ctx (gauge) / cost · burn · `~time-to-limit` - **col 3 · violet** — cache (gauge) / model · effort - **col 4 · periwinkle** — 5h limit (gauge) / **api line only on an incident** -- machine row (cpu/mem/disk/batt) uses gray bars; **mem turns red ≥90%** - -Per-column label fields (`COL_FIELD`) are uniform so every bar starts at the same -x in its column. Bars turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for -5h/7d) and `UTILITY_CTX_WARN_PCT` (90%, ctx). The **API health line is hidden -when Anthropic is healthy** and only appears (red) during a `degraded`/`down` +- a blank line separates the gauge row from the machine row +- machine bars (cpu/mem/disk/batt) are gray; **cpu/mem/disk redden ≥90%**, but + **battery reddens only when low (≤20%)** since a full charge is good + +No header `· live` suffix and no footer line. Per-column label fields +(`COL_FIELD`) are uniform so every bar starts at the same x in its column. Limit +gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for 5h/week) and +`UTILITY_CTX_WARN_PCT` (90%, ctx). The **API health line is hidden when Anthropic +is healthy** and only appears (red) during a `degraded`/`down` incident. Bar width scales with the terminal width the daemon reads off the tty, and the diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index f0220017..88c9e575 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -172,7 +172,7 @@ const FG_RGB: Rgb = [233, 235, 242] // per-column label field (max label + a space) so bars start at the same x in // their column on every row. -const COL_FIELD = [4, 4, 6, 5] as const +const COL_FIELD = [5, 4, 6, 5] as const // fill + track bar. truecolor → bg blocks; else █/░ fallback. function renderBar(pct: number, width: number, h: Hue, mode: ColorMode): string { @@ -233,8 +233,7 @@ function renderUtilityPanel( nudge: string[] ): string { const now = Date.now() - const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)} ${color("muted", "· live", mode)}` - const footer = `${LEFT_PAD}${color("muted", "distro tv · utils", mode)}` + const header = `${color("indigo", "▍", mode)} ${color("indigo", "utils", mode)}` const sep = color("muted", " │ ", mode) const { ai, git, machine, health } = slot @@ -291,7 +290,7 @@ function renderUtilityPanel( if (ai.sevenDayPct !== undefined) { const w = ai.sevenDayPct >= UTILITY_LIMIT_WARN_PCT gau[0] = gcell( - "7d", + "week", 0, ai.sevenDayPct, barW, @@ -346,12 +345,12 @@ function renderUtilityPanel( } } - // ── machine row ── cpu | mem | disk | batt (gray; turns red ≥90%) + // ── machine row ── cpu | mem | disk | batt. cpu/mem/disk redden when full + // (≥90%); battery reddens when LOW (≤20%) since a full charge is good. const mac: string[] = ["", "", "", ""] if (machine) { - const mc = (label: string, col: number, pct: number | undefined): string => { + const mc = (label: string, col: number, pct: number | undefined, hot: boolean): string => { if (pct === undefined) return "" - const hot = pct >= 90 return gcell( label, col, @@ -363,10 +362,10 @@ function renderUtilityPanel( mode ) } - mac[0] = mc("cpu", 0, machine.cpuPct) - mac[1] = mc("mem", 1, machine.memPct) - mac[2] = mc("disk", 2, machine.diskFreePct) - mac[3] = mc("batt", 3, machine.battPct) + mac[0] = mc("cpu", 0, machine.cpuPct, (machine.cpuPct ?? 0) >= 90) + mac[1] = mc("mem", 1, machine.memPct, (machine.memPct ?? 0) >= 90) + mac[2] = mc("disk", 2, machine.diskUsedPct, (machine.diskUsedPct ?? 0) >= 90) + mac[3] = mc("batt", 3, machine.battPct, (machine.battPct ?? 100) <= 20) } // pad cols 0-2 to their widest cell across the rendered rows (col 3 is the @@ -376,7 +375,11 @@ function renderUtilityPanel( const w = [0, 0, 0] for (const r of present) for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r[i] ?? "")) - const lines = present.map((r) => { + const lines: string[] = [] + let max = 0 + for (const r of present) { + // blank line between the gauge row and the machine row for readability. + if (r === mac && lines.length > 0) lines.push("") let s = padEndVis(r[0] ?? "", w[0] ?? 0) + sep + @@ -384,9 +387,10 @@ function renderUtilityPanel( sep + padEndVis(r[2] ?? "", w[2] ?? 0) if ((r[3] ?? "").length > 0) s += sep + r[3] - return `${LEFT_PAD}${s}` - }) - const max = lines.reduce((mx, l) => Math.max(mx, visLen(l)), 0) + const line = `${LEFT_PAD}${s}` + lines.push(line) + max = Math.max(max, visLen(line)) + } return { lines, max } } @@ -404,7 +408,7 @@ function renderUtilityPanel( chosen = r } - return [...nudge, header, ...chosen.lines, footer].join("\n") + return [...nudge, header, ...chosen.lines].join("\n") } // "90m" → "1h 30m", "45m" → "45m" diff --git a/packages/cli/src/lib/utility-slot.ts b/packages/cli/src/lib/utility-slot.ts index d11f5fe6..919009d4 100644 --- a/packages/cli/src/lib/utility-slot.ts +++ b/packages/cli/src/lib/utility-slot.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process" -import { statfs } from "node:fs/promises" -import { cpus, freemem, loadavg, platform, totalmem } from "node:os" +import { readFile, statfs } from "node:fs/promises" +import { cpus, platform } from "node:os" import { promisify } from "node:util" import { UTILITY_GIT_CACHE_MS, @@ -87,6 +87,55 @@ async function probeGit(cwd: string, now: number): Promise { // ── machine probe ───────────────────────────────────────────────────────── +function cpuSnapshot(): { idle: number; total: number } { + let idle = 0 + let total = 0 + for (const c of cpus()) { + for (const k of Object.keys(c.times) as (keyof typeof c.times)[]) total += c.times[k] + idle += c.times.idle + } + return { idle, total } +} + +// real instantaneous CPU% busy: sample cpu times twice ~250ms apart. loadavg +// is not a % and overstates on multi-core boxes, so we never use it. +async function probeCpu(): Promise { + const a = cpuSnapshot() + await new Promise((r) => setTimeout(r, 250)) + const b = cpuSnapshot() + const dt = b.total - a.total + if (dt <= 0) return undefined + return Math.max(0, Math.min(100, Math.round((1 - (b.idle - a.idle) / dt) * 100))) +} + +// real memory pressure: os.freemem() is meaningless on macOS (reports ~3% free +// because the OS uses RAM for cache), so use memory_pressure / MemAvailable. +async function probeMem(): Promise { + if (platform() === "darwin") { + try { + const { stdout } = await execFileAsync("memory_pressure", [], { timeout: 1500 }) + const m = /System-wide memory free percentage:\s*([\d.]+)%/.exec(stdout) + if (m) return Math.max(0, Math.min(100, Math.round(100 - Number.parseFloat(m[1] as string)))) + } catch { + /* omit */ + } + return undefined + } + if (platform() === "linux") { + try { + const txt = await readFile("/proc/meminfo", "utf8") + const tot = /MemTotal:\s+(\d+)/.exec(txt) + const avail = /MemAvailable:\s+(\d+)/.exec(txt) + if (tot && avail) { + return Math.round((1 - Number(avail[1]) / Number(tot[1])) * 100) + } + } catch { + /* omit */ + } + } + return undefined // never show a misleading fallback +} + async function probeBattery(): Promise { if (platform() !== "darwin") return undefined try { @@ -99,22 +148,15 @@ async function probeBattery(): Promise { } async function probeMachine(cwd: string | undefined): Promise { + const [cpuPct, memPct, battPct] = await Promise.all([probeCpu(), probeMem(), probeBattery()]) const out: UtilityMachine = {} - - // 1-min load average normalized by core count — a cheap CPU-pressure proxy. - const cores = cpus().length || 1 - const load1 = loadavg()[0] ?? 0 - if (load1 > 0) out.cpuPct = Math.min(100, Math.round((load1 / cores) * 100)) - - const total = totalmem() - if (total > 0) out.memPct = Math.round(((total - freemem()) / total) * 100) - - out.battPct = await probeBattery() - + if (cpuPct !== undefined) out.cpuPct = cpuPct + if (memPct !== undefined) out.memPct = memPct + if (battPct !== undefined) out.battPct = battPct try { const stats = await statfs(cwd || process.env["HOME"] || "/") const blocks = Number(stats.blocks) - if (blocks > 0) out.diskFreePct = Math.round((Number(stats.bavail) / blocks) * 100) + if (blocks > 0) out.diskUsedPct = Math.round((1 - Number(stats.bavail) / blocks) * 100) } catch { /* omit */ } diff --git a/packages/shared/src/types/UtilityPayload.ts b/packages/shared/src/types/UtilityPayload.ts index 47db017b..ba24c86e 100644 --- a/packages/shared/src/types/UtilityPayload.ts +++ b/packages/shared/src/types/UtilityPayload.ts @@ -42,10 +42,10 @@ export interface UtilityGit { } export interface UtilityMachine { - cpuPct?: number - memPct?: number - battPct?: number - diskFreePct?: number + cpuPct?: number // instantaneous % busy (sampled), not load average + memPct?: number // % used — real pressure (macOS memory_pressure / linux MemAvailable) + battPct?: number // charge % + diskUsedPct?: number // % used (100 - free), so a fuller disk reads as a fuller bar } export type AnthropicHealth = "ok" | "degraded" | "down" From 3cbbc4a48ab3dfe26cd2356cf84f98252b66d30e Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 17:02:44 +0530 Subject: [PATCH 4/7] =?UTF-8?q?feat(cli):=20utils=20panel=20=E2=80=94=20gi?= =?UTF-8?q?t=20arrows=20only,=20drop=20battery,=20group=20limits,=20rule?= =?UTF-8?q?=20divider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - git: show just branch + ↑ahead ↓behind vs remote (git-CLI style); drop the uncommitted-files count - machine: remove battery (not useful in a terminal) → cpu / mem / disk - gauge order ctx · cache · week · 5h so the weekly + 5-hour limits sit together - replace the gauge/machine separator with a dim horizontal rule (a blank line was being collapsed by Claude's statusLine) --- gitbook-docs/cli/utilities-channel.md | 31 +++++----- packages/cli/src/lib/render-line.ts | 68 +++++++++++---------- packages/cli/src/lib/utility-slot.ts | 14 +---- packages/shared/src/types/UtilityPayload.ts | 1 - 4 files changed, 53 insertions(+), 61 deletions(-) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md index 79354cbf..91b95aea 100644 --- a/gitbook-docs/cli/utilities-channel.md +++ b/gitbook-docs/cli/utilities-channel.md @@ -74,26 +74,29 @@ down. Each column owns a hue (fill + a dark "unfilled" track tint): ``` ▍ utils -⎇ organic-bread ↑3 ↓1 · 2 dirty │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded -week █░░░░░░░ 14% · 3d 16h │ ctx ███░░░░░ 42% │ cache ████████ 99% │ 5h ███░░ 36% · 1h 9m - -cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 │ batt ████████ 100 +⎇ organic-bread ↑3 ↓1 │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded +ctx ███░░░░░ 42% │ cache ████████ 99% │ week █░░░ 14% · 3d │ 5h █░░░ 9% · 4h 6m +────────────────────────────────────────────────────────────────────────────────── +cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 ``` -- **col 1 · teal** — weekly limit (gauge, labeled `week`) / git: `⎇ branch ↑ahead ↓behind · N dirty` -- **col 2 · amber** — ctx (gauge) / cost · burn · `~time-to-limit` -- **col 3 · violet** — cache (gauge) / model · effort -- **col 4 · periwinkle** — 5h limit (gauge) / **api line only on an incident** -- a blank line separates the gauge row from the machine row -- machine bars (cpu/mem/disk/batt) are gray; **cpu/mem/disk redden ≥90%**, but - **battery reddens only when low (≤20%)** since a full charge is good +Gauge order is **ctx · cache · week · 5h** — the two Claude limits (weekly + +5-hour) sit **together** as columns 3 & 4. Per-metric hues: ctx amber, cache +violet, week teal, 5h periwinkle. + +- **context row** — git `⎇ branch ↑ahead ↓behind` (git-CLI style, no dirty count) · + cost · burn · `~time-to-limit` · model/effort · **api line only on an incident** +- **gauge row** — ctx · cache · weekly limit (`week`) · 5-hour limit (`5h`) +- a **dim horizontal rule** separates the gauge row from the machine row (a blank + line gets collapsed by Claude's statusLine, so we draw a rule) +- **machine row** — cpu / mem / disk, gray bars that redden ≥90% (battery dropped — + not useful in a terminal) No header `· live` suffix and no footer line. Per-column label fields (`COL_FIELD`) are uniform so every bar starts at the same x in its column. Limit -gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for 5h/week) and +gauges turn red + `⚠` at/above `UTILITY_LIMIT_WARN_PCT` (90%, for week/5h) and `UTILITY_CTX_WARN_PCT` (90%, ctx). The **API health line is hidden when Anthropic -is healthy** and only appears (red) during a `degraded`/`down` -incident. +is healthy** and only appears (red) during a `degraded`/`down` incident. Bar width scales with the terminal width the daemon reads off the tty, and the panel degrades progressively on narrow terminals: full → drop machine row → drop diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index 88c9e575..0dfe7b8e 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -172,7 +172,8 @@ const FG_RGB: Rgb = [233, 235, 242] // per-column label field (max label + a space) so bars start at the same x in // their column on every row. -const COL_FIELD = [5, 4, 6, 5] as const +// columns: ctx(0) · cache(1) · week(2) · 5h(3); machine cpu(0)/mem(1)/disk(2). +const COL_FIELD = [4, 6, 5, 3] as const // fill + track bar. truecolor → bg blocks; else █/░ fallback. function renderBar(pct: number, width: number, h: Hue, mode: ColorMode): string { @@ -243,13 +244,11 @@ function renderUtilityPanel( // ── context row ── git | cost/burn/proj | model | api(incident only) const ctx: string[] = ["", "", "", ""] if (git?.branch) { + // git-CLI style: just the branch + ↑ahead/↓behind vs remote (no dirty count). const ab: string[] = [] if (git.ahead) ab.push(`↑${git.ahead}`) if (git.behind) ab.push(`↓${git.behind}`) - const metaParts: string[] = [] - if (ab.length) metaParts.push(ab.join(" ")) - if (git.dirtyFiles !== undefined) metaParts.push(`${git.dirtyFiles} dirty`) - const meta = metaParts.length ? color("muted", ` ${metaParts.join(" · ")}`, mode) : "" + const meta = ab.length ? color("muted", ` ${ab.join(" ")}`, mode) : "" ctx[0] = rgb(`⎇ ${git.branch}`, H_TEAL.fill[0], H_TEAL.fill[1], H_TEAL.fill[2], mode) + meta } if (ai) { @@ -284,28 +283,14 @@ function renderUtilityPanel( ctx[3] = color("negative", "⚠ offline", mode) } - // ── gauge row ── 7d | ctx | cache | 5h + // ── gauge row ── ctx | cache | week | 5h (the two limits kept together) const gau: string[] = ["", "", "", ""] if (ai) { - if (ai.sevenDayPct !== undefined) { - const w = ai.sevenDayPct >= UTILITY_LIMIT_WARN_PCT - gau[0] = gcell( - "week", - 0, - ai.sevenDayPct, - barW, - w ? H_RED : H_TEAL, - `${ai.sevenDayPct}%`, - w ? H_RED.fill : H_TEAL.fill, - mode, - ai.sevenDayResetAt ? fmtReset(ai.sevenDayResetAt, now) : undefined - ) - } if (ai.ctxPct !== undefined) { const w = ai.ctxPct >= UTILITY_CTX_WARN_PCT - gau[1] = gcell( + gau[0] = gcell( "ctx", - 1, + 0, ai.ctxPct, barW, w ? H_RED : H_AMBER, @@ -315,9 +300,9 @@ function renderUtilityPanel( ) } if (ai.cachePct !== undefined) { - gau[2] = gcell( + gau[1] = gcell( "cache", - 2, + 1, ai.cachePct, barW, H_VIOLET, @@ -326,6 +311,20 @@ function renderUtilityPanel( mode ) } + if (ai.sevenDayPct !== undefined) { + const w = ai.sevenDayPct >= UTILITY_LIMIT_WARN_PCT + gau[2] = gcell( + "week", + 2, + ai.sevenDayPct, + barW, + w ? H_RED : H_TEAL, + `${ai.sevenDayPct}%`, + w ? H_RED.fill : H_TEAL.fill, + mode, + ai.sevenDayResetAt ? fmtReset(ai.sevenDayResetAt, now) : undefined + ) + } if (ai.fiveHourPct !== undefined) { const w = ai.fiveHourPct >= UTILITY_LIMIT_WARN_PCT const pre = w ? color("negative", "⚠ ", mode) : "" @@ -345,12 +344,12 @@ function renderUtilityPanel( } } - // ── machine row ── cpu | mem | disk | batt. cpu/mem/disk redden when full - // (≥90%); battery reddens when LOW (≤20%) since a full charge is good. + // ── machine row ── cpu | mem | disk (gray; redden when full ≥90%). const mac: string[] = ["", "", "", ""] if (machine) { - const mc = (label: string, col: number, pct: number | undefined, hot: boolean): string => { + const mc = (label: string, col: number, pct: number | undefined): string => { if (pct === undefined) return "" + const hot = pct >= 90 return gcell( label, col, @@ -362,10 +361,9 @@ function renderUtilityPanel( mode ) } - mac[0] = mc("cpu", 0, machine.cpuPct, (machine.cpuPct ?? 0) >= 90) - mac[1] = mc("mem", 1, machine.memPct, (machine.memPct ?? 0) >= 90) - mac[2] = mc("disk", 2, machine.diskUsedPct, (machine.diskUsedPct ?? 0) >= 90) - mac[3] = mc("batt", 3, machine.battPct, (machine.battPct ?? 100) <= 20) + mac[0] = mc("cpu", 0, machine.cpuPct) + mac[1] = mc("mem", 1, machine.memPct) + mac[2] = mc("disk", 2, machine.diskUsedPct) } // pad cols 0-2 to their widest cell across the rendered rows (col 3 is the @@ -378,8 +376,12 @@ function renderUtilityPanel( const lines: string[] = [] let max = 0 for (const r of present) { - // blank line between the gauge row and the machine row for readability. - if (r === mac && lines.length > 0) lines.push("") + // a dim horizontal rule separates the gauge row from the machine row — + // a blank line gets collapsed by Claude's statusLine, a rule always shows. + if (r === mac && max > 0) { + const ruleW = Math.max(0, max - LEFT_PAD.length) + lines.push(`${LEFT_PAD}${rgb("─".repeat(ruleW), 72, 78, 92, mode)}`) + } let s = padEndVis(r[0] ?? "", w[0] ?? 0) + sep + diff --git a/packages/cli/src/lib/utility-slot.ts b/packages/cli/src/lib/utility-slot.ts index 919009d4..014a9379 100644 --- a/packages/cli/src/lib/utility-slot.ts +++ b/packages/cli/src/lib/utility-slot.ts @@ -136,23 +136,11 @@ async function probeMem(): Promise { return undefined // never show a misleading fallback } -async function probeBattery(): Promise { - if (platform() !== "darwin") return undefined - try { - const { stdout } = await execFileAsync("pmset", ["-g", "batt"], { timeout: 1500 }) - const m = /(\d+)%/.exec(stdout) - return m ? Number.parseInt(m[1] as string, 10) : undefined - } catch { - return undefined - } -} - async function probeMachine(cwd: string | undefined): Promise { - const [cpuPct, memPct, battPct] = await Promise.all([probeCpu(), probeMem(), probeBattery()]) + const [cpuPct, memPct] = await Promise.all([probeCpu(), probeMem()]) const out: UtilityMachine = {} if (cpuPct !== undefined) out.cpuPct = cpuPct if (memPct !== undefined) out.memPct = memPct - if (battPct !== undefined) out.battPct = battPct try { const stats = await statfs(cwd || process.env["HOME"] || "/") const blocks = Number(stats.blocks) diff --git a/packages/shared/src/types/UtilityPayload.ts b/packages/shared/src/types/UtilityPayload.ts index ba24c86e..5072c5c2 100644 --- a/packages/shared/src/types/UtilityPayload.ts +++ b/packages/shared/src/types/UtilityPayload.ts @@ -44,7 +44,6 @@ export interface UtilityGit { export interface UtilityMachine { cpuPct?: number // instantaneous % busy (sampled), not load average memPct?: number // % used — real pressure (macOS memory_pressure / linux MemAvailable) - battPct?: number // charge % diskUsedPct?: number // % used (100 - free), so a fuller disk reads as a fuller bar } From b9d6395ed1287f3285dc9ea6bb27efbdb0f7fca1 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 17:11:04 +0530 Subject: [PATCH 5/7] =?UTF-8?q?feat(cli):=20utils=20panel=20=E2=80=94=20bl?= =?UTF-8?q?ank-space=20row=20separator=20instead=20of=20a=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swap the dim horizontal rule between the gauge and machine rows for a blank gap. a truly-empty line gets collapsed by Claude's statusLine, so the row is non-breaking spaces — it renders blank but carries width, so it survives. --- gitbook-docs/cli/utilities-channel.md | 7 ++++--- packages/cli/src/lib/render-line.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md index 91b95aea..f0ecdaf1 100644 --- a/gitbook-docs/cli/utilities-channel.md +++ b/gitbook-docs/cli/utilities-channel.md @@ -76,7 +76,7 @@ down. Each column owns a hue (fill + a dark "unfilled" track tint): ▍ utils ⎇ organic-bread ↑3 ↓1 │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded ctx ███░░░░░ 42% │ cache ████████ 99% │ week █░░░ 14% · 3d │ 5h █░░░ 9% · 4h 6m -────────────────────────────────────────────────────────────────────────────────── + cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 ``` @@ -87,8 +87,9 @@ violet, week teal, 5h periwinkle. - **context row** — git `⎇ branch ↑ahead ↓behind` (git-CLI style, no dirty count) · cost · burn · `~time-to-limit` · model/effort · **api line only on an incident** - **gauge row** — ctx · cache · weekly limit (`week`) · 5-hour limit (`5h`) -- a **dim horizontal rule** separates the gauge row from the machine row (a blank - line gets collapsed by Claude's statusLine, so we draw a rule) +- a **blank separator row** sits between the gauge row and the machine row — + Claude's statusLine collapses a zero-width empty line, so it's a row of + non-breaking spaces (renders blank, but carries width so it survives) - **machine row** — cpu / mem / disk, gray bars that redden ≥90% (battery dropped — not useful in a terminal) diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index 0dfe7b8e..bfa57e1f 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -375,12 +375,13 @@ function renderUtilityPanel( for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r[i] ?? "")) const lines: string[] = [] let max = 0 + const NBSP = String.fromCharCode(0xa0) // renders blank, carries width for (const r of present) { - // a dim horizontal rule separates the gauge row from the machine row — - // a blank line gets collapsed by Claude's statusLine, a rule always shows. + // a blank separator row sits between the gauge and machine rows — + // an empty line gets collapsed, so we emit non-breaking spaces (blank, width-bearing). if (r === mac && max > 0) { - const ruleW = Math.max(0, max - LEFT_PAD.length) - lines.push(`${LEFT_PAD}${rgb("─".repeat(ruleW), 72, 78, 92, mode)}`) + const gapW = Math.max(1, max - LEFT_PAD.length) + lines.push(`${LEFT_PAD}${NBSP.repeat(gapW)}`) } let s = padEndVis(r[0] ?? "", w[0] ?? 0) + From b355a445684647c28ffeaaecf7a588f7ddf329aa Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 17:17:25 +0530 Subject: [PATCH 6/7] =?UTF-8?q?fix(cli):=20restore=20faint=20rule=20separa?= =?UTF-8?q?tor=20=E2=80=94=20blank=20row=20dropped=20the=20machine=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a non-breaking-space "blank" row made Claude's statusLine truncate every row after it (the machine row vanished). a plain empty line just collapses to no gap. so revert to a thin divider, dimmed (60,64,74) to read as breathing room rather than a hard line — the lightest separator that renders the machine row. --- gitbook-docs/cli/utilities-channel.md | 9 +++++---- packages/cli/src/lib/render-line.ts | 9 ++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gitbook-docs/cli/utilities-channel.md b/gitbook-docs/cli/utilities-channel.md index f0ecdaf1..2b3cb311 100644 --- a/gitbook-docs/cli/utilities-channel.md +++ b/gitbook-docs/cli/utilities-channel.md @@ -76,7 +76,7 @@ down. Each column owns a hue (fill + a dark "unfilled" track tint): ▍ utils ⎇ organic-bread ↑3 ↓1 │ $0.29 · $0.19/m · ~5m │ Opus 4.8 / high │ ⚠ api degraded ctx ███░░░░░ 42% │ cache ████████ 99% │ week █░░░ 14% · 3d │ 5h █░░░ 9% · 4h 6m - +────────────────────────────────────────────────────────────────────────────────── cpu ███░░░░░ 38 │ mem ████░░░░ 44 │ disk ███░░░░░ 43 ``` @@ -87,9 +87,10 @@ violet, week teal, 5h periwinkle. - **context row** — git `⎇ branch ↑ahead ↓behind` (git-CLI style, no dirty count) · cost · burn · `~time-to-limit` · model/effort · **api line only on an incident** - **gauge row** — ctx · cache · weekly limit (`week`) · 5-hour limit (`5h`) -- a **blank separator row** sits between the gauge row and the machine row — - Claude's statusLine collapses a zero-width empty line, so it's a row of - non-breaking spaces (renders blank, but carries width so it survives) +- a **faint dim rule** separates the gauge row from the machine row. A truly + blank line can't be used: Claude's statusLine collapses a zero-width line, and + a whitespace/non-breaking-space row makes it **drop every row after it** — so a + thin rule is the lightest separator that renders reliably - **machine row** — cpu / mem / disk, gray bars that redden ≥90% (battery dropped — not useful in a terminal) diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index bfa57e1f..4f455dbe 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -375,13 +375,12 @@ function renderUtilityPanel( for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r[i] ?? "")) const lines: string[] = [] let max = 0 - const NBSP = String.fromCharCode(0xa0) // renders blank, carries width for (const r of present) { - // a blank separator row sits between the gauge and machine rows — - // an empty line gets collapsed, so we emit non-breaking spaces (blank, width-bearing). + // a faint divider between the gauge and machine rows — + // a truly-blank line makes Claude drop the rows after it, so we use a dim rule. if (r === mac && max > 0) { - const gapW = Math.max(1, max - LEFT_PAD.length) - lines.push(`${LEFT_PAD}${NBSP.repeat(gapW)}`) + const ruleW = Math.max(1, max - LEFT_PAD.length) + lines.push(`${LEFT_PAD}${rgb("\u2500".repeat(ruleW), 60, 64, 74, mode)}`) } let s = padEndVis(r[0] ?? "", w[0] ?? 0) + From 7d00c87f8a9358d9eaf4cea51afdaa59135be884 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Tue, 2 Jun 2026 17:57:13 +0530 Subject: [PATCH 7/7] =?UTF-8?q?refactor(cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20perf,=20correctness,=20dead=20code,=20code=20smells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-usage: single snapshot read per rotation via loadUsage() (was reading the same file twice — readUsageSnapshot + deriveUsage); cache-hit-rate now excludes cache_creation tokens (writes, not reads) from the denominator; burn-rate window is an explicit BURN_RATE_WINDOW_MS const (the old staleMs arg was silently clamped and had no effect) - render-line: replace fragile `r === mac` identity check with a GridRow { cells, sepBefore } shape; drop the misleading `chosen = r` fallback in the non-break path (pre-seed handles it) - utility-slot: background probe failures now reported via an optional debug logger (daemon threads log.debug in) instead of a silent .catch(() => {}) - statusline: skip wrapped-command output when spawnSync sets res.error (timeout / spawn failure) so we never emit a partial line - shared: drop never-populated UtilityAi fields (burnTokPerMin, compactAtPct) --- packages/cli/src/commands/daemon.ts | 1 + packages/cli/src/commands/statusline.ts | 9 +++- packages/cli/src/lib/claude-usage.ts | 35 +++++++++------ packages/cli/src/lib/render-line.ts | 48 +++++++++++++-------- packages/cli/src/lib/utility-slot.ts | 35 ++++++++++----- packages/shared/src/types/UtilityPayload.ts | 5 +-- 6 files changed, 87 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 91e76f3d..10a3d02f 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -363,6 +363,7 @@ export async function runDaemon(): Promise { let livePrefs = cfg.preferences const utilityProvider = createUtilityProvider({ getLayoutPref: () => livePrefs.utilitiesLayout, + log: (msg, fields) => log.debug(msg, fields), }) orchestrator = createOrchestrator({ diff --git a/packages/cli/src/commands/statusline.ts b/packages/cli/src/commands/statusline.ts index 71f21030..0cdfb8c9 100644 --- a/packages/cli/src/commands/statusline.ts +++ b/packages/cli/src/commands/statusline.ts @@ -47,8 +47,13 @@ export const statuslineCmd = new Command("statusline") timeout: 800, maxBuffer: 1024 * 1024, }) - const out = (res.stdout ?? "").replace(/\n+$/, "") - if (out.length > 0) parts.push(out) + // res.error is set on spawn failure / timeout (and stdout may be a + // partial chunk) — skip it then so we never emit a half-rendered line. + // stderr from the wrapped command is intentionally dropped. + if (!res.error) { + const out = (res.stdout ?? "").replace(/\n+$/, "") + if (out.length > 0) parts.push(out) + } } catch { /* fall through — show our line alone */ } diff --git a/packages/cli/src/lib/claude-usage.ts b/packages/cli/src/lib/claude-usage.ts index 79b63628..760789ba 100644 --- a/packages/cli/src/lib/claude-usage.ts +++ b/packages/cli/src/lib/claude-usage.ts @@ -102,11 +102,13 @@ export function parseStatuslineInput(raw: string, now: number): UsageLatest { return cur } + // cache hit rate = reads / (reads + fresh uncached input). cache_creation + // tokens are writes (being stored for the first time), not reads — counting + // them in the denominator deflated the % while a session was building its + // cache, so they're excluded. const cacheRead = num(pick(["context_window", "current_usage", "cache_read_input_tokens"])) ?? 0 - const cacheCreate = - num(pick(["context_window", "current_usage", "cache_creation_input_tokens"])) ?? 0 const inTok = num(pick(["context_window", "current_usage", "input_tokens"])) ?? 0 - const cacheDenom = cacheRead + cacheCreate + inTok + const cacheDenom = cacheRead + inTok const cachePct = cacheDenom > 0 ? Math.round((cacheRead * 100) / cacheDenom) : undefined return { @@ -166,20 +168,27 @@ export function recordUsage(latest: UsageLatest): void { writeSnapshot({ version: SNAPSHOT_VERSION, latest, history }) } -// Read the latest snapshot, or null when none/stale. -export function readUsageSnapshot(now: number, staleMs: number): UsageLatest | null { +// Read the snapshot once and return both the latest fields (or null when stale) +// and the derived burn-rate / time-to-limit — a single file read per call, so +// the per-rotation render path doesn't read the same file twice. +export function loadUsage( + now: number, + staleMs: number +): { latest: UsageLatest | null; derived: UsageDerived } { const snap = readSnapshot() - if (!snap) return null - if (now - snap.latest.ts > staleMs) return null - return snap.latest + if (!snap) return { latest: null, derived: {} } + const latest = now - snap.latest.ts > staleMs ? null : snap.latest + return { latest, derived: deriveFromSnapshot(snap, now) } } -// Burn rate ($/min) from cumulative cost over the history window, and a +// Rolling window for burn-rate / projection — deliberately independent of the +// snapshot staleness cutoff. +const BURN_RATE_WINDOW_MS = 10 * 60_000 + +// Burn rate ($/min) from cumulative cost over BURN_RATE_WINDOW_MS, and a // projection of minutes until the nearest rate-limit window hits 100%. -export function deriveUsage(now: number, staleMs: number): UsageDerived { - const snap = readSnapshot() - if (!snap) return {} - const hist = snap.history.filter((s) => now - s.ts <= Math.max(staleMs, 10 * 60_000)) +function deriveFromSnapshot(snap: UsageSnapshot, now: number): UsageDerived { + const hist = snap.history.filter((s) => now - s.ts <= BURN_RATE_WINDOW_MS) if (hist.length < 2) return {} const first = hist[0] const last = hist[hist.length - 1] diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index 4f455dbe..b4522822 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -156,6 +156,11 @@ export function renderSlotLine( // across rows, branch + ahead/behind, and an api line only on an incident. type Rgb = readonly [number, number, number] +// one row of the utility grid; `sepBefore` draws a faint divider above it. +interface GridRow { + cells: string[] + sepBefore?: boolean +} interface Hue { fill: Rgb track: Rgb @@ -368,27 +373,28 @@ function renderUtilityPanel( // pad cols 0-2 to their widest cell across the rendered rows (col 3 is the // tail — its cells already start at the same x, so they align unpadded). - const build = (rows: string[][]): { lines: string[]; max: number } => { - const present = rows.filter((r) => r.some((c) => c.length > 0)) + const build = (rows: GridRow[]): { lines: string[]; max: number } => { + const present = rows.filter((r) => r.cells.some((c) => c.length > 0)) const w = [0, 0, 0] for (const r of present) - for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r[i] ?? "")) + for (let i = 0; i < 3; i++) w[i] = Math.max(w[i] ?? 0, visLen(r.cells[i] ?? "")) const lines: string[] = [] let max = 0 for (const r of present) { - // a faint divider between the gauge and machine rows — - // a truly-blank line makes Claude drop the rows after it, so we use a dim rule. - if (r === mac && max > 0) { + // `sepBefore` rows get a faint divider above them. A truly-blank line is + // collapsed by Claude's statusLine, so we draw a dim rule instead. + if (r.sepBefore && lines.length > 0) { const ruleW = Math.max(1, max - LEFT_PAD.length) - lines.push(`${LEFT_PAD}${rgb("\u2500".repeat(ruleW), 60, 64, 74, mode)}`) + lines.push(`${LEFT_PAD}${rgb("─".repeat(ruleW), 60, 64, 74, mode)}`) } + const c = r.cells let s = - padEndVis(r[0] ?? "", w[0] ?? 0) + + padEndVis(c[0] ?? "", w[0] ?? 0) + sep + - padEndVis(r[1] ?? "", w[1] ?? 0) + + padEndVis(c[1] ?? "", w[1] ?? 0) + sep + - padEndVis(r[2] ?? "", w[2] ?? 0) - if ((r[3] ?? "").length > 0) s += sep + r[3] + padEndVis(c[2] ?? "", w[2] ?? 0) + if ((c[3] ?? "").length > 0) s += sep + c[3] const line = `${LEFT_PAD}${s}` lines.push(line) max = Math.max(max, visLen(line)) @@ -396,18 +402,26 @@ function renderUtilityPanel( return { lines, max } } - // progressive degradation so the core gauges always fit: full → drop machine - // → drop context detail (keep git + api) → gauges only. - const slimCtx = [ctx[0] ?? "", "", "", ctx[3] ?? ""] - const attempts = [[ctx, gau, mac], [ctx, gau], [slimCtx, gau], [gau]] - let chosen = build(attempts[attempts.length - 1] as string[][]) + // progressive degradation so the core gauges always fit: full -> drop machine + // -> drop context detail (keep git + api) -> gauges only. + const ctxRow: GridRow = { cells: ctx } + const gauRow: GridRow = { cells: gau } + const macRow: GridRow = { cells: mac, sepBefore: true } + const slimCtxRow: GridRow = { cells: [ctx[0] ?? "", "", "", ctx[3] ?? ""] } + const attempts: GridRow[][] = [ + [ctxRow, gauRow, macRow], + [ctxRow, gauRow], + [slimCtxRow, gauRow], + [gauRow], + ] + // pre-seed with the narrowest layout; only replace it when one actually fits. + let chosen = build(attempts[attempts.length - 1] as GridRow[]) for (const rows of attempts) { const r = build(rows) if (r.max <= W) { chosen = r break } - chosen = r } return [...nudge, header, ...chosen.lines].join("\n") diff --git a/packages/cli/src/lib/utility-slot.ts b/packages/cli/src/lib/utility-slot.ts index 014a9379..ff877ec2 100644 --- a/packages/cli/src/lib/utility-slot.ts +++ b/packages/cli/src/lib/utility-slot.ts @@ -14,7 +14,7 @@ import { type UtilityPayload, } from "@distrotv/shared" import type { CachedSlot } from "./slot-cache.js" -import { deriveUsage, readUsageSnapshot, type UsageLatest } from "./claude-usage.js" +import { loadUsage, type UsageDerived, type UsageLatest } from "./claude-usage.js" import { hasWrappedStatusLine } from "./wrapped-statusline.js" const execFileAsync = promisify(execFile) @@ -26,6 +26,9 @@ export interface UtilityProviderDeps { // CLI-local pref: "auto" picks complement when a custom status line was // wrapped at install, else full. getLayoutPref?: () => LayoutPref + // optional debug logger; background probe failures are reported here (never + // thrown). Without it, failures are silent. + log?: (msg: string, fields?: Record) => void } export interface UtilityProvider { @@ -196,12 +199,18 @@ interface Cell { export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityProvider { const now = deps.now ?? (() => Date.now()) const getLayoutPref = deps.getLayoutPref ?? (() => "auto" as LayoutPref) + const log = deps.log ?? (() => {}) const gitCell: Cell = { value: null, at: 0, inFlight: false } const machineCell: Cell = { value: null, at: 0, inFlight: false } const healthCell: Cell = { value: null, at: 0, inFlight: false } - function refresh(cell: Cell, ttl: number, run: () => Promise): void { + function refresh( + label: string, + cell: Cell, + ttl: number, + run: () => Promise + ): void { if (cell.inFlight) return if (cell.value !== null && now() - cell.at < ttl) return cell.inFlight = true @@ -210,7 +219,11 @@ export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityPr cell.value = v cell.at = now() }) - .catch(() => {}) + .catch((err: unknown) => { + // probes must never throw into the render path, but the failure + // shouldn't be invisible either — report it for diagnosis. + log(`utility probe failed: ${label}`, { error: (err as Error)?.message }) + }) .finally(() => { cell.inFlight = false }) @@ -222,9 +235,11 @@ export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityPr return hasWrappedStatusLine() ? "complement" : "full" } - function aiFrom(usage: UsageLatest | null): UtilityPayload["ai"] | undefined { + function aiFrom( + usage: UsageLatest | null, + derived: UsageDerived + ): UtilityPayload["ai"] | undefined { if (!usage) return undefined - const derived = deriveUsage(now(), UTILITY_SNAPSHOT_STALE_MS) const ai: NonNullable = { fiveHourPct: usage.fiveHourPct, fiveHourResetAt: usage.fiveHourResetAt, @@ -247,17 +262,17 @@ export function createUtilityProvider(deps: UtilityProviderDeps = {}): UtilityPr return { build() { const t = now() - const usage = readUsageSnapshot(t, UTILITY_SNAPSHOT_STALE_MS) + const { latest: usage, derived } = loadUsage(t, UTILITY_SNAPSHOT_STALE_MS) // kick stale probes off in the background; use whatever's cached now. - refresh(machineCell, UTILITY_MACHINE_CACHE_MS, () => probeMachine(usage?.cwd)) - refresh(healthCell, UTILITY_HEALTH_CACHE_MS, () => probeHealth(now)) + refresh("machine", machineCell, UTILITY_MACHINE_CACHE_MS, () => probeMachine(usage?.cwd)) + refresh("health", healthCell, UTILITY_HEALTH_CACHE_MS, () => probeHealth(now)) if (usage?.cwd) { const cwd = usage.cwd - refresh(gitCell, UTILITY_GIT_CACHE_MS, () => probeGit(cwd, t)) + refresh("git", gitCell, UTILITY_GIT_CACHE_MS, () => probeGit(cwd, t)) } - const ai = aiFrom(usage) + const ai = aiFrom(usage, derived) const git = gitCell.value ?? undefined const machine = machineCell.value ?? undefined const health = healthCell.value ?? undefined diff --git a/packages/shared/src/types/UtilityPayload.ts b/packages/shared/src/types/UtilityPayload.ts index 5072c5c2..61b75542 100644 --- a/packages/shared/src/types/UtilityPayload.ts +++ b/packages/shared/src/types/UtilityPayload.ts @@ -15,14 +15,11 @@ export interface UtilityAi { // context window ctxPct?: number ctxSize?: number - // % at which auto-compact kicks in (for the "compaction imminent" cue) - compactAtPct?: number // session economics costUsd?: number cachePct?: number - // derived from the rolling snapshot history + // burn rate ($/min), derived from the rolling snapshot history burnUsdPerMin?: number - burnTokPerMin?: number // projected minutes until the nearest limit (5h or 7d) is hit at current burn timeToLimitMin?: number // identity / session deltas