UNPKG

@owloops/claude-powerline

Version:

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

519 lines (451 loc) 13.9 kB
import { readdir, readFile, stat } from "node:fs/promises"; import { existsSync, createReadStream } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { createInterface } from "node:readline"; import { debug } from "./logger"; export interface ClaudeHookData { hook_event_name: string; session_id: string; transcript_path: string; cwd: string; model: { id: string; display_name: string; }; workspace: { current_dir: string; project_dir: string; }; version?: string; output_style?: { name: string; }; cost?: { total_cost_usd: number; total_duration_ms: number; total_api_duration_ms: number; total_lines_added: number; total_lines_removed: number; }; context_window?: { total_input_tokens: number; total_output_tokens: number; context_window_size: number; used_percentage?: number | null; remaining_percentage?: number | null; current_usage?: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number; }; }; exceeds_200k_tokens?: boolean; rate_limits?: { five_hour?: { used_percentage: number; resets_at: number; }; seven_day?: { used_percentage: number; resets_at: number; }; }; worktree?: { name: string; path: string; branch?: string; original_cwd: string; original_branch?: string; }; agent?: { name: string; }; effort?: { level?: string; }; thinking?: { enabled?: boolean; }; } export function getEffortLevel(hookData: ClaudeHookData): string | null { const level = hookData.effort?.level; if (typeof level !== "string") return null; const trimmed = level.trim(); return trimmed ? trimmed : null; } export function getThinkingEnabled(hookData: ClaudeHookData): boolean | null { const enabled = hookData.thinking?.enabled; if (typeof enabled !== "boolean") return null; return enabled; } export function getClaudePaths(): string[] { const paths: string[] = []; const envPath = process.env.CLAUDE_CONFIG_DIR; if (envPath) { envPath.split(",").forEach((path) => { const trimmedPath = path.trim(); if (existsSync(trimmedPath)) { paths.push(trimmedPath); } }); } if (paths.length === 0) { const homeDir = homedir(); const configPath = join(homeDir, ".config", "claude"); const claudePath = join(homeDir, ".claude"); if (existsSync(configPath)) { paths.push(configPath); } if (existsSync(claudePath)) { paths.push(claudePath); } } return paths; } export async function findProjectPaths( claudePaths: string[], ): Promise<string[]> { const projectPaths: string[] = []; for (const claudePath of claudePaths) { const projectsDir = join(claudePath, "projects"); if (existsSync(projectsDir)) { try { const entries = await readdir(projectsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const projectPath = join(projectsDir, entry.name); projectPaths.push(projectPath); } } } catch (error) { debug(`Failed to read projects directory ${projectsDir}:`, error); } } } return projectPaths; } export async function findTranscriptFile( sessionId: string, ): Promise<string | null> { const claudePaths = getClaudePaths(); const projectPaths = await findProjectPaths(claudePaths); for (const projectPath of projectPaths) { const transcriptPath = join(projectPath, `${sessionId}.jsonl`); if (existsSync(transcriptPath)) { return transcriptPath; } } return null; } export async function findAgentTranscripts( sessionId: string, projectPath: string, ): Promise<string[]> { const agentFiles: string[] = []; const subagentsDir = join(projectPath, sessionId, "subagents"); try { const files = await readdir(subagentsDir); const agentFileNames = files.filter( (f) => f.startsWith("agent-") && f.endsWith(".jsonl"), ); for (const fileName of agentFileNames) { const filePath = join(subagentsDir, fileName); try { const content = await readFile(filePath, "utf-8"); const firstLine = content.split("\n")[0]; if (firstLine) { const parsed = JSON.parse(firstLine); if (parsed.sessionId === sessionId) { agentFiles.push(filePath); } } } catch { debug(`Failed to check agent file ${filePath}`); } } } catch (error) { debug(`Failed to read subagents directory ${subagentsDir}:`, error); } return agentFiles; } export async function getEarliestTimestamp( filePath: string, ): Promise<Date | null> { try { const content = await readFile(filePath, "utf-8"); const lines = content.trim().split("\n"); let earliestDate: Date | null = null; for (const line of lines) { if (!line.trim()) continue; try { const json = JSON.parse(line); if (json.timestamp && typeof json.timestamp === "string") { const date = new Date(json.timestamp); if (!isNaN(date.getTime())) { if (earliestDate === null || date < earliestDate) { earliestDate = date; } } } } catch { continue; } } return earliestDate; } catch (error) { debug(`Failed to get earliest timestamp for ${filePath}:`, error); return null; } } export async function sortFilesByTimestamp( files: string[], oldestFirst = true, ): Promise<string[]> { const filesWithTimestamps = await Promise.all( files.map(async (file) => ({ file, timestamp: await getEarliestTimestamp(file), })), ); return filesWithTimestamps .sort((a, b) => { if (a.timestamp === null && b.timestamp === null) return 0; if (a.timestamp === null) return 1; if (b.timestamp === null) return -1; const sortOrder = oldestFirst ? 1 : -1; return sortOrder * (a.timestamp.getTime() - b.timestamp.getTime()); }) .map((item) => item.file); } export async function getFileModificationDate( filePath: string, ): Promise<Date | null> { try { const stats = await stat(filePath); return stats.mtime; } catch { return null; } } export interface ParsedEntry { timestamp: Date; message?: { id?: string; usage?: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }; model?: string; }; costUSD?: number; isSidechain?: boolean; raw: Record<string, unknown>; } export function createUniqueHash(entry: ParsedEntry): string | null { const messageId = entry.message?.id || (typeof entry.raw.message === "object" && entry.raw.message !== null && "id" in entry.raw.message ? (entry.raw.message.id as string) : undefined); const requestId = "requestId" in entry.raw ? (entry.raw.requestId as string) : undefined; if (!messageId || !requestId) { return null; } return `${messageId}:${requestId}`; } const STREAMING_THRESHOLD_BYTES = 1024 * 1024; export async function parseJsonlFile(filePath: string): Promise<ParsedEntry[]> { try { const stats = await stat(filePath); const fileSizeBytes = stats.size; let entries: ParsedEntry[]; if (fileSizeBytes > STREAMING_THRESHOLD_BYTES) { debug( `Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`, ); entries = await parseJsonlFileStreaming(filePath); } else { entries = await parseJsonlFileInMemory(filePath); } debug(`Parsed ${entries.length} entries from ${filePath}`); return entries; } catch (error) { debug(`Failed to read file ${filePath}:`, error); return []; } } async function parseJsonlFileInMemory( filePath: string, ): Promise<ParsedEntry[]> { const content = await readFile(filePath, "utf-8"); const lines = content .trim() .split("\n") .filter((line) => line.trim()); const entries: ParsedEntry[] = []; for (const line of lines) { try { const raw = JSON.parse(line); if (!raw.timestamp) continue; const entry: ParsedEntry = { timestamp: new Date(raw.timestamp), message: raw.message, costUSD: typeof raw.costUSD === "number" ? raw.costUSD : undefined, isSidechain: raw.isSidechain === true, raw, }; entries.push(entry); } catch (parseError) { debug(`Failed to parse JSONL line: ${parseError}`); continue; } } return entries; } async function parseJsonlFileStreaming( filePath: string, ): Promise<ParsedEntry[]> { return new Promise((resolve, reject) => { const entries: ParsedEntry[] = []; const fileStream = createReadStream(filePath, { encoding: "utf8" }); const rl = createInterface({ input: fileStream, crlfDelay: Infinity, }); rl.on("line", (line) => { const trimmedLine = line.trim(); if (!trimmedLine) return; try { const raw = JSON.parse(trimmedLine); if (!raw.timestamp) return; const entry: ParsedEntry = { timestamp: new Date(raw.timestamp), message: raw.message, costUSD: typeof raw.costUSD === "number" ? raw.costUSD : undefined, isSidechain: raw.isSidechain === true, raw, }; entries.push(entry); } catch (parseError) { debug(`Failed to parse JSONL line: ${parseError}`); } }); rl.on("close", () => { resolve(entries); }); rl.on("error", (error) => { debug(`Streaming parser error for ${filePath}:`, error); reject(error); }); fileStream.on("error", (error) => { debug(`File stream error for ${filePath}:`, error); reject(error); }); }); } interface FileStat { filePath: string; mtime: Date; } async function statFile(filePath: string): Promise<FileStat | null> { try { const mtime = await getFileModificationDate(filePath); return mtime ? { filePath, mtime } : null; } catch { return null; } } async function collectProjectFiles( projectPath: string, fileFilter?: (filePath: string, modTime: Date) => boolean, ): Promise<FileStat[]> { try { const entries = await readdir(projectPath, { withFileTypes: true }); const sessionFiles = entries .filter((e) => !e.isDirectory() && e.name.endsWith(".jsonl")) .map((e) => statFile(join(projectPath, e.name))); const subagentFiles = entries .filter((e) => e.isDirectory()) .map(async (e) => { const subagentsDir = join(projectPath, e.name, "subagents"); try { const files = await readdir(subagentsDir); return files .filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl")) .map((f) => statFile(join(subagentsDir, f))); } catch { return []; } }); const [sessionResults, subagentResults] = await Promise.all([ Promise.all(sessionFiles), Promise.all(subagentFiles).then((nested) => Promise.all(nested.flat())), ]); return [...sessionResults, ...subagentResults].filter( (s): s is FileStat => s !== null && (!fileFilter || fileFilter(s.filePath, s.mtime)), ); } catch (dirError) { debug(`Failed to read project directory ${projectPath}:`, dirError); return []; } } /** * Loads entries from Claude projects with deterministic deduplication. * @param timeFilter Optional filter to apply based on timestamp * @param fileFilter Optional filter to apply based on file path and modification time * @param sortFiles Whether to sort files by modification time * @returns Deduplicated entries sorted by timestamp * @note Sorts entries by timestamp before deduplication to ensure consistent * duplicate selection. Otherwise, parallel file loading causes race conditions * where different duplicates are kept on each run, leading to flickering values. */ export async function loadEntriesFromProjects( timeFilter?: (entry: ParsedEntry) => boolean, fileFilter?: (filePath: string, modTime: Date) => boolean, sortFiles = false, ): Promise<ParsedEntry[]> { const claudePaths = getClaudePaths(); const projectPaths = await findProjectPaths(claudePaths); const processedHashes = new Set<string>(); const allFilesPromises = projectPaths.map((projectPath) => collectProjectFiles(projectPath, fileFilter), ); const allFileResults = await Promise.all(allFilesPromises); const allFilesWithMtime = allFileResults .flat() .filter((file): file is { filePath: string; mtime: Date } => file !== null); if (sortFiles) { allFilesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); } const allFiles = allFilesWithMtime.map((file) => file.filePath); const entries: ParsedEntry[] = []; const filePromises = allFiles.map(async (filePath) => { const fileEntries = await parseJsonlFile(filePath); return fileEntries.filter((entry) => !timeFilter || timeFilter(entry)); }); const fileResults = await Promise.all(filePromises); for (const fileEntries of fileResults) { entries.push(...fileEntries); } entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); const deduplicatedEntries: ParsedEntry[] = []; for (const entry of entries) { const uniqueHash = createUniqueHash(entry); if (uniqueHash && processedHashes.has(uniqueHash)) { continue; } if (uniqueHash) { processedHashes.add(uniqueHash); } deduplicatedEntries.push(entry); } return deduplicatedEntries; }