UNPKG

@owloops/claude-powerline

Version:

Beautiful vim-style powerline statusline for Claude Code with real-time usage tracking, git integration, and custom themes

916 lines (802 loc) 25 kB
import type { ClaudeHookData } from "../utils/claude"; import { getEffortLevel, getThinkingEnabled } from "../utils/claude"; import type { PowerlineColors } from "../themes"; import type { PowerlineConfig } from "../config/loader"; import type { BlockInfo } from "./block"; import type { CacheTimerInfo } from "./cacheTimer"; import type { UsageInfo, TokenBreakdown, GitInfo, ContextInfo, MetricsInfo, } from "."; import type { TodayInfo } from "./today"; import { formatModelName, abbreviateFishStyle, formatCost, formatTokens, formatTokenBreakdown, formatTimeSince, formatDuration, formatLongTimeRemaining, formatCacheTimerElapsed, collapseHome, minutesUntilReset, } from "../utils/formatters"; import { resolveBudgetDisplay } from "../utils/budget"; import type { BudgetItemConfig } from "../config/loader"; import { shouldShowIcon } from "../utils/icon-visibility"; export interface SegmentConfig { enabled: boolean; showIcon?: boolean; } export interface DirectorySegmentConfig extends SegmentConfig { showBasename?: boolean; style?: "full" | "fish" | "basename"; } export interface GitSegmentConfig extends SegmentConfig { showSha?: boolean; showAheadBehind?: boolean; showWorkingTree?: boolean; showOperation?: boolean; showTag?: boolean; showTimeSinceCommit?: boolean; showStashCount?: boolean; showUpstream?: boolean; showRepoName?: boolean; } export interface UsageSegmentConfig extends SegmentConfig { type: "cost" | "tokens" | "both" | "breakdown"; costSource?: "calculated" | "official"; } export interface TmuxSegmentConfig extends SegmentConfig {} export type BarDisplayStyle = | "text" | "ball" | "bar" | "blocks" | "blocks-line" | "capped" | "dots" | "filled" | "geometric" | "line" | "squares"; export interface ContextSegmentConfig extends SegmentConfig { showPercentageOnly?: boolean; displayStyle?: BarDisplayStyle; autocompactBuffer?: number; percentageMode?: "remaining" | "used"; } export interface MetricsSegmentConfig extends SegmentConfig { showResponseTime?: boolean; showLastResponseTime?: boolean; showDuration?: boolean; showMessageCount?: boolean; showLinesAdded?: boolean; showLinesRemoved?: boolean; } export interface BlockSegmentConfig extends SegmentConfig { type: "cost" | "tokens" | "both" | "time" | "weighted"; burnType?: "cost" | "tokens" | "both" | "none"; displayStyle?: BarDisplayStyle; } export interface TodaySegmentConfig extends SegmentConfig { type: "cost" | "tokens" | "both" | "breakdown"; } export interface VersionSegmentConfig extends SegmentConfig {} export interface SessionIdSegmentConfig extends SegmentConfig { showIdLabel?: boolean; } export interface EnvSegmentConfig extends SegmentConfig { variable: string; prefix?: string; } export interface WeeklySegmentConfig extends SegmentConfig { displayStyle?: BarDisplayStyle; } export interface AgentSegmentConfig extends SegmentConfig { showLabel?: boolean; } export interface ThinkingSegmentConfig extends SegmentConfig { showEnabled?: boolean; showEffort?: boolean; } export interface CacheTimerSegmentConfig extends SegmentConfig {} export type AnySegmentConfig = | SegmentConfig | DirectorySegmentConfig | GitSegmentConfig | UsageSegmentConfig | TmuxSegmentConfig | ContextSegmentConfig | MetricsSegmentConfig | BlockSegmentConfig | TodaySegmentConfig | VersionSegmentConfig | SessionIdSegmentConfig | EnvSegmentConfig | WeeklySegmentConfig | AgentSegmentConfig | ThinkingSegmentConfig | CacheTimerSegmentConfig; export interface PowerlineSymbols { right: string; left: string; branch: string; model: string; git_clean: string; git_dirty: string; git_conflicts: string; git_ahead: string; git_behind: string; git_worktree: string; git_tag: string; git_sha: string; git_upstream: string; git_stash: string; git_time: string; session_cost: string; block_cost: string; today_cost: string; context_time: string; metrics_response: string; metrics_last_response: string; metrics_duration: string; metrics_messages: string; metrics_lines_added: string; metrics_lines_removed: string; metrics_burn: string; version: string; bar_filled: string; bar_empty: string; env: string; session_id: string; weekly_cost: string; agent: string; thinking: string; cache_timer: string; } export interface SegmentData { text: string; bgColor: string; fgColor: string; bold?: boolean; } interface BarStyleDef { filled: string; empty: string; cap?: string; marker?: string; } const BAR_STYLES: Record<string, BarStyleDef> = { ball: { filled: "─", empty: "─", marker: "●" }, blocks: { filled: "█", empty: "░" }, "blocks-line": { filled: "█", empty: "─" }, capped: { filled: "━", empty: "┄", cap: "╸" }, dots: { filled: "●", empty: "○" }, filled: { filled: "■", empty: "□" }, geometric: { filled: "▰", empty: "▱" }, line: { filled: "━", empty: "┄" }, squares: { filled: "◼", empty: "◻" }, }; export class SegmentRenderer { constructor( private readonly config: PowerlineConfig, private readonly symbols: PowerlineSymbols, ) {} private leadingIcon(symbol: string, segConfig?: SegmentConfig): string { const show = shouldShowIcon( this.config.display?.showIcons, segConfig?.showIcon, ); return show ? `${symbol} ` : ""; } renderDirectory( hookData: ClaudeHookData, colors: PowerlineColors, config?: DirectorySegmentConfig, ): SegmentData { const worktreeOriginalCwd = hookData.worktree?.original_cwd || undefined; const currentDir = worktreeOriginalCwd ?? (hookData.workspace?.current_dir || hookData.cwd || "/"); const projectDir = worktreeOriginalCwd ?? hookData.workspace?.project_dir; const style = config?.style ?? (config?.showBasename ? "basename" : "full"); if (style === "basename") { const basename = currentDir.split(/[\\/]/).pop() || "root"; return { text: basename, bgColor: colors.modeBg, fgColor: colors.modeFg, }; } const displayDir = collapseHome(currentDir); const displayProjectDir = projectDir ? collapseHome(projectDir) : projectDir; let dirName = this.getDisplayDirectoryName(displayDir, displayProjectDir); if (style === "fish") { dirName = abbreviateFishStyle(dirName); } return { text: dirName, bgColor: colors.modeBg, fgColor: colors.modeFg, }; } renderGit( gitInfo: GitInfo, colors: PowerlineColors, config?: GitSegmentConfig, ): SegmentData | null { if (!gitInfo) return null; const parts: string[] = []; if (config?.showRepoName && gitInfo.repoName) { parts.push(gitInfo.repoName); if (gitInfo.isWorktree) { parts.push(this.symbols.git_worktree); } } if (config?.showOperation && gitInfo.operation) { parts.push(`[${gitInfo.operation}]`); } const showBranchIcon = shouldShowIcon( this.config.display?.showIcons, config?.showIcon, ); parts.push( showBranchIcon ? `${this.symbols.branch} ${gitInfo.branch}` : gitInfo.branch, ); if (config?.showTag && gitInfo.tag) { parts.push(`${this.symbols.git_tag} ${gitInfo.tag}`); } if (config?.showSha && gitInfo.sha) { parts.push(`${this.symbols.git_sha} ${gitInfo.sha}`); } if (config?.showAheadBehind !== false) { if (gitInfo.ahead > 0 && gitInfo.behind > 0) { parts.push( `${this.symbols.git_ahead}${gitInfo.ahead}${this.symbols.git_behind}${gitInfo.behind}`, ); } else if (gitInfo.ahead > 0) { parts.push(`${this.symbols.git_ahead}${gitInfo.ahead}`); } else if (gitInfo.behind > 0) { parts.push(`${this.symbols.git_behind}${gitInfo.behind}`); } } if (config?.showWorkingTree) { const counts: string[] = []; if (gitInfo.staged && gitInfo.staged > 0) counts.push(`+${gitInfo.staged}`); if (gitInfo.unstaged && gitInfo.unstaged > 0) counts.push(`~${gitInfo.unstaged}`); if (gitInfo.untracked && gitInfo.untracked > 0) counts.push(`?${gitInfo.untracked}`); if (gitInfo.conflicts && gitInfo.conflicts > 0) counts.push(`!${gitInfo.conflicts}`); if (counts.length > 0) { parts.push(`(${counts.join(" ")})`); } } if (config?.showUpstream && gitInfo.upstream) { parts.push(`${this.symbols.git_upstream}${gitInfo.upstream}`); } if ( config?.showStashCount && gitInfo.stashCount && gitInfo.stashCount > 0 ) { parts.push(`${this.symbols.git_stash} ${gitInfo.stashCount}`); } if (config?.showTimeSinceCommit && gitInfo.timeSinceCommit !== undefined) { const time = formatTimeSince(gitInfo.timeSinceCommit); parts.push(`${this.symbols.git_time} ${time}`); } let gitStatusIcon = this.symbols.git_clean; if (gitInfo.status === "conflicts") { gitStatusIcon = this.symbols.git_conflicts; } else if (gitInfo.status === "dirty") { gitStatusIcon = this.symbols.git_dirty; } parts.push(gitStatusIcon); return { text: parts.join(" "), bgColor: colors.gitBg, fgColor: colors.gitFg, }; } renderModel( hookData: ClaudeHookData, colors: PowerlineColors, config?: SegmentConfig, ): SegmentData { const rawName = hookData.model?.display_name || "Claude"; const modelName = formatModelName(rawName); return { text: `${this.leadingIcon(this.symbols.model, config)}${modelName}`, bgColor: colors.modelBg, fgColor: colors.modelFg, }; } renderSession( usageInfo: UsageInfo, colors: PowerlineColors, config?: UsageSegmentConfig, ): SegmentData | null { const type = config?.type || "cost"; const costSource = config?.costSource; const sessionBudget = this.config.budget?.session; const getCost = () => { if (costSource === "calculated") return usageInfo.session.calculatedCost; if (costSource === "official") return usageInfo.session.officialCost; return usageInfo.session.cost; }; const formattedUsage = this.formatUsageWithBudget( getCost(), usageInfo.session.tokens, usageInfo.session.tokenBreakdown, type, sessionBudget, ); if (formattedUsage === null) return null; const text = `${this.leadingIcon(this.symbols.session_cost, config)}${formattedUsage}`; return { text, bgColor: colors.sessionBg, fgColor: colors.sessionFg, }; } renderSessionId( sessionId: string, colors: PowerlineColors, config?: SessionIdSegmentConfig, ): SegmentData { const showLabel = config?.showIdLabel !== false; const text = showLabel ? `${this.leadingIcon(this.symbols.session_id, config)}${sessionId}` : sessionId; return { text, bgColor: colors.sessionBg, fgColor: colors.sessionFg, }; } renderTmux( sessionId: string | null, colors: PowerlineColors, ): SegmentData | null { if (!sessionId) { return { text: `tmux:none`, bgColor: colors.tmuxBg, fgColor: colors.tmuxFg, }; } return { text: `tmux:${sessionId}`, bgColor: colors.tmuxBg, fgColor: colors.tmuxFg, }; } renderContext( contextInfo: ContextInfo | null, colors: PowerlineColors, config?: ContextSegmentConfig, ): SegmentData | null { const barLength = 10; const style = config?.displayStyle ?? "text"; const defaultMode = style === "text" ? "remaining" : "used"; const mode = config?.percentageMode ?? defaultMode; const barStyleDef = this.resolveBarStyleDef(style); const emptyPct = mode === "remaining" ? "100%" : "0%"; if (!contextInfo) { if (barStyleDef) { const emptyBar = barStyleDef.empty.repeat(barLength); return { text: `${emptyBar} ${emptyPct}`, bgColor: colors.contextBg, fgColor: colors.contextFg, }; } return { text: `${this.leadingIcon(this.symbols.context_time, config)}0 (${emptyPct})`, bgColor: colors.contextBg, fgColor: colors.contextFg, }; } let bgColor = colors.contextBg; let fgColor = colors.contextFg; let bold = colors.contextBold; if (contextInfo.contextLeftPercentage <= 20) { bgColor = colors.contextCriticalBg; fgColor = colors.contextCriticalFg; bold = colors.contextCriticalBold; } else if (contextInfo.contextLeftPercentage <= 40) { bgColor = colors.contextWarningBg; fgColor = colors.contextWarningFg; bold = colors.contextWarningBold; } const pct = mode === "remaining" ? contextInfo.contextLeftPercentage : contextInfo.usablePercentage; const filledCount = Math.round( (contextInfo.usablePercentage / 100) * barLength, ); const emptyCount = barLength - filledCount; if (barStyleDef) { const bar = this.buildBar( barStyleDef, filledCount, emptyCount, barLength, ); const text = config?.showPercentageOnly ? `${bar} ${pct}%` : `${bar} ${contextInfo.totalTokens.toLocaleString()} (${pct}%)`; return { text, bgColor, fgColor, bold }; } const iconPrefix = this.leadingIcon(this.symbols.context_time, config); const text = config?.showPercentageOnly ? `${iconPrefix}${pct}%` : `${iconPrefix}${contextInfo.totalTokens.toLocaleString()} (${pct}%)`; return { text, bgColor, fgColor, bold }; } private buildBar( s: BarStyleDef, filledCount: number, emptyCount: number, barLength: number, ): string { if (s.marker) { const pos = Math.min(filledCount, barLength - 1); return ( s.filled.repeat(pos) + s.marker + s.empty.repeat(barLength - pos - 1) ); } if (s.cap) { if (filledCount === 0) { return s.cap + s.empty.repeat(barLength - 1); } if (filledCount >= barLength) { return s.filled.repeat(barLength); } return ( s.filled.repeat(filledCount - 1) + s.cap + s.empty.repeat(emptyCount) ); } return s.filled.repeat(filledCount) + s.empty.repeat(emptyCount); } private resolveBarStyleDef(style: string): BarStyleDef | null { return style === "bar" ? { filled: this.symbols.bar_filled, empty: this.symbols.bar_empty } : (BAR_STYLES[style] ?? null); } private formatPercentageWithBar( pct: number, displayStyle?: BarDisplayStyle, timeStr?: string | null, ): string { const style = displayStyle ?? "text"; const barStyleDef = this.resolveBarStyleDef(style); const barLength = 10; if (barStyleDef) { const filledCount = Math.round((pct / 100) * barLength); const emptyCount = barLength - filledCount; const bar = this.buildBar( barStyleDef, filledCount, emptyCount, barLength, ); return timeStr ? `${bar} ${pct}% (${timeStr})` : `${bar} ${pct}%`; } return timeStr ? `${pct}% (${timeStr})` : `${pct}%`; } renderMetrics( metricsInfo: MetricsInfo | null, colors: PowerlineColors, config?: MetricsSegmentConfig, ): SegmentData | null { if (!metricsInfo) { return { text: `${this.symbols.metrics_response} new`, bgColor: colors.metricsBg, fgColor: colors.metricsFg, }; } const parts: string[] = []; if (config?.showLastResponseTime && metricsInfo.lastResponseTime !== null) { const lastResponseTime = metricsInfo.lastResponseTime < 60 ? `${metricsInfo.lastResponseTime.toFixed(1)}s` : `${(metricsInfo.lastResponseTime / 60).toFixed(1)}m`; parts.push(`${this.symbols.metrics_last_response} ${lastResponseTime}`); } if ( config?.showResponseTime !== false && metricsInfo.responseTime !== null ) { const responseTime = metricsInfo.responseTime < 60 ? `${metricsInfo.responseTime.toFixed(1)}s` : `${(metricsInfo.responseTime / 60).toFixed(1)}m`; parts.push(`${this.symbols.metrics_response} ${responseTime}`); } if ( config?.showDuration !== false && metricsInfo.sessionDuration !== null ) { const duration = formatDuration(metricsInfo.sessionDuration); parts.push(`${this.symbols.metrics_duration} ${duration}`); } if ( config?.showMessageCount !== false && metricsInfo.messageCount !== null ) { parts.push( `${this.symbols.metrics_messages} ${metricsInfo.messageCount}`, ); } if ( config?.showLinesAdded !== false && metricsInfo.linesAdded !== null && metricsInfo.linesAdded > 0 ) { parts.push( `${this.symbols.metrics_lines_added} ${metricsInfo.linesAdded}`, ); } if ( config?.showLinesRemoved !== false && metricsInfo.linesRemoved !== null && metricsInfo.linesRemoved > 0 ) { parts.push( `${this.symbols.metrics_lines_removed} ${metricsInfo.linesRemoved}`, ); } if (parts.length === 0) { return { text: `${this.symbols.metrics_response} active`, bgColor: colors.metricsBg, fgColor: colors.metricsFg, }; } return { text: parts.join(" "), bgColor: colors.metricsBg, fgColor: colors.metricsFg, }; } renderBlock( blockInfo: BlockInfo, colors: PowerlineColors, config?: BlockSegmentConfig, ): SegmentData { const pct = Math.round(blockInfo.nativeUtilization); const timeStr = formatLongTimeRemaining(blockInfo.timeRemaining); const blockBudget = this.config.budget?.block; const warningThreshold = blockBudget?.warningThreshold ?? 80; let bgColor = colors.blockBg; let fgColor = colors.blockFg; let bold = colors.blockBold; if (pct >= warningThreshold) { bgColor = colors.contextCriticalBg; fgColor = colors.contextCriticalFg; bold = colors.contextCriticalBold; } else if (pct >= 50) { bgColor = colors.contextWarningBg; fgColor = colors.contextWarningFg; bold = colors.contextWarningBold; } return { text: `${this.leadingIcon(this.symbols.block_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`, bgColor, fgColor, bold, }; } renderWeekly( hookData: ClaudeHookData, colors: PowerlineColors, config?: WeeklySegmentConfig, ): SegmentData | null { const sevenDay = hookData.rate_limits?.seven_day; if (!sevenDay) return null; const pct = Math.round(sevenDay.used_percentage); const timeStr = formatLongTimeRemaining( minutesUntilReset(sevenDay.resets_at), ); let bgColor = colors.weeklyBg; let fgColor = colors.weeklyFg; let bold = colors.weeklyBold; if (pct >= 80) { bgColor = colors.contextCriticalBg; fgColor = colors.contextCriticalFg; bold = colors.contextCriticalBold; } else if (pct >= 50) { bgColor = colors.contextWarningBg; fgColor = colors.contextWarningFg; bold = colors.contextWarningBold; } return { text: `${this.leadingIcon(this.symbols.weekly_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`, bgColor, fgColor, bold, }; } renderToday( todayInfo: TodayInfo, colors: PowerlineColors, configOrType?: TodaySegmentConfig | string, ): SegmentData | null { const config: TodaySegmentConfig | undefined = typeof configOrType === "string" ? ({ enabled: true, type: configOrType } as TodaySegmentConfig) : configOrType; const type = config?.type ?? "cost"; const todayBudget = this.config.budget?.today; const formattedUsage = this.formatUsageWithBudget( todayInfo.cost, todayInfo.tokens, todayInfo.tokenBreakdown, type, todayBudget, ); if (formattedUsage === null) return null; const text = `${this.leadingIcon(this.symbols.today_cost, config)}${formattedUsage}`; return { text, bgColor: colors.todayBg, fgColor: colors.todayFg, }; } private getDisplayDirectoryName( currentDir: string, projectDir?: string, ): string { if (currentDir.startsWith("~")) { return currentDir; } if (projectDir && projectDir !== currentDir) { if (currentDir.startsWith(projectDir)) { const relativePath = currentDir.slice(projectDir.length + 1); return relativePath || projectDir.split(/[\\/]/).pop() || "project"; } } return currentDir; } private formatUsageDisplay( cost: number | null, tokens: number | null, tokenBreakdown: TokenBreakdown | null, type: string, ): string { switch (type) { case "cost": return formatCost(cost); case "tokens": return formatTokens(tokens); case "both": return `${formatCost(cost)} (${formatTokens(tokens)})`; case "breakdown": return formatTokenBreakdown(tokenBreakdown); default: return formatCost(cost); } } private formatUsageWithBudget( cost: number | null, tokens: number | null, tokenBreakdown: TokenBreakdown | null, type: string, budget?: BudgetItemConfig, ): string | null { const state = resolveBudgetDisplay(cost, tokens, budget); if (state.suppressAll) return null; if (!state.showBase) return state.percentText; const baseDisplay = this.formatUsageDisplay( cost, tokens, tokenBreakdown, type, ); return state.percentText ? `${baseDisplay} ${state.percentText}` : baseDisplay; } renderVersion( hookData: ClaudeHookData, colors: PowerlineColors, config?: VersionSegmentConfig, ): SegmentData | null { if (!hookData.version) { return null; } return { text: `${this.leadingIcon(this.symbols.version, config)}v${hookData.version}`, bgColor: colors.versionBg, fgColor: colors.versionFg, }; } renderEnv( colors: PowerlineColors, config: EnvSegmentConfig, ): SegmentData | null { const value = globalThis.process?.env?.[config.variable]; if (!value) return null; const prefix = config.prefix ?? config.variable; const iconPrefix = this.leadingIcon(this.symbols.env, config); const text = prefix ? `${iconPrefix}${prefix}: ${value}` : `${iconPrefix}${value}`; return { text, bgColor: colors.envBg, fgColor: colors.envFg }; } renderAgent( hookData: ClaudeHookData, colors: PowerlineColors, config?: AgentSegmentConfig, ): SegmentData | null { const rawName = hookData.agent?.name; if (typeof rawName !== "string") return null; const name = rawName.trim(); if (!name) return null; const iconPrefix = this.leadingIcon(this.symbols.agent, config); const body = config?.showLabel ? `agent: ${name}` : name; return { text: `${iconPrefix}${body}`, bgColor: colors.agentBg, fgColor: colors.agentFg, }; } renderThinking( hookData: ClaudeHookData, colors: PowerlineColors, config?: ThinkingSegmentConfig, ): SegmentData | null { const showEnabled = config?.showEnabled ?? true; const showEffort = config?.showEffort ?? true; if (!showEnabled && !showEffort) return null; const enabled = showEnabled ? getThinkingEnabled(hookData) : null; const level = showEffort ? getEffortLevel(hookData) : null; const parts: string[] = []; if (enabled !== null) parts.push(enabled ? "On" : "Off"); if (level) parts.push(level); if (parts.length === 0) return null; const iconPrefix = this.leadingIcon(this.symbols.thinking, config); return { text: `${iconPrefix}${parts.join(" · ")}`, bgColor: colors.thinkingBg, fgColor: colors.thinkingFg, }; } renderCacheTimer( info: CacheTimerInfo, colors: PowerlineColors, config?: CacheTimerSegmentConfig, ): SegmentData { const e = info.elapsedSeconds; const iconPrefix = this.leadingIcon(this.symbols.cache_timer, config); const text = `${iconPrefix}${formatCacheTimerElapsed(e)}`; let bgColor = colors.cacheTimerBg; let fgColor = colors.cacheTimerFg; let bold = colors.cacheTimerBold; if (e >= 300) { bgColor = colors.contextCriticalBg; fgColor = colors.contextCriticalFg; bold = colors.contextCriticalBold; } else if (e >= 180) { bgColor = colors.contextWarningBg; fgColor = colors.contextWarningFg; bold = colors.contextWarningBold; } return { text, bgColor, fgColor, bold }; } }