/** * The individual footer segments. * * Each segment carries progressively shorter `forms` and a `weight`; the layout * engine advances forms and drops segments from the highest weight down, so the * bar degrades predictably instead of clipping arbitrary text. Colors come from * the design-system roles, so the bar follows the active theme. */ import type { Theme } from "@earendil-works/pi-coding-agent"; import { stripTerminalSequences } from "@earendil-works/pi-tui"; import { formatTokens, sanitize, sanitizeLine } from "../../../../lib/format.ts"; import { gaugeBar } from "../../../../lib/tui/gauge.ts"; import { chip } from "../../../../lib/tui/rows.ts"; import { paint } from "../../../../lib/tui/tokens.ts"; import { CONFIG } from "../config.ts"; import { contextRole, formatCost, formatCwd, formatPercent, thinkingColor, truncateLabel } from "./format.ts"; import { compactStatus, dropStatusDetail } from "./status.ts"; import type { Segment, StatusSnapshot } from "../types.ts"; /** Tertiary text: paths and de-emphasized meters. */ const faint = (theme: Theme, text: string): string => paint(theme, "faint", text); /** * The label part of a themed status chip: strip its escape sequences, sanitize * it to one safe line, then drop the leading icon. */ function chipLabel(raw: string): string { return sanitizeLine(stripTerminalSequences(raw)) .replace(/^\S+\s*/, "") .trim(); } export function pwdSegment(snapshot: StatusSnapshot, theme: Theme, home: string | undefined): Segment { return { id: "pwd", weight: 2, droppable: false, separator: "", forms: [ faint(theme, formatCwd(snapshot.cwd, home, 0)), faint(theme, formatCwd(snapshot.cwd, home, 1)), faint(theme, formatCwd(snapshot.cwd, home, 2)), ], }; } export function sessionSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const name = snapshot.sessionName ? sanitizeLine(snapshot.sessionName) : ""; if (!name) return null; const short = truncateLabel(name, CONFIG.sessionLabelMax); // Brackets and a brighter token keep the session name from reading as a // trailing path segment. const forms = [paint(theme, "meta", `[${name}]`)]; if (short !== name) forms.push(paint(theme, "meta", `[${short}]`)); return { id: "session", weight: 3, droppable: true, separator: faint(theme, CONFIG.separators.item), forms, }; } export function branchSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { if (!snapshot.branch) return null; const detached = snapshot.branch === CONFIG.labels.detached; const icon = detached ? CONFIG.icons.detached : CONFIG.icons.branch; return { id: "branch", weight: 1, droppable: true, separator: faint(theme, CONFIG.separators.item), forms: [chip(theme, icon, snapshot.branch, detached ? "warn" : "meta")], }; } export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const raw = snapshot.statuses.get(CONFIG.worktreeStatusKey); if (!raw) return null; const icon = CONFIG.icons.worktree; const separator = faint(theme, CONFIG.separators.item); const label = chipLabel(raw); if (!label) { return { id: "worktree", weight: 3, droppable: true, separator, forms: [paint(theme, "ok", icon)] }; } const short = truncateLabel(label, CONFIG.worktreeLabelMax); const forms = [chip(theme, icon, label, "ok")]; if (short !== label) forms.push(chip(theme, icon, short, "ok")); forms.push(paint(theme, "ok", icon)); return { id: "worktree", weight: 3, droppable: true, separator, forms }; } export function browseSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const raw = snapshot.statuses.get(CONFIG.browseStatusKey); if (!raw) return null; const icon = CONFIG.icons.browse; const label = chipLabel(raw); const separator = faint(theme, CONFIG.separators.item); if (!label) { return { id: "browse", weight: 3, droppable: true, separator, forms: [paint(theme, "ok", icon)] }; } // A two-tone chip: the `◉` glyph stays green while the port reads as accent. return { id: "browse", weight: 3, droppable: true, separator, forms: [ `${paint(theme, "ok", icon)} ${paint(theme, "accent", label)}`, `${paint(theme, "ok", icon)}${paint(theme, "accent", label)}`, paint(theme, "ok", icon), ], }; } /** Sort rank: an alert (a failed job) leads the slot; modes follow by key. */ function statusRank(key: string): number { return key === CONFIG.jobsFailureStatusKey ? 0 : 1; } export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const others = [...snapshot.statuses.entries()] .filter(([key]) => key !== CONFIG.worktreeStatusKey && key !== CONFIG.browseStatusKey) .sort(([a], [b]) => statusRank(a) - statusRank(b) || a.localeCompare(b)) .map(([key, value]) => { const status = sanitize(value); // The plan chip trails the plan file name; the slot shows the mode only. return key === CONFIG.planStatusKey ? dropStatusDetail(status) : status; }) .filter(Boolean); if (others.length === 0) return null; const full = others.join(faint(theme, CONFIG.separators.item)); // The compact form keeps each status's own colour (see `compactStatus`); a // two-token badge keeps its count (`↺ 2` -> `↺2`) instead of collapsing to a // bare icon. const icons = others.map((status) => compactStatus(status)).join(" "); return { id: "statuses", // The last thing to go, but droppable: a tight bar drops the slot rather // than truncating the model, which sits at the end of the line. weight: 1, droppable: true, separator: faint(theme, CONFIG.separators.group), forms: [full, icons], }; } export function contextSegment(snapshot: StatusSnapshot, theme: Theme): Segment { const percent = snapshot.context.percent; const role = contextRole(percent); const percentText = paint(theme, role, formatPercent(percent)); // Unknown usage draws no track, so `?` sits where the gauge would be rather // than a full row of empty blocks claiming a known zero. const widths: readonly number[] = percent === null ? [0] : CONFIG.gauge.widths; const forms = widths.map((blocks) => blocks > 0 ? `${gaugeBar(theme, { percent, blocks, role, emptyRole: "faint" })} ${percentText}` : percentText, ); return { id: "context", weight: 1, droppable: false, separator: "", forms }; } export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { if (snapshot.context.contextWindow <= 0) return null; return { id: "window", weight: 3, droppable: true, separator: "", forms: [faint(theme, `/${formatTokens(snapshot.context.contextWindow)}`)], }; } export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { if (snapshot.usage.cost === 0) return null; return { id: "cost", weight: 2, droppable: true, separator: faint(theme, CONFIG.separators.item), forms: [paint(theme, "meta", formatCost(snapshot.usage.cost, snapshot.currency))], }; } export function tokensSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const { input, output } = snapshot.usage; if (input === 0 && output === 0) return null; const parts: string[] = []; if (input > 0) parts.push(`↑${formatTokens(input)}`); if (output > 0) parts.push(`↓${formatTokens(output)}`); return { id: "tokens", weight: 4, droppable: true, separator: faint(theme, CONFIG.separators.item), forms: [faint(theme, parts.join(" "))], }; } export function cacheSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { const { cacheRead, cacheWrite } = snapshot.usage; const cached = cacheRead > 0 || cacheWrite > 0; const parts: string[] = []; if (cacheRead > 0) parts.push(`R${formatTokens(cacheRead)}`); if (cacheWrite > 0) parts.push(`W${formatTokens(cacheWrite)}`); // A hit rate is meaningful only once something was actually cached; an // assistant turn with no cache reports `0`, which would otherwise render a // lone `CH 0%` (the built-in footer guards this the same way). if (cached && snapshot.cacheHitRate !== null) parts.push(`CH ${Math.round(snapshot.cacheHitRate)}%`); if (parts.length === 0) return null; return { id: "cache", weight: 5, droppable: true, separator: faint(theme, CONFIG.separators.item), forms: [faint(theme, parts.join(" "))], }; } export function modelSegment(snapshot: StatusSnapshot, theme: Theme): Segment { const model = snapshot.model; if (!model) { // No model selected: a muted note, not an accent-colored pseudo-model. return { id: "model", weight: 1, droppable: false, separator: "", forms: [paint(theme, "meta", CONFIG.labels.noModel)], }; } const provider = snapshot.providerCount > 1 ? `(${model.provider}) ` : ""; return { id: "model", weight: 1, droppable: false, separator: "", forms: [paint(theme, "accent", `${provider}${model.id}`), paint(theme, "accent", model.id)], }; } export function thinkingSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null { if (!snapshot.model?.reasoning) return null; const level = snapshot.thinkingLevel ?? "off"; // The reasoning scale is Pi's own seven-token ramp, not a design role: a // level colors itself from `thinkingColor`. const color = thinkingColor(level); return { id: "thinking", weight: 2, droppable: true, separator: faint(theme, CONFIG.separators.item), forms: [theme.fg(color, level)], }; }