Skip to content
1 change: 1 addition & 0 deletions gitbook-docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
124 changes: 124 additions & 0 deletions gitbook-docs/cli/utilities-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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** | 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`.

## 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.

## 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
⎇ 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
```

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 **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)

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 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.

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

`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.
11 changes: 11 additions & 0 deletions packages/cli/src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -357,8 +358,17 @@ export async function runDaemon(): Promise<number> {
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,
log: (msg, fields) => log.debug(msg, fields),
})

orchestrator = createOrchestrator({
slotCache,
utilityProvider,
ledger,
display: { show: showAd },
keyCapture,
Expand All @@ -382,6 +392,7 @@ export async function runDaemon(): Promise<number> {
try {
const next = await readConfig()
if (!next) return
livePrefs = next.preferences
orchestrator.updatePreferences(next.preferences)
const nextFp = fingerprintPrefs(next.preferences)
if (nextFp !== lastPrefsFingerprint) {
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/commands/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Action =
| "channels"
| "watchlist"
| "alerts"
| "utilities"
| "quiet-hours"
| "tz"
| "topics"
Expand Down Expand Up @@ -51,6 +52,7 @@ async function showMenu(currentMode: ChannelMode): Promise<Action> {
{ 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)" },
Expand Down Expand Up @@ -118,6 +120,47 @@ async function tzAction(current: SyncedPreferences): Promise<void> {
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<void> {
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<void> {
intro("distro preferences")
let prefs = await getPreferences()
Expand Down Expand Up @@ -205,6 +248,11 @@ async function runPreferences(): Promise<void> {
continue
}

if (action === "utilities") {
await utilitiesAction()
continue
}

if (action === "quiet-hours") {
await quietHoursAction(prefs)
prefs = await getPreferences()
Expand Down
70 changes: 65 additions & 5 deletions packages/cli/src/commands/statusline.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,76 @@
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,
})
// 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 */
}
}

// 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)
})
Loading
Loading