@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
text/typescript
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;
}