UNPKG

@owloops/claude-powerline

Version:

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

472 lines (413 loc) 13.2 kB
import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { DEFAULT_CONFIG } from "./defaults"; import type { ColorTheme } from "../themes"; import type { TuiGridConfig } from "../tui/types"; import { isValidSegmentRef } from "../tui/types"; import { BOX_PRESETS } from "../utils/constants"; import type { SegmentConfig, DirectorySegmentConfig, GitSegmentConfig, UsageSegmentConfig, TmuxSegmentConfig, ContextSegmentConfig, MetricsSegmentConfig, BlockSegmentConfig, TodaySegmentConfig, VersionSegmentConfig, SessionIdSegmentConfig, EnvSegmentConfig, WeeklySegmentConfig, AgentSegmentConfig, ThinkingSegmentConfig, CacheTimerSegmentConfig, } from "../segments/renderer"; export interface LineConfig { segments: { directory?: DirectorySegmentConfig; git?: GitSegmentConfig; model?: SegmentConfig; session?: UsageSegmentConfig; block?: BlockSegmentConfig; today?: TodaySegmentConfig; tmux?: TmuxSegmentConfig; context?: ContextSegmentConfig; metrics?: MetricsSegmentConfig; version?: VersionSegmentConfig; sessionId?: SessionIdSegmentConfig; env?: EnvSegmentConfig; weekly?: WeeklySegmentConfig; agent?: AgentSegmentConfig; thinking?: ThinkingSegmentConfig; cacheTimer?: CacheTimerSegmentConfig; }; } export interface DisplayConfig { lines: LineConfig[]; style?: "minimal" | "powerline" | "capsule" | "tui"; charset?: "unicode" | "text"; colorCompatibility?: "auto" | "ansi" | "ansi256" | "truecolor"; autoWrap?: boolean; padding?: number; showIcons?: boolean; tui?: TuiGridConfig; } export interface BudgetItemConfig { amount?: number; warningThreshold?: number; type?: "cost" | "tokens"; showPercentage?: boolean; showValue?: boolean; } export interface BudgetConfig { session?: BudgetItemConfig; today?: BudgetItemConfig; block?: BudgetItemConfig; } export interface PowerlineConfig { theme: | "light" | "dark" | "nord" | "tokyo-night" | "rose-pine" | "gruvbox" | "custom"; display: DisplayConfig; colors?: { custom: ColorTheme; }; budget?: BudgetConfig; modelContextLimits?: Record<string, number>; } function isValidTheme(theme: string): theme is PowerlineConfig["theme"] { return [ "light", "dark", "nord", "tokyo-night", "rose-pine", "gruvbox", "custom", ].includes(theme); } function isValidStyle( style: string, ): style is "minimal" | "powerline" | "capsule" | "tui" { return ( style === "minimal" || style === "powerline" || style === "capsule" || style === "tui" ); } function isValidCharset(charset: string): charset is "unicode" | "text" { return charset === "unicode" || charset === "text"; } function getArgValue(args: string[], argName: string): string | undefined { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === argName && i + 1 < args.length) { return args[i + 1]; } if (arg?.startsWith(`${argName}=`)) { return arg.split("=")[1]; } } return undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function deepMerge<T extends Record<string, any>>( target: T, source: Partial<T>, ): T { const result = { ...target }; for (const key in source) { const sourceValue = source[key]; if (sourceValue !== undefined) { if ( typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue) ) { const targetValue = result[key] || {}; result[key] = deepMerge( // eslint-disable-next-line @typescript-eslint/no-explicit-any targetValue as Record<string, any>, // eslint-disable-next-line @typescript-eslint/no-explicit-any sourceValue as Record<string, any>, ) as T[Extract<keyof T, string>]; } else { result[key] = sourceValue as T[Extract<keyof T, string>]; } } } return result; } function findConfigFile( customPath?: string, projectDir?: string, ): string | null { if (customPath) { return fs.existsSync(customPath) ? customPath : null; } const locations = [ ...(projectDir ? [path.join(projectDir, ".claude-powerline.json")] : []), path.join(process.cwd(), ".claude-powerline.json"), path.join(os.homedir(), ".claude", "claude-powerline.json"), path.join(os.homedir(), ".config", "claude-powerline", "config.json"), ]; return locations.find(fs.existsSync) || null; } function loadConfigFile(filePath: string): Partial<PowerlineConfig> { try { const content = fs.readFileSync(filePath, "utf-8"); return JSON.parse(content); } catch (error) { throw new Error( `Failed to load config file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); } } function loadEnvConfig(): Partial<PowerlineConfig> { const config: Partial<PowerlineConfig> = {}; const display: Partial<DisplayConfig> = {}; const theme = process.env.CLAUDE_POWERLINE_THEME; if (theme && isValidTheme(theme)) { config.theme = theme; } const style = process.env.CLAUDE_POWERLINE_STYLE; if (style) { if (isValidStyle(style)) { display.style = style; } else { console.warn( `Invalid display style '${style}' from environment variable, falling back to 'minimal'`, ); display.style = "minimal"; } } if (Object.keys(display).length > 0) { config.display = display as DisplayConfig; } return config; } function getConfigPathFromEnv(): string | undefined { return process.env.CLAUDE_POWERLINE_CONFIG; } function parseCLIOverrides(args: string[]): Partial<PowerlineConfig> { const config: Partial<PowerlineConfig> = {}; const display: Partial<DisplayConfig> = {}; const theme = getArgValue(args, "--theme"); if (theme && isValidTheme(theme)) { config.theme = theme; } const style = getArgValue(args, "--style"); if (style) { if (isValidStyle(style)) { display.style = style; } else { console.warn( `Invalid display style '${style}' from CLI argument, falling back to 'minimal'`, ); display.style = "minimal"; } } const charset = getArgValue(args, "--charset"); if (charset) { if (isValidCharset(charset)) { display.charset = charset; } else { console.warn( `Invalid charset '${charset}' from CLI argument, falling back to 'unicode'`, ); display.charset = "unicode"; } } if (Object.keys(display).length > 0) { config.display = display as DisplayConfig; } return config; } function validateGridConfig(tui: TuiGridConfig): string | null { if (typeof tui.box === "string" && !BOX_PRESETS[tui.box]) { const valid = Object.keys(BOX_PRESETS).join(", "); return `unknown box preset "${tui.box}" (valid: ${valid})`; } if ( !tui.breakpoints || !Array.isArray(tui.breakpoints) || tui.breakpoints.length === 0 ) { return "grid config must have at least one breakpoint"; } const seenMinWidths = new Set<number>(); for (let bpIdx = 0; bpIdx < tui.breakpoints.length; bpIdx++) { const bp = tui.breakpoints[bpIdx]!; const prefix = `breakpoint[${bpIdx}]`; if (typeof bp.minWidth !== "number" || bp.minWidth < 0) { return `${prefix}: minWidth must be a non-negative number`; } if (seenMinWidths.has(bp.minWidth)) { return `${prefix}: duplicate minWidth ${bp.minWidth} (each breakpoint must have a unique minWidth)`; } seenMinWidths.add(bp.minWidth); if (!bp.areas || !Array.isArray(bp.areas) || bp.areas.length === 0) { return `${prefix}: areas must be a non-empty array of strings`; } if (!bp.columns || !Array.isArray(bp.columns) || bp.columns.length === 0) { return `${prefix}: columns must be a non-empty array`; } const colCount = bp.columns.length; // Validate column definitions for (const col of bp.columns) { if (typeof col !== "string") { return `${prefix}: column definition must be a string`; } if (!/^(\d+fr|\d+|auto)$/.test(col)) { return `${prefix}: invalid column definition "${col}" (use "auto", "Nfr", or a fixed integer)`; } } // Validate align array if (bp.align !== undefined) { if (!Array.isArray(bp.align)) { return `${prefix}: align must be an array`; } if (bp.align.length !== colCount) { return `${prefix}: align length (${bp.align.length}) must match columns length (${colCount})`; } for (const a of bp.align) { if (a !== "left" && a !== "center" && a !== "right") { return `${prefix}: invalid align value "${a}"`; } } } // Validate areas rows const seenSegments = new Set<string>(); for (let rowIdx = 0; rowIdx < bp.areas.length; rowIdx++) { const row = bp.areas[rowIdx]!; // Divider row if (row.trim() === "---") continue; const cells = row.trim().split(/\s+/); if (cells.length !== colCount) { return `${prefix}: row "${row}" has ${cells.length} cells but expected ${colCount} columns`; } // Check segment names and contiguity const templateNames = tui.segments ? new Set(Object.keys(tui.segments)) : new Set<string>(); let prevCell = ""; let spanName = ""; for (const cell of cells) { if (cell !== ".") { if (!isValidSegmentRef(cell) && !templateNames.has(cell)) { return `${prefix}: unknown segment name "${cell}"`; } // Check for non-contiguous spans if (cell === spanName) { // still in the same span, ok } else if (seenSegments.has(cell)) { return `${prefix}: segment "${cell}" appears on multiple rows`; } } // Track span contiguity if (cell !== prevCell) { if (spanName && prevCell !== spanName && prevCell !== ".") { // finished a span } spanName = cell; } prevCell = cell; } // Check for non-contiguous spans within this row const seen = new Map<string, number>(); for (let i = 0; i < cells.length; i++) { const cell = cells[i]!; if (cell === "." || cell === "---") continue; const lastIdx = seen.get(cell); if (lastIdx !== undefined && lastIdx !== i - 1) { return `${prefix}: segment "${cell}" has non-contiguous span in row "${row}"`; } seen.set(cell, i); } // Record segments from this row for (const cell of cells) { if (cell !== "." && cell !== "---") { seenSegments.add(cell); } } } } if (tui.segments) { for (const [segRef, tmpl] of Object.entries(tui.segments)) { if (!tmpl.items || !Array.isArray(tmpl.items)) { return `segments["${segRef}"]: items must be an array`; } if ( tmpl.justify !== undefined && tmpl.justify !== "start" && tmpl.justify !== "between" ) { return `segments["${segRef}"]: invalid justify value "${tmpl.justify}" (use "start" or "between")`; } } } return null; // valid } export function loadConfig( args: string[] = process.argv, projectDir?: string, ): PowerlineConfig { let config: PowerlineConfig = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); const rawConfigPath = getArgValue(args, "--config") || getConfigPathFromEnv(); const configPath = rawConfigPath?.startsWith("~") ? rawConfigPath.replace("~", os.homedir()) : rawConfigPath; const configFile = findConfigFile(configPath, projectDir); if (configFile) { try { const fileConfig = loadConfigFile(configFile); config = deepMerge(config, fileConfig); } catch (err) { console.warn( `Warning: ${err instanceof Error ? err.message : String(err)}`, ); } } if (config.display?.style && !isValidStyle(config.display.style)) { console.warn( `Invalid display style '${config.display.style}' in config file, falling back to 'minimal'`, ); config.display.style = "minimal"; } if (config.display?.charset && !isValidCharset(config.display.charset)) { console.warn( `Invalid charset '${config.display.charset}' in config file, falling back to 'unicode'`, ); config.display.charset = "unicode"; } if (config.theme && !isValidTheme(config.theme)) { console.warn( `Invalid theme '${config.theme}' in config file, falling back to 'dark'`, ); config.theme = "dark"; } const envConfig = loadEnvConfig(); config = deepMerge(config, envConfig); const cliOverrides = parseCLIOverrides(args); config = deepMerge(config, cliOverrides); // Validate grid config if present if (config.display?.tui) { const error = validateGridConfig(config.display.tui); if (error) { process.stderr.write( `Warning: invalid grid config: ${error}. Falling back to hardcoded layout.\n`, ); delete config.display.tui; } } return config; } export const loadConfigFromCLI = loadConfig;