/** * display.ts — Pure formatting helpers and display utilities for agent UI. * * All functions are stateless and dependency-free (no SDK runtime imports, no widget lifecycle). * Consumed by the widget, the menu, tool modules, and the notification renderer. */ import type { ThemeColor } from "@earendil-works/pi-coding-agent"; import { GLYPHS } from "./glyphs.js"; import type { AgentDefinitionLookup, AgentStatus, InvocationTags, ModelIdentity, TurnBudget, } from "./view-types.js"; export type { AgentDefinitionLookup, InvocationTags, ModelIdentity, TurnBudget, } from "./view-types.js"; // ---- Types ---- export interface Theme { fg(color: ThemeColor, text: string): string; bold(text: string): string; } /** Metadata attached to Agent tool results for custom rendering. */ export type AgentDetails = Readonly<{ displayName: string; description: string; subagentType: string; toolUses: number; tokens: string; durationMs: number; status: AgentStatus | "background"; /** Human-readable description of what the agent is currently doing. */ activity?: string; /** Current spinner frame index (for animated running indicator). */ spinnerFrame?: number; /** The `provider/id` of the model the agent runs (e.g. "anthropic/claude-haiku-4-5"). */ modelName?: string; /** Notable config tags (e.g. ["thinking: high", "inherit context"]). */ tags?: ReadonlyArray; agentId?: string; error?: string; /** The run's turn budget; absent until its turn loop starts. */ turnBudget?: TurnBudget; }>; // ---- Constants ---- /** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */ export const ERROR_STATUSES: ReadonlySet = new Set([ "error", "aborted", "stopped", ]); /** Tool name → human-readable action for activity descriptions. */ const TOOL_DISPLAY: Record = { read: "reading", bash: "running command", edit: "editing", write: "writing", grep: "searching", find: "finding files", ls: "listing", }; // ---- Pure formatters ---- /** Format a token count compactly: "33.8k token", "1.2M token". */ export function formatTokens(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`; return `${count} token`; } /** * Token count with optional context-fill % and compaction-count annotations. * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error. * Compaction count rendered as `⇊N` in dim (see `glyphs.ts`). * * "12.3k token" — no annotations * "12.3k token (45%)" — percent only * "12.3k token (⇊2)" — compactions only (e.g. right after compact) * "12.3k token (45% · ⇊2)" — both */ export function formatSessionTokens( tokens: number, percent: number | null, theme: Theme, compactions = 0, ): string { const tokenStr = formatTokens(tokens); const annot: string[] = []; if (percent !== null) { const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim"; annot.push(theme.fg(color, `${Math.round(percent)}%`)); } if (compactions > 0) { annot.push(theme.fg("dim", `${GLYPHS.compactions}${compactions}`)); } if (annot.length === 0) return tokenStr; const sep = theme.fg("dim", " · "); return `${tokenStr} ${theme.fg("dim", "(")}${annot.join(sep)}${theme.fg("dim", ")")}`; } /** Format a turn budget as turns used against the ceiling: "↻5≤30", or "↻5" when unlimited. */ export function formatTurnBudget(budget: TurnBudget): string { return budget.maxTurns != null ? `${GLYPHS.turns}${budget.used}≤${budget.maxTurns}` : `${GLYPHS.turns}${budget.used}`; } /** Format milliseconds as human-readable duration. */ export function formatMs(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } /** A model as `provider/id`, the syntax the Agent tool's `model` argument accepts. */ export function formatModel(model: ModelIdentity): string { return `${model.provider}/${model.id}`; } /** A model's `provider/id` label, or undefined while the model is unknown. */ export function modelLabel( model: ModelIdentity | undefined, ): string | undefined { return model ? formatModel(model) : undefined; } /** Format duration from start/completed timestamps. */ export function formatDuration( startedAt: number, completedAt?: number, ): string { if (completedAt) return formatMs(completedAt - startedAt); return `${formatMs(Date.now() - startedAt)} (running)`; } // ---- Display helpers ---- /** * The display name of an agent type. * * A type the registry does not know is named as it was asked for: the * registry's own list is what a caller should have picked from. */ export function getDisplayName( type: string, registry: AgentDefinitionLookup, ): string { const definition = registry.find(type); return definition ? definition.displayName || definition.id : type; } /** Mode label is not included — callers add it where they want it. */ export function buildInvocationTags(invocation: InvocationTags | undefined): { tags: string[]; } { const tags: string[] = []; if (!invocation) return { tags }; if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`); if (invocation.inheritContext) tags.push("inherit context"); if (invocation.runInBackground) tags.push("background"); if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`); return { tags }; } /** Truncate text to a single line, max `len` chars. */ function truncateLine(text: string, len = 60): string { const line = text .split("\n") .find((l) => l.trim()) ?.trim() ?? ""; if (line.length <= len) return line; return line.slice(0, len) + "…"; } /** Build a human-readable activity string from currently-running tools or response text. */ export function describeActivity( activeTools: ReadonlyMap, responseText?: string, ): string { if (activeTools.size > 0) { const groups = new Map(); for (const toolName of activeTools.values()) { const action = TOOL_DISPLAY[toolName] ?? toolName; groups.set(action, (groups.get(action) ?? 0) + 1); } const parts: string[] = []; for (const [action, count] of groups) { if (count > 1) { parts.push( `${action} ${count} ${action === "searching" ? "patterns" : "files"}`, ); } else { parts.push(action); } } return parts.join(", ") + "…"; } // No tools active — show truncated response text if available if (responseText && responseText.trim().length > 0) { return truncateLine(responseText); } return "thinking…"; }