import * as fs from "node:fs"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { settings } from "../../config/settings"; import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; import * as git from "../../utils/git"; import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color"; import { sanitizeStatusText } from "../shared"; import { computeContextBreakdown } from "../utils/context-usage"; import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext, } from "./status-line/git-utils"; import { getPreset } from "./status-line/presets"; import { renderSegment, type SegmentContext } from "./status-line/segments"; import { getSeparator } from "./status-line/separators"; import { calculateTokensPerSecond } from "./status-line/token-rate"; export interface StatusLineSegmentOptions { model?: { showThinkingLevel?: boolean }; path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean }; git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean }; time?: { format?: "12h" | "24h"; showSeconds?: boolean }; } export interface StatusLineSettings { preset?: StatusLinePreset; leftSegments?: StatusLineSegmentId[]; rightSegments?: StatusLineSegmentId[]; separator?: StatusLineSeparatorStyle; segmentOptions?: StatusLineSegmentOptions; showHookStatus?: boolean; sessionAccent?: boolean; } // ═══════════════════════════════════════════════════════════════════════════ // Rendering Helpers // ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════ // StatusLineComponent // ═══════════════════════════════════════════════════════════════════════════ export class StatusLineComponent implements Component { #settings: StatusLineSettings = {}; #cachedBranch: string | null | undefined = undefined; #cachedBranchRepoId: string | null | undefined = undefined; #gitWatcher: fs.FSWatcher | null = null; #onBranchChange: (() => void) | null = null; #autoCompactEnabled: boolean = true; #hookStatuses: Map = new Map(); #subagentCount: number = 0; #sessionStartTime: number = Date.now(); #planModeStatus: { enabled: boolean; paused: boolean } | null = null; #loopModeStatus: { enabled: boolean } | null = null; #goalModeStatus: { enabled: boolean; paused: boolean } | null = null; // Git status caching (1s TTL) #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; #gitStatusLastFetch = 0; #gitStatusInFlight = false; // PR lookup caching (invalidated on branch/repo context changes) #cachedPr: { number: number; url: string } | null | undefined = undefined; #cachedPrContext: PrCacheContext | undefined = undefined; #prLookupInFlight = false; #defaultBranch?: string; #lastTokensPerSecond: number | null = null; #lastTokensPerSecondTimestamp: number | null = null; // Context breakdown caching (2s TTL — aligns with /context command output) #cachedBreakdown: { usedTokens: number; contextWindow: number } | null = null; #breakdownFetchedAt = 0; constructor(private readonly session: AgentSession) { this.#settings = { preset: settings.get("statusLine.preset"), leftSegments: settings.get("statusLine.leftSegments"), rightSegments: settings.get("statusLine.rightSegments"), separator: settings.get("statusLine.separator"), showHookStatus: settings.get("statusLine.showHookStatus"), segmentOptions: settings.getGroup("statusLine").segmentOptions, sessionAccent: settings.get("statusLine.sessionAccent"), }; } updateSettings(settings: StatusLineSettings): void { this.#settings = settings; } setAutoCompactEnabled(enabled: boolean): void { this.#autoCompactEnabled = enabled; } setSubagentCount(count: number): void { this.#subagentCount = count; } setSessionStartTime(time: number): void { this.#sessionStartTime = time; } setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void { this.#planModeStatus = status ?? null; } setLoopModeStatus(status: { enabled: boolean } | undefined): void { this.#loopModeStatus = status ?? null; } setGoalModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void { this.#goalModeStatus = status ?? null; } setHookStatus(key: string, text: string | undefined): void { if (text === undefined) { this.#hookStatuses.delete(key); } else { this.#hookStatuses.set(key, text); } } watchBranch(onBranchChange: () => void): void { this.#onBranchChange = onBranchChange; this.#setupGitWatcher(); } #setupGitWatcher(): void { if (this.#gitWatcher) { this.#gitWatcher.close(); this.#gitWatcher = null; } const gitHeadPath = git.repo.resolveSync(getProjectDir())?.headPath ?? null; if (!gitHeadPath) return; try { this.#gitWatcher = fs.watch(gitHeadPath, () => { this.#invalidateGitCaches(); if (this.#onBranchChange) { this.#onBranchChange(); } }); } catch { this.#invalidateGitCaches(); } } dispose(): void { if (this.#gitWatcher) { this.#gitWatcher.close(); this.#gitWatcher = null; } } invalidate(): void { this.#invalidateGitCaches(); } #invalidateGitCaches(): void { this.#cachedBranch = undefined; this.#cachedBranchRepoId = undefined; this.#cachedPrContext = undefined; } #getCurrentBranch(): string | null { const head = git.head.resolveSync(getProjectDir()); const gitHeadPath = head?.headPath ?? null; if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) { return this.#cachedBranch; } this.#cachedBranchRepoId = gitHeadPath; if (!head) { this.#cachedBranch = null; return null; } this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached"; return this.#cachedBranch ?? null; } #isDefaultBranch(branch: string): boolean { if (this.#defaultBranch === undefined) { this.#defaultBranch = "main"; (async () => { const resolved = await git.branch.default(getProjectDir()); if (resolved) { this.#defaultBranch = resolved; if (this.#onBranchChange) { this.#onBranchChange(); } } })(); } return branch === this.#defaultBranch; } #getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) { return this.#cachedGitStatus; } this.#gitStatusInFlight = true; (async () => { try { this.#cachedGitStatus = await git.status.summary(getProjectDir()); } catch { this.#cachedGitStatus = null; } finally { this.#gitStatusLastFetch = Date.now(); this.#gitStatusInFlight = false; } })(); return this.#cachedGitStatus; } #lookupPr(): { number: number; url: string } | null { const branch = this.#getCurrentBranch(); const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null; if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) { return this.#cachedPr ?? null; } const stalePr = this.#cachedPr; // Don't look up if no branch, detached HEAD, default branch, or already in flight if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) { return stalePr ?? null; } this.#prLookupInFlight = true; const lookupContext = currentContext; // Fire async lookup, keep stale value visible until resolved (async () => { // Helper: only write cache if branch/repo context hasn't changed since launch const setCachedPr = (value: { number: number; url: string } | null) => { const latestBranch = this.#getCurrentBranch(); const latestContext = latestBranch ? createPrCacheContext(latestBranch, this.#cachedBranchRepoId ?? null) : undefined; if (lookupContext && isSamePrCacheContext(latestContext, lookupContext)) { this.#cachedPr = value; this.#cachedPrContext = lookupContext; } }; try { // Requires `gh repo set-default` to be configured; fails gracefully if not const result = await $`gh pr view --json number,url`.quiet().nothrow(); if (result.exitCode !== 0) { setCachedPr(null); return; } const pr = JSON.parse(result.stdout.toString()) as { number: number; url: string }; if (typeof pr.number === "number") { setCachedPr({ number: pr.number, url: pr.url }); } else { setCachedPr(null); } } catch { setCachedPr(null); } finally { this.#prLookupInFlight = false; if (this.#onBranchChange) { this.#onBranchChange(); } } })(); return stalePr ?? null; } #getTokensPerSecond(): number | null { let lastAssistantTimestamp: number | null = null; for (let i = this.session.state.messages.length - 1; i >= 0; i--) { const message = this.session.state.messages[i]; if (message?.role === "assistant") { lastAssistantTimestamp = message.timestamp; break; } } if (lastAssistantTimestamp === null) { this.#lastTokensPerSecond = null; this.#lastTokensPerSecondTimestamp = null; return null; } const rate = calculateTokensPerSecond(this.session.state.messages, this.session.isStreaming); if (rate !== null) { this.#lastTokensPerSecond = rate; this.#lastTokensPerSecondTimestamp = lastAssistantTimestamp; return rate; } if (this.#lastTokensPerSecondTimestamp === lastAssistantTimestamp) { return this.#lastTokensPerSecond; } return null; } #getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } { const now = Date.now(); if (!this.#cachedBreakdown || now - this.#breakdownFetchedAt > 2_000) { const breakdown = computeContextBreakdown(this.session); this.#cachedBreakdown = { usedTokens: breakdown.usedTokens, contextWindow: breakdown.contextWindow, }; this.#breakdownFetchedAt = now; } return this.#cachedBreakdown; } #buildSegmentContext(width: number): SegmentContext { const state = this.session.state; // Get usage statistics const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0, }; const usageStats = { ...aggregateUsageStats, tokensPerSecond: this.#getTokensPerSecond(), }; // Context usage — aligned with /context command so both surfaces report the same value const breakdown = this.#getCachedContextBreakdown(); const contextTokens = breakdown.usedTokens; const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0; const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; return { session: this.session, width, options: this.#resolveSettings().segmentOptions ?? {}, planMode: this.#planModeStatus, loopMode: this.#loopModeStatus, goalMode: this.#goalModeStatus, usageStats, contextPercent, contextWindow, autoCompactEnabled: this.#autoCompactEnabled, subagentCount: this.#subagentCount, sessionStartTime: this.#sessionStartTime, git: { branch: this.#getCurrentBranch(), status: this.#getGitStatus(), pr: this.#lookupPr(), }, }; } #resolveSettings(): Required< Pick > & StatusLineSettings { const preset = this.#settings.preset ?? "default"; const presetDef = getPreset(preset); const useCustomSegments = preset === "custom"; const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {}; for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) { mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record) }; } for (const [segment, options] of Object.entries(this.#settings.segmentOptions ?? {})) { const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {}; mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(current as Record), ...(options as Record), }; } const leftSegments = useCustomSegments ? (this.#settings.leftSegments ?? presetDef.leftSegments) : presetDef.leftSegments; const rightSegments = useCustomSegments ? (this.#settings.rightSegments ?? presetDef.rightSegments) : presetDef.rightSegments; return { ...this.#settings, leftSegments, rightSegments, separator: this.#settings.separator ?? presetDef.separator, segmentOptions: mergedSegmentOptions, }; } #buildStatusLine(width: number): string { const ctx = this.#buildSegmentContext(width); const effectiveSettings = this.#resolveSettings(); const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme); const bgAnsi = theme.getBgAnsi("statusLineBg"); const fgAnsi = theme.getFgAnsi("text"); const sepAnsi = theme.getFgAnsi("statusLineSep"); // Collect visible segment contents const leftParts: string[] = []; const leftSegIds: StatusLineSegmentId[] = []; for (const segId of effectiveSettings.leftSegments) { const rendered = renderSegment(segId, ctx); if (rendered.visible && rendered.content) { leftParts.push(rendered.content); leftSegIds.push(segId); } } const rightParts: string[] = []; for (const segId of effectiveSettings.rightSegments) { const rendered = renderSegment(segId, ctx); if (rendered.visible && rendered.content) { rightParts.push(rendered.content); } } const runningBackgroundJobs = this.session.getAsyncJobSnapshot()?.running.length ?? 0; if (runningBackgroundJobs > 0) { const icon = theme.icon.agents ? `${theme.icon.agents} ` : ""; const label = `${formatCount("job", runningBackgroundJobs)} running`; rightParts.push(theme.fg("statusLineSubagents", `${icon}${label}`)); } const topFillWidth = Math.max(0, width); const left = [...leftParts]; const right = [...rightParts]; const leftSepWidth = visibleWidth(separatorDef.left); const rightSepWidth = visibleWidth(separatorDef.right); const leftCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0; const rightCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.left) : 0; const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => { if (parts.length === 0) return 0; const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0); const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2); return partsWidth + sepTotal + 2 + capWidth; }; let leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0); if (topFillWidth > 0) { while (totalWidth() > topFillWidth && right.length > 0) { right.pop(); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); } // Shrink path before dropping left segments — path is the only elastic segment const pathIdx = leftSegIds.indexOf("path"); if (pathIdx >= 0 && totalWidth() > topFillWidth) { const overflow = totalWidth() - topFillWidth; const currentPathVW = visibleWidth(left[pathIdx]); const minPathVW = 8; // icon + ellipsis + a few chars const shrinkable = currentPathVW - minPathVW; if (shrinkable > 0) { const shrinkBy = Math.min(shrinkable, overflow); const currentMaxLen = ctx.options.path?.maxLength ?? 40; let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy); const pathCtx = (maxLen: number): SegmentContext => ({ ...ctx, options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } }, }); let reRendered = renderSegment("path", pathCtx(newMaxLen)); if (reRendered.visible && reRendered.content) { // maxLength governs path text, not icon prefix; iterate to compensate for (let i = 0; i < 8; i++) { const saved = currentPathVW - visibleWidth(reRendered.content); if (saved >= shrinkBy) break; const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved)); if (nextMaxLen >= newMaxLen) break; // no progress or hit floor newMaxLen = nextMaxLen; const adjusted = renderSegment("path", pathCtx(newMaxLen)); if (!adjusted.visible || !adjusted.content) break; reRendered = adjusted; } left[pathIdx] = reRendered.content; leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); } } } while (totalWidth() > topFillWidth && left.length > 0) { left.pop(); leftSegIds.pop(); leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); } } const renderGroup = (parts: string[], direction: "left" | "right"): string => { if (parts.length === 0) return ""; const sep = direction === "left" ? separatorDef.left : separatorDef.right; const cap = separatorDef.endCaps ? direction === "left" ? separatorDef.endCaps.right : separatorDef.endCaps.left : ""; const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi; const capText = cap ? `${capPrefix}${cap}\x1b[0m` : ""; let content = bgAnsi + fgAnsi; content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `; content += "\x1b[0m"; if (capText) { return direction === "right" ? capText + content : content + capText; } return content; }; const leftGroup = renderGroup(left, "left"); const rightGroup = renderGroup(right, "right"); if (!leftGroup && !rightGroup) return ""; if (topFillWidth === 0 || left.length === 0 || right.length === 0) { return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup; } leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth); const sessionName = effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined; const accentHex = sessionName ? getSessionAccentHex(sessionName) : undefined; const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border"); const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`; return leftGroup + gapFill + rightGroup; } getTopBorder(width: number): { content: string; width: number } { const content = this.#buildStatusLine(width); return { content, width: visibleWidth(content), }; } render(width: number): string[] { // Only render hook statuses - main status is in editor's top border const showHooks = this.#settings.showHookStatus ?? true; if (!showHooks || this.#hookStatuses.size === 0) { return []; } const sortedStatuses = Array.from(this.#hookStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const hookLine = sortedStatuses.join(" "); return [truncateToWidth(hookLine, width)]; } }