UNPKG

claude-status-bar

Version:

Powerline-style status bar for Claude Code CLI with i18n support (English/Korean)

1,709 lines (1,683 loc) 67.3 kB
#!/usr/bin/env node import { program } from 'commander'; import { z } from 'zod'; import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { render, useApp, useInput, Box, Text } from 'ink'; import { jsxs, jsx } from 'react/jsx-runtime'; import { exec, execSync } from 'child_process'; import i18next from 'i18next'; import { initReactI18next, useTranslation } from 'react-i18next'; import { Chalk } from 'chalk'; import { promisify } from 'util'; import { createHash } from 'crypto'; import React2, { useState } from 'react'; var ClaudeInputSchema = z.object({ session_id: z.string().optional().default(""), transcript_path: z.string().optional().default(""), model: z.object({ id: z.string().optional().default("unknown"), display_name: z.string().optional().default("Claude") }).optional().default({ id: "unknown", display_name: "Claude" }), workspace: z.object({ current_dir: z.string().optional().default(""), project_dir: z.string().optional().default("") }).optional().default({ current_dir: "", project_dir: "" }), cost: z.object({ // Claude Code 실제 필드명 total_cost_usd: z.number().optional(), total_duration_ms: z.number().optional(), total_api_duration_ms: z.number().optional(), // 실제 API 호출 시간 total_lines_added: z.number().optional(), total_lines_removed: z.number().optional(), // 이전 버전 호환성 api_cost: z.number().optional(), duration_ms: z.number().optional(), lines_added: z.number().optional(), lines_removed: z.number().optional() }).optional(), version: z.string().optional(), cwd: z.string().optional().default("") // Claude Code가 전달할 수 있는 추가 필드들 허용 }).passthrough(); var MODEL_MAX_TOKENS = { "claude-opus-4-5-20251101": 2e5, "claude-sonnet-4-5-20250929": 2e5, "claude-sonnet-4-20250514": 2e5, "claude-3-5-sonnet-20241022": 2e5, "claude-3-5-haiku-20241022": 2e5, "claude-3-opus-20240229": 2e5, "claude-3-sonnet-20240229": 2e5, "claude-3-haiku-20240307": 2e5 }; function getModelMaxTokens(modelId) { if (MODEL_MAX_TOKENS[modelId]) { return MODEL_MAX_TOKENS[modelId]; } for (const [key, value] of Object.entries(MODEL_MAX_TOKENS)) { if (modelId.includes(key) || key.includes(modelId)) { return value; } } return 2e5; } async function readStdin() { return new Promise((resolve, reject) => { let data = ""; if (process.stdin.isTTY) { resolve(""); return; } process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { data += chunk; }); process.stdin.on("end", () => { resolve(data.trim()); }); process.stdin.on("error", (error) => { reject(error); }); setTimeout(() => { if (data.length === 0) { resolve(""); } }, 5e3); }); } async function parseClaudeInput() { try { const raw = await readStdin(); if (!raw) { if (process.env.DEBUG_STATUSLINE) { console.error("[statusline] No stdin data received"); } return null; } if (process.env.DEBUG_STATUSLINE) { console.error("[statusline] Raw input:", raw.substring(0, 500)); } const parsed = JSON.parse(raw); if (process.env.DEBUG_STATUSLINE) { console.error("[statusline] Parsed keys:", Object.keys(parsed)); } try { const logPath = join(homedir(), ".claude", "statusline-debug.json"); writeFileSync(logPath, JSON.stringify(parsed, null, 2)); } catch { } const validated = ClaudeInputSchema.parse(parsed); return validated; } catch (error) { if (process.env.DEBUG_STATUSLINE) { console.error("[statusline] Parse error:", error); } return null; } } function createMockClaudeInput() { return { session_id: "mock-session-123", transcript_path: "", model: { id: "claude-sonnet-4-20250514", display_name: "Claude Sonnet 4" }, workspace: { current_dir: process.cwd(), project_dir: process.cwd() }, cost: { api_cost: 0.0523, duration_ms: 125e3 }, version: "1.0.0", cwd: process.cwd() }; } // src/themes/powerline-dark.ts var powerlineDark = { id: "powerline-dark", name: "Powerline Dark", symbols: { separator: "\uE0B0", // separatorThin: "\uE0B1", // branch: "\uE0A0", // readonly: "\uE0A2", // modified: "\u25CF" }, colors: { segments: { model: { bg: "#5c6bc0", fg: "#ffffff" }, git: { bg: "#ffffff", fg: "#37474f" }, tokens: { bg: "#42a5f5", fg: "#0d47a1" }, cost: { bg: "#ffa726", fg: "#e65100" }, session: { bg: "#78909c", fg: "#263238" }, cwd: { bg: "#5c6bc0", fg: "#ffffff" }, context: { bg: "#ab47bc", fg: "#ffffff" }, todo: { bg: "#26a69a", fg: "#004d40" }, memory: { bg: "#ec407a", fg: "#ffffff" }, files: { bg: "#66bb6a", fg: "#1b5e20" } }, status: { normal: "#66bb6a", warning: "#ffa726", error: "#ef5350", success: "#66bb6a" }, progress: { filled: "#42a5f5", empty: "#424242", warning: "#ffa726", critical: "#ef5350" } } }; // src/themes/powerline-light.ts var powerlineLight = { id: "powerline-light", name: "Powerline Light", symbols: { separator: "\uE0B0", // separatorThin: "\uE0B1", // branch: "\uE0A0", // readonly: "\uE0A2", // modified: "\u25CF" }, colors: { segments: { model: { bg: "#7986cb", fg: "#1a237e" }, git: { bg: "#ffffff", fg: "#37474f" }, tokens: { bg: "#64b5f6", fg: "#0d47a1" }, cost: { bg: "#ffb74d", fg: "#e65100" }, session: { bg: "#90a4ae", fg: "#263238" }, cwd: { bg: "#7986cb", fg: "#1a237e" }, context: { bg: "#ba68c8", fg: "#4a148c" }, todo: { bg: "#4db6ac", fg: "#004d40" }, memory: { bg: "#f48fb1", fg: "#880e4f" }, files: { bg: "#81c784", fg: "#1b5e20" } }, status: { normal: "#4caf50", warning: "#ff9800", error: "#f44336", success: "#4caf50" }, progress: { filled: "#2196f3", empty: "#bdbdbd", warning: "#ff9800", critical: "#f44336" } } }; // src/themes/minimal.ts var minimal = { id: "minimal", name: "Minimal (ASCII)", symbols: { separator: ">", separatorThin: "|", branch: "", readonly: "[RO]", modified: "*" }, colors: { segments: { model: { bg: "#6366f1", fg: "#ffffff" }, git: { bg: "#ffffff", fg: "#37474f" }, tokens: { bg: "#3b82f6", fg: "#ffffff" }, cost: { bg: "#f59e0b", fg: "#ffffff" }, session: { bg: "#6b7280", fg: "#ffffff" }, cwd: { bg: "#6366f1", fg: "#ffffff" }, context: { bg: "#a855f7", fg: "#ffffff" }, todo: { bg: "#14b8a6", fg: "#ffffff" }, memory: { bg: "#ec4899", fg: "#ffffff" }, files: { bg: "#22c55e", fg: "#ffffff" } }, status: { normal: "#22c55e", warning: "#f59e0b", error: "#ef4444", success: "#22c55e" }, progress: { filled: "#3b82f6", empty: "#374151", warning: "#f59e0b", critical: "#ef4444" } } }; // src/themes/index.ts var themes = { "powerline-dark": powerlineDark, "powerline-light": powerlineLight, "minimal": minimal }; function getTheme(themeId) { return themes[themeId] ?? powerlineDark; } function getAvailableThemes() { return Object.values(themes); } // src/widgets/registry.ts var WidgetRegistry = class { widgets = /* @__PURE__ */ new Map(); /** * 위젯 등록 */ register(widget) { if (this.widgets.has(widget.id)) { console.warn(`Widget "${widget.id}" is already registered. Overwriting.`); } this.widgets.set(widget.id, widget); } /** * ID로 위젯 가져오기 */ get(id) { return this.widgets.get(id); } /** * 모든 위젯 가져오기 */ getAll() { return Array.from(this.widgets.values()); } /** * 활성화된 위젯만 가져오기 (순서대로 정렬) */ getEnabled(widgetConfigs) { return this.getAll().filter((widget) => { const config2 = widgetConfigs[widget.id]; return config2 ? config2.enabled : widget.defaultEnabled; }).sort((a, b) => { const orderA = widgetConfigs[a.id]?.order ?? a.defaultOrder; const orderB = widgetConfigs[b.id]?.order ?? b.defaultOrder; return orderA - orderB; }); } /** * 위젯 개수 */ get size() { return this.widgets.size; } /** * 레지스트리 초기화 */ clear() { this.widgets.clear(); } }; var widgetRegistry = new WidgetRegistry(); // src/utils/format.ts function formatTokens(tokens) { if (tokens >= 1e6) { return `${(tokens / 1e6).toFixed(1)}M`; } if (tokens >= 1e3) { return `${(tokens / 1e3).toFixed(1)}K`; } return tokens.toString(); } function formatCost(cost) { if (cost < 0.01) { return `$${cost.toFixed(4)}`; } if (cost < 1) { return `$${cost.toFixed(3)}`; } return `$${cost.toFixed(2)}`; } function formatDuration(ms) { const seconds = Math.floor(ms / 1e3); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { const remainingMinutes = minutes % 60; return `${hours}h${remainingMinutes > 0 ? ` ${remainingMinutes}m` : ""}`; } if (minutes > 0) { const remainingSeconds = seconds % 60; return `${minutes}m${remainingSeconds > 0 ? ` ${remainingSeconds}s` : ""}`; } return `${seconds}s`; } function shortenPath(path, maxLength = 30) { const home = process.env.HOME || process.env.USERPROFILE || ""; let shortened = path; if (home && path.startsWith(home)) { shortened = "~" + path.slice(home.length); } shortened = shortened.replace(/\\/g, "/"); if (shortened.length > maxLength) { const parts = shortened.split("/"); if (parts.length > 3) { const first = parts[0]; const last = parts.slice(-2).join("/"); shortened = `${first}/.../${last}`; } } return shortened; } function formatPercent(value) { return `${Math.round(value)}%`; } function shortenModelName(modelId) { const patterns = [ [/claude-opus-4-5/i, "opus-4.5"], [/claude-sonnet-4-5/i, "sonnet-4.5"], [/claude-sonnet-4/i, "sonnet-4"], [/claude-3-5-sonnet/i, "sonnet-3.5"], [/claude-3-5-haiku/i, "haiku-3.5"], [/claude-3-opus/i, "opus-3"], [/claude-3-sonnet/i, "sonnet-3"], [/claude-3-haiku/i, "haiku-3"] ]; for (const [pattern, replacement] of patterns) { if (pattern.test(modelId)) { return replacement; } } return modelId.length > 15 ? modelId.slice(0, 15) + "..." : modelId; } var ModelWidgetComponent = ({ data }) => { const modelName = shortenModelName( data.model?.display_name || data.model?.id || "unknown" ); return /* @__PURE__ */ jsx(Text, { children: modelName }); }; var ModelWidget = { id: "model", name: "Model", description: "Displays the current Claude model name", defaultEnabled: true, defaultOrder: 0, colorKey: "model", Component: ModelWidgetComponent }; var gitCache = null; var CACHE_TTL = 5e3; function getGitInfo(cwd) { const now = Date.now(); if (gitCache && gitCache.expires > now) { return gitCache.value; } try { const options = { encoding: "utf8", timeout: 500, stdio: ["pipe", "pipe", "ignore"], cwd: cwd || process.cwd() }; const branch = execSync("git branch --show-current", options).trim(); let isDirty = false; let linesAdded = 0; let linesRemoved = 0; let filesChanged = 0; try { const status = execSync("git status --porcelain", options).trim(); isDirty = status.length > 0; if (isDirty) { const diffUnstaged = execSync("git diff --numstat", options).trim(); const diffStaged = execSync("git diff --cached --numstat", options).trim(); const parseDiff = (diff) => { if (!diff) return { added: 0, removed: 0, files: 0 }; let added = 0, removed = 0, files = 0; for (const line of diff.split("\n")) { const parts = line.split(" "); if (parts.length >= 2) { const a = parseInt(parts[0], 10); const r = parseInt(parts[1], 10); if (!isNaN(a)) added += a; if (!isNaN(r)) removed += r; files++; } } return { added, removed, files }; }; const unstaged = parseDiff(diffUnstaged); const staged = parseDiff(diffStaged); linesAdded = unstaged.added + staged.added; linesRemoved = unstaged.removed + staged.removed; filesChanged = unstaged.files + staged.files; } } catch { } const result = { branch: branch || null, isDirty, linesAdded, linesRemoved, filesChanged }; gitCache = { value: result, expires: now + CACHE_TTL }; return result; } catch { const result = { branch: null, isDirty: false, linesAdded: 0, linesRemoved: 0, filesChanged: 0 }; gitCache = { value: result, expires: now + CACHE_TTL }; return result; } } var GitBranchWidgetComponent = ({ data, theme }) => { const gitInfo = getGitInfo(data.cwd || data.workspace?.current_dir); if (!gitInfo.branch) { return null; } const branchIcon = theme.symbols.branch; const modifiedIcon = gitInfo.isDirty ? ` ${theme.symbols.modified}` : ""; return /* @__PURE__ */ jsxs(Text, { children: [ branchIcon ? `${branchIcon} ` : "", gitInfo.branch, modifiedIcon ] }); }; var GitBranchWidget = { id: "git", name: "Git Branch", description: "Displays the current Git branch name", defaultEnabled: true, defaultOrder: 1, colorKey: "git", Component: GitBranchWidgetComponent }; function parseTranscript(transcriptPath) { if (!existsSync(transcriptPath)) { return []; } try { const content = readFileSync(transcriptPath, "utf-8"); const lines = content.split("\n").filter((line) => line.trim()); const messages = []; for (const line of lines) { try { const parsed = JSON.parse(line); if (parsed.type === "user" || parsed.type === "assistant") { let textContent = ""; if (parsed.message?.content) { if (typeof parsed.message.content === "string") { textContent = parsed.message.content; } else if (Array.isArray(parsed.message.content)) { textContent = parsed.message.content.map((item) => item.text || item.content || "").join(" "); } } messages.push({ type: parsed.type, content: textContent }); } if (parsed.type === "tool_use" || parsed.tool_name) { messages.push({ type: "tool_use", tool_name: parsed.tool_name, tool_input: parsed.tool_input }); } if (parsed.type === "tool_result" || parsed.tool_result !== void 0) { messages.push({ type: "tool_result", tool_result: parsed.tool_result }); } } catch { } } return messages; } catch { return []; } } function extractActualTokenUsage(transcriptPath) { const result = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalConsumed: 0, contextTokens: 0 }; if (!existsSync(transcriptPath)) { return result; } try { const content = readFileSync(transcriptPath, "utf-8"); const lines = content.split("\n").filter((line) => line.trim()); let lastUsage = null; for (const line of lines) { try { const parsed = JSON.parse(line); if (parsed.type === "assistant" && parsed.message?.usage) { const usage = parsed.message.usage; lastUsage = usage; result.totalConsumed += (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_creation_input_tokens || 0); result.outputTokens += usage.output_tokens || 0; } } catch { } } if (lastUsage) { result.inputTokens = lastUsage.input_tokens || 0; result.cacheCreationTokens = lastUsage.cache_creation_input_tokens || 0; result.cacheReadTokens = lastUsage.cache_read_input_tokens || 0; result.contextTokens = result.inputTokens + result.cacheCreationTokens + result.cacheReadTokens; } result.totalTokens = result.totalConsumed; return result; } catch { return result; } } function estimateTokens(text) { if (!text) return 0; const koreanChars = (text.match(/[\u3131-\uD79D]/g) || []).length; const otherChars = text.length - koreanChars; const koreanTokens = koreanChars / 1.5; const otherTokens = otherChars / 4; return Math.ceil(koreanTokens + otherTokens); } function calculateTotalTokens(messages, transcriptPath) { return messages.reduce((sum, msg) => { let tokens = 0; if (typeof msg.content === "string") { tokens += estimateTokens(msg.content); } if (msg.tool_input) { tokens += estimateTokens(JSON.stringify(msg.tool_input)); } if (msg.tool_result) { tokens += estimateTokens( typeof msg.tool_result === "string" ? msg.tool_result : JSON.stringify(msg.tool_result) ); } return sum + tokens; }, 0); } function extractTodoProgress(messages) { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.tool_name === "TodoWrite" && msg.tool_input) { const input = msg.tool_input; if (input.todos && Array.isArray(input.todos)) { const todos = input.todos; return { completed: todos.filter((t2) => t2.status === "completed").length, inProgress: todos.filter((t2) => t2.status === "in_progress").length, pending: todos.filter((t2) => t2.status === "pending").length, total: todos.length }; } } } return { completed: 0, inProgress: 0, pending: 0, total: 0 }; } function calculateContextUsage(messages, maxTokens) { const usedTokens = calculateTotalTokens(messages); return Math.min(usedTokens / maxTokens * 100, 100); } var TokensWidgetComponent = ({ data }) => { let tokens = 0; if (data.transcript_path) { try { const messages = parseTranscript(data.transcript_path); tokens = calculateTotalTokens(messages); } catch { } } const formattedTokens = formatTokens(tokens); return /* @__PURE__ */ jsxs(Text, { children: [ formattedTokens, " tokens" ] }); }; var TokensWidget = { id: "tokens", name: "Token Usage", description: "Displays estimated token usage", defaultEnabled: true, defaultOrder: 2, colorKey: "tokens", Component: TokensWidgetComponent }; var CostWidgetComponent = ({ data }) => { const cost = data.cost?.total_cost_usd ?? data.cost?.api_cost ?? 0; const formattedCost = formatCost(cost); return /* @__PURE__ */ jsx(Text, { children: formattedCost }); }; var CostWidget = { id: "cost", name: "API Cost", description: "Displays the API cost for this session", defaultEnabled: true, defaultOrder: 3, colorKey: "cost", Component: CostWidgetComponent }; var SessionWidgetComponent = ({ data }) => { const durationMs = data.cost?.total_duration_ms ?? data.cost?.duration_ms ?? 0; const formattedDuration = formatDuration(durationMs); return /* @__PURE__ */ jsx(Text, { children: formattedDuration }); }; var SessionWidget = { id: "session", name: "Session Time", description: "Displays the session duration", defaultEnabled: true, defaultOrder: 4, colorKey: "session", Component: SessionWidgetComponent }; var CwdWidgetComponent = ({ data, config: config2 }) => { const cwd = data.cwd || data.workspace?.current_dir || process.cwd(); const maxLength = config2.options?.maxLength ?? 25; const shortenedPath = shortenPath(cwd, maxLength); return /* @__PURE__ */ jsx(Text, { children: shortenedPath }); }; var CwdWidget = { id: "cwd", name: "Working Directory", description: "Displays the current working directory", defaultEnabled: true, defaultOrder: 5, colorKey: "cwd", Component: CwdWidgetComponent }; function createProgressBar(percent, width = 10, filledChar = "\u2588", emptyChar = "\u2591") { const filled = Math.round(percent / 100 * width); const empty = width - filled; return filledChar.repeat(filled) + emptyChar.repeat(empty); } var ContextWidgetComponent = ({ data, theme }) => { let usagePercent = 0; if (data.transcript_path) { try { const messages = parseTranscript(data.transcript_path); const maxTokens = getModelMaxTokens(data.model?.id || ""); usagePercent = calculateContextUsage(messages, maxTokens); } catch { } } let statusColor = theme.colors.progress.filled; if (usagePercent >= 90) { statusColor = theme.colors.progress.critical; } else if (usagePercent >= 70) { statusColor = theme.colors.progress.warning; } const progressBar = createProgressBar(usagePercent); const percentText = formatPercent(usagePercent); return /* @__PURE__ */ jsxs(Text, { children: [ "CTX ", /* @__PURE__ */ jsx(Text, { color: statusColor, children: progressBar }), " ", percentText ] }); }; var ContextWidget = { id: "context", name: "Context Window", description: "Displays context window usage percentage", defaultEnabled: true, defaultOrder: 6, colorKey: "context", Component: ContextWidgetComponent }; var TodoWidgetComponent = ({ data, theme }) => { let todoProgress = { completed: 0, inProgress: 0, pending: 0, total: 0 }; if (data.transcript_path) { try { const messages = parseTranscript(data.transcript_path); todoProgress = extractTodoProgress(messages); } catch { } } if (todoProgress.total === 0) { return null; } const percent = Math.round( todoProgress.completed / todoProgress.total * 100 ); let statusColor = theme.colors.status.normal; if (todoProgress.inProgress > 0) { statusColor = theme.colors.status.warning; } if (percent === 100) { statusColor = theme.colors.status.success; } return /* @__PURE__ */ jsxs(Text, { children: [ "TODO", " ", /* @__PURE__ */ jsxs(Text, { color: statusColor, children: [ todoProgress.completed, "/", todoProgress.total ] }), " ", "[", percent, "%]" ] }); }; var TodoWidget = { id: "todo", name: "Todo Progress", description: "Displays todo list progress", defaultEnabled: false, // 기본 비활성화 defaultOrder: 7, colorKey: "todo", Component: TodoWidgetComponent }; function formatBytes(bytes) { if (bytes >= 1024 * 1024 * 1024) { return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`; } if (bytes >= 1024 * 1024) { return `${(bytes / 1024 / 1024).toFixed(0)}MB`; } if (bytes >= 1024) { return `${(bytes / 1024).toFixed(0)}KB`; } return `${bytes}B`; } var MemoryWidgetComponent = () => { const memory = process.memoryUsage(); const usedMemory = formatBytes(memory.heapUsed); return /* @__PURE__ */ jsx(Text, { children: usedMemory }); }; var MemoryWidget = { id: "memory", name: "Memory Usage", description: "Displays process memory usage", defaultEnabled: false, defaultOrder: 8, colorKey: "memory", Component: MemoryWidgetComponent }; var FilesWidgetComponent = ({ data }) => { const gitInfo = getGitInfo(data.cwd || data.workspace?.current_dir); const linesAdded = gitInfo.linesAdded; const linesRemoved = gitInfo.linesRemoved; if (linesAdded === 0 && linesRemoved === 0) { return /* @__PURE__ */ jsx(Text, { children: "no changes" }); } return /* @__PURE__ */ jsxs(Text, { children: [ "+", linesAdded, "/-", linesRemoved ] }); }; var FilesWidget = { id: "files", name: "File Changes", description: "Displays lines added/removed in working directory", defaultEnabled: false, defaultOrder: 9, colorKey: "files", Component: FilesWidgetComponent }; // src/i18n/types.ts var supportedLanguages = ["en", "ko"]; // src/i18n/detection.ts function detectLocale() { const envLocale = process.env.LANG || process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANGUAGE; if (envLocale) { const lang = envLocale.split(/[._-]/)[0].toLowerCase(); if (supportedLanguages.includes(lang)) { return lang; } } if (process.platform === "win32") { try { const output = execSync( 'powershell -Command "[System.Globalization.CultureInfo]::CurrentUICulture.Name"', { encoding: "utf-8", timeout: 3e3 } ).trim(); const lang = output.split("-")[0].toLowerCase(); if (supportedLanguages.includes(lang)) { return lang; } } catch { } } return "en"; } // src/i18n/locales/en/cli.json var cli_default = { description: "Powerline-style status bar for Claude Code CLI", commands: { render: { description: "Render the status bar (reads JSON from stdin)", demo: "Use demo data instead of stdin" }, config: { description: "Manage configuration (opens interactive TUI by default)", show: "Show current configuration as JSON", reset: "Reset to default configuration", theme: "Set theme" }, themes: { description: "List available themes" }, widgets: { description: "List available widgets" } }, messages: { configReset: "Configuration reset to defaults.", themeSet: "Theme set to: {{theme}}", invalidTheme: "Invalid theme: {{theme}}. Available: {{available}}", availableThemes: "Available themes:", availableWidgets: "Available widgets:", current: "(current)", error: "Error:" } }; // src/i18n/locales/en/tui.json var tui_default = { header: { title: "Claude Status Bar Configuration" }, tabs: { widgets: "Widgets", themes: "Themes" }, footer: { instructions: "\u2191\u2193: Navigate | Space: Toggle | Tab: Switch | S: Save | Q: Quit", saved: "Saved!" }, labels: { current: "(current)" } }; // src/i18n/locales/en/widgets.json var widgets_default = { model: { name: "Model", description: "Displays the current Claude model name" }, git: { name: "Git Branch", description: "Displays the current Git branch name" }, tokens: { name: "Token Usage", description: "Displays estimated token usage" }, cost: { name: "API Cost", description: "Displays the API cost for this session" }, session: { name: "Session Time", description: "Displays the session duration" }, cwd: { name: "Working Directory", description: "Displays the current working directory" }, context: { name: "Context Window", description: "Displays context window usage percentage" }, todo: { name: "Todo Progress", description: "Displays todo list progress" }, memory: { name: "Memory Usage", description: "Displays process memory usage" }, files: { name: "File Changes", description: "Displays lines added/removed" } }; // src/i18n/locales/en/renderer.json var renderer_default = { noWidgets: "No widgets to display", truncated: "...", labels: { tok: "tok", ctx: "CTX", todo: "TODO", mem: "MEM", noChanges: "no changes", unknown: "unknown", files: "files" } }; // src/i18n/locales/ko/cli.json var cli_default2 = { description: "Claude Code CLI\uC6A9 Powerline \uC2A4\uD0C0\uC77C \uC0C1\uD0DC\uBC14", commands: { render: { description: "\uC0C1\uD0DC\uBC14 \uB80C\uB354\uB9C1 (stdin\uC5D0\uC11C JSON \uC77D\uAE30)", demo: "stdin \uB300\uC2E0 \uB370\uBAA8 \uB370\uC774\uD130 \uC0AC\uC6A9" }, config: { description: "\uC124\uC815 \uAD00\uB9AC (\uAE30\uBCF8: \uB300\uD654\uD615 TUI \uC2E4\uD589)", show: "\uD604\uC7AC \uC124\uC815\uC744 JSON\uC73C\uB85C \uD45C\uC2DC", reset: "\uAE30\uBCF8 \uC124\uC815\uC73C\uB85C \uCD08\uAE30\uD654", theme: "\uD14C\uB9C8 \uC124\uC815" }, themes: { description: "\uC0AC\uC6A9 \uAC00\uB2A5\uD55C \uD14C\uB9C8 \uBAA9\uB85D" }, widgets: { description: "\uC0AC\uC6A9 \uAC00\uB2A5\uD55C \uC704\uC82F \uBAA9\uB85D" } }, messages: { configReset: "\uC124\uC815\uC774 \uAE30\uBCF8\uAC12\uC73C\uB85C \uCD08\uAE30\uD654\uB418\uC5C8\uC2B5\uB2C8\uB2E4.", themeSet: "\uD14C\uB9C8 \uC124\uC815\uB428: {{theme}}", invalidTheme: "\uC798\uBABB\uB41C \uD14C\uB9C8: {{theme}}. \uC0AC\uC6A9 \uAC00\uB2A5: {{available}}", availableThemes: "\uC0AC\uC6A9 \uAC00\uB2A5\uD55C \uD14C\uB9C8:", availableWidgets: "\uC0AC\uC6A9 \uAC00\uB2A5\uD55C \uC704\uC82F:", current: "(\uD604\uC7AC)", error: "\uC624\uB958:" } }; // src/i18n/locales/ko/tui.json var tui_default2 = { header: { title: "Claude \uC0C1\uD0DC\uBC14 \uC124\uC815" }, tabs: { widgets: "\uC704\uC82F", themes: "\uD14C\uB9C8" }, footer: { instructions: "\u2191\u2193: \uC774\uB3D9 | Space: \uC120\uD0DD | Tab: \uD0ED \uC804\uD658 | S: \uC800\uC7A5 | Q: \uC885\uB8CC", saved: "\uC800\uC7A5\uB428!" }, labels: { current: "(\uD604\uC7AC)" } }; // src/i18n/locales/ko/widgets.json var widgets_default2 = { model: { name: "\uBAA8\uB378", description: "\uD604\uC7AC Claude \uBAA8\uB378\uBA85 \uD45C\uC2DC" }, git: { name: "Git \uBE0C\uB79C\uCE58", description: "\uD604\uC7AC Git \uBE0C\uB79C\uCE58\uBA85 \uD45C\uC2DC" }, tokens: { name: "\uD1A0\uD070 \uC0AC\uC6A9\uB7C9", description: "\uC608\uC0C1 \uD1A0\uD070 \uC0AC\uC6A9\uB7C9 \uD45C\uC2DC" }, cost: { name: "API \uBE44\uC6A9", description: "\uC774 \uC138\uC158\uC758 API \uBE44\uC6A9 \uD45C\uC2DC" }, session: { name: "\uC138\uC158 \uC2DC\uAC04", description: "\uC138\uC158 \uACBD\uACFC \uC2DC\uAC04 \uD45C\uC2DC" }, cwd: { name: "\uC791\uC5C5 \uB514\uB809\uD1A0\uB9AC", description: "\uD604\uC7AC \uC791\uC5C5 \uB514\uB809\uD1A0\uB9AC \uD45C\uC2DC" }, context: { name: "\uCEE8\uD14D\uC2A4\uD2B8 \uC708\uB3C4\uC6B0", description: "\uCEE8\uD14D\uC2A4\uD2B8 \uC708\uB3C4\uC6B0 \uC0AC\uC6A9\uB960 \uD45C\uC2DC" }, todo: { name: "\uD560\uC77C \uC9C4\uD589\uB960", description: "\uD560\uC77C \uBAA9\uB85D \uC9C4\uD589 \uC0C1\uD669 \uD45C\uC2DC" }, memory: { name: "\uBA54\uBAA8\uB9AC \uC0AC\uC6A9\uB7C9", description: "\uD504\uB85C\uC138\uC2A4 \uBA54\uBAA8\uB9AC \uC0AC\uC6A9\uB7C9 \uD45C\uC2DC" }, files: { name: "\uD30C\uC77C \uBCC0\uACBD", description: "\uCD94\uAC00/\uC0AD\uC81C\uB41C \uB77C\uC778 \uC218 \uD45C\uC2DC" } }; // src/i18n/locales/ko/renderer.json var renderer_default2 = { noWidgets: "\uD45C\uC2DC\uD560 \uC704\uC82F\uC774 \uC5C6\uC2B5\uB2C8\uB2E4", truncated: "...", labels: { tok: "\uD1A0\uD070", ctx: "\uCEE8\uD14D\uC2A4\uD2B8", todo: "\uD560\uC77C", mem: "\uBA54\uBAA8\uB9AC", noChanges: "\uBCC0\uACBD \uC5C6\uC74C", unknown: "\uC54C \uC218 \uC5C6\uC74C", files: "\uD30C\uC77C" } }; // src/i18n/index.ts var resources = { en: { cli: cli_default, tui: tui_default, widgets: widgets_default, renderer: renderer_default }, ko: { cli: cli_default2, tui: tui_default2, widgets: widgets_default2, renderer: renderer_default2 } }; var initialized = false; var currentLocale = "en"; function initI18n(locale = "auto") { if (initialized) return i18next; if (locale === "auto") { currentLocale = detectLocale(); } else if (locale === "en" || locale === "ko") { currentLocale = locale; } else { currentLocale = "en"; } i18next.use(initReactI18next).init({ lng: currentLocale, fallbackLng: "en", defaultNS: "cli", ns: ["cli", "tui", "widgets", "renderer"], resources, interpolation: { escapeValue: false }, returnNull: false }); initialized = true; return i18next; } var t = i18next.t.bind(i18next); i18next.changeLanguage.bind(i18next); // src/widgets/types.ts function getWidgetName(widget) { const translated = t(`widgets:${widget.id}.name`); if (translated === `${widget.id}.name`) { return widget.name; } return translated; } function getWidgetDescription(widget) { const translated = t(`widgets:${widget.id}.description`); if (translated === `${widget.id}.description`) { return widget.description; } return translated; } // src/widgets/index.ts function registerBuiltinWidgets() { widgetRegistry.register(ModelWidget); widgetRegistry.register(GitBranchWidget); widgetRegistry.register(TokensWidget); widgetRegistry.register(CostWidget); widgetRegistry.register(SessionWidget); widgetRegistry.register(CwdWidget); widgetRegistry.register(ContextWidget); widgetRegistry.register(TodoWidget); widgetRegistry.register(MemoryWidget); widgetRegistry.register(FilesWidget); } var WidgetConfigSchema = z.object({ enabled: z.boolean().default(true), order: z.number().int().min(0).default(0), options: z.record(z.unknown()).optional() }); var BehaviorConfigSchema = z.object({ contextWarningThreshold: z.number().min(0).max(100).default(70), contextDangerThreshold: z.number().min(0).max(100).default(90) }); var AppConfigSchema = z.object({ version: z.literal(1).default(1), theme: z.string().default("powerline-dark"), locale: z.enum(["auto", "en", "ko"]).default("auto"), widgets: z.record(WidgetConfigSchema).default({}), behavior: BehaviorConfigSchema.default({}) }); // src/config/defaults.ts var defaultConfig = { version: 1, theme: "powerline-dark", widgets: { model: { enabled: true, order: 0 }, git: { enabled: true, order: 1 }, tokens: { enabled: true, order: 2 }, cost: { enabled: true, order: 3 }, session: { enabled: true, order: 4 }, cwd: { enabled: true, order: 5 }, context: { enabled: true, order: 6 }, todo: { enabled: false, order: 7 } }, behavior: { contextWarningThreshold: 70, contextDangerThreshold: 90 } }; // src/config/loader.ts function getConfigPaths() { const home = homedir(); return [ join(process.cwd(), ".claude-status-bar.json"), join(home, ".claude-status-bar", "config.json"), join(home, ".config", "claude-status-bar", "config.json") ]; } function getDefaultConfigPath() { const home = homedir(); return join(home, ".claude-status-bar", "config.json"); } function loadConfig() { for (const configPath of getConfigPaths()) { if (existsSync(configPath)) { try { const raw = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(raw); const validated = AppConfigSchema.parse(parsed); return validated; } catch (error) { console.error(`Error loading config from ${configPath}:`, error); } } } return defaultConfig; } function saveConfig(config2, path) { const configPath = getDefaultConfigPath(); const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(configPath, JSON.stringify(config2, null, 2), "utf-8"); } // src/utils/terminal.ts function getTerminalWidth() { return process.stdout.columns || 80; } function stripAnsi(text) { return text.replace(/\x1b\[[0-9;]*m/g, ""); } function getDisplayWidth(text) { const stripped = stripAnsi(text); let width = 0; for (const char of stripped) { const code = char.charCodeAt(0); if (code >= 4352 && code <= 4607 || // 한글 자모 code >= 12288 && code <= 40959 || // CJK code >= 44032 && code <= 55203 || // 한글 음절 code >= 63744 && code <= 64255 || // CJK 호환 code >= 65280 && code <= 65519) { width += 2; } else { width += 1; } } return width; } // src/core/renderer.ts var chalk = new Chalk({ level: 3 }); function createProgressBar2(percent, width = 10, filledChar = "\u2588", emptyChar = "\u2591") { const filled = Math.round(percent / 100 * width); const empty = width - filled; return filledChar.repeat(filled) + emptyChar.repeat(empty); } function getWidgetContent(widgetId, data, theme) { try { switch (widgetId) { case "model": return shortenModelName(data.model?.display_name || data.model?.id || t("renderer:labels.unknown")); case "git": { const gitInfo = getGitInfo(data.cwd || data.workspace?.current_dir); if (!gitInfo.branch) return null; const linesAdded = gitInfo.linesAdded; const linesRemoved = gitInfo.linesRemoved; const branch = chalk.hex("#37474f")(gitInfo.branch); const added = chalk.hex("#2e7d32").bold(`+${linesAdded}`); const removed = chalk.hex("#c62828").bold(`-${linesRemoved}`); return chalk.bgHex("#ffffff")(`${branch} ${added} ${removed}`); } case "tokens": { let tokens = 0; if (data.transcript_path) { const usage = extractActualTokenUsage(data.transcript_path); tokens = usage.totalTokens; } return `${formatTokens(tokens)} ${t("renderer:labels.tok")}`; } case "cost": return formatCost(data.cost?.total_cost_usd ?? data.cost?.api_cost ?? 0); case "session": return formatDuration(data.cost?.total_duration_ms ?? data.cost?.duration_ms ?? 0); case "cwd": return shortenPath(data.cwd || data.workspace?.current_dir || process.cwd(), 20); case "context": { let usagePercent = 0; if (data.transcript_path) { const usage = extractActualTokenUsage(data.transcript_path); const maxTokens = getModelMaxTokens(data.model?.id || ""); usagePercent = Math.min(usage.contextTokens / maxTokens * 100, 100); } const bar = createProgressBar2(usagePercent, 8); return `${t("renderer:labels.ctx")} ${bar} ${formatPercent(usagePercent)}`; } case "todo": { let todoProgress = { completed: 0, inProgress: 0, pending: 0, total: 0 }; if (data.transcript_path) { const messages = parseTranscript(data.transcript_path); todoProgress = extractTodoProgress(messages); } if (todoProgress.total === 0) return null; const percent = Math.round(todoProgress.completed / todoProgress.total * 100); return `${t("renderer:labels.todo")} ${todoProgress.completed}/${todoProgress.total} [${percent}%]`; } case "memory": { const memory = process.memoryUsage(); const usedMB = Math.round(memory.heapUsed / 1024 / 1024); return `${t("renderer:labels.mem")} ${usedMB}MB`; } case "files": { const gitInfo = getGitInfo(data.cwd || data.workspace?.current_dir); const filesChanged = gitInfo.filesChanged || 0; if (filesChanged === 0) return null; return `${filesChanged} ${t("renderer:labels.files")}`; } default: return null; } } catch (error) { return null; } } function renderSegment(content, bgColor, fgColor, nextBgColor, separator, isLastInLine = false) { const hasAnsi = content.includes("\x1B["); let segment; if (hasAnsi) { const prefix = chalk.bgHex(bgColor)(" "); const suffix = chalk.bgHex(bgColor)(" "); segment = prefix + content + suffix; } else { segment = chalk.bgHex(bgColor).hex(fgColor)(` ${content} `); } let sep = ""; if (isLastInLine) { sep = chalk.hex(bgColor)(separator) + "\x1B[0m"; } else if (nextBgColor) { sep = chalk.bgHex(nextBgColor).hex(bgColor)(separator); } else { sep = chalk.hex(bgColor)(separator) + "\x1B[0m"; } return segment + sep; } function getSegmentDisplayWidth(content, separator) { return getDisplayWidth(content) + 2 + getDisplayWidth(separator); } function fitSegmentsToWidth(segments, _separator, _maxWidth) { return segments; } function renderStatusBar(data, theme, widgets, widgetConfigs) { const activeWidgets = widgets.filter((widget) => { const config2 = widgetConfigs[widget.id]; return config2 ? config2.enabled : widget.defaultEnabled; }).sort((a, b) => { const orderA = widgetConfigs[a.id]?.order ?? a.defaultOrder; const orderB = widgetConfigs[b.id]?.order ?? b.defaultOrder; return orderA - orderB; }); let segments = []; for (const widget of activeWidgets) { const content = getWidgetContent(widget.id, data); if (content !== null) { segments.push({ widget, content }); } } if (segments.length === 0) { return chalk.gray(t("renderer:noWidgets")); } const terminalWidth = getTerminalWidth(); const separator = theme.symbols.separator; segments = fitSegmentsToWidth(segments); if (segments.length === 0) { return chalk.gray(t("renderer:truncated")); } let output = ""; let currentLineWidth = 0; let lineSegments = []; for (let i = 0; i < segments.length; i++) { const { widget, content } = segments[i]; const colors = theme.colors.segments[widget.colorKey]; const segmentWidth = getSegmentDisplayWidth(content, separator); if (currentLineWidth > 0 && currentLineWidth + segmentWidth > terminalWidth) { for (let j = 0; j < lineSegments.length; j++) { const seg = lineSegments[j]; const isLast = j === lineSegments.length - 1; const nextSeg = lineSegments[j + 1]; const nextBgColor = isLast ? null : nextSeg?.colors.bg; output += renderSegment(seg.content, seg.colors.bg, seg.colors.fg, nextBgColor, separator, isLast); } output += "\n"; lineSegments = []; currentLineWidth = 0; } lineSegments.push({ widget, content, colors }); currentLineWidth += segmentWidth; } for (let j = 0; j < lineSegments.length; j++) { const seg = lineSegments[j]; const isLast = j === lineSegments.length - 1; const nextSeg = lineSegments[j + 1]; const nextBgColor = isLast ? null : nextSeg?.colors.bg; output += renderSegment(seg.content, seg.colors.bg, seg.colors.fg, nextBgColor, separator, isLast); } return output; } // src/utils/cache.ts var Cache = class { cache = /* @__PURE__ */ new Map(); ttl; maxSize; constructor(options) { this.ttl = options.ttl; this.maxSize = options.maxSize ?? 100; } /** * 캐시에서 값 조회 * TTL 만료 시 undefined 반환 */ get(key) { const entry = this.cache.get(key); if (!entry) return void 0; if (Date.now() - entry.timestamp > this.ttl) { this.cache.delete(key); return void 0; } return entry.value; } /** * 캐시에 값 저장 * @param key 캐시 키 * @param value 저장할 값 * @param mtime 파일 수정 시간 (선택적, 파일 기반 캐시 무효화용) */ set(key, value, mtime) { if (this.cache.size >= this.maxSize) { const oldestKey = this.cache.keys().next().value; if (oldestKey) { this.cache.delete(oldestKey); } } this.cache.set(key, { value, timestamp: Date.now(), mtime }); } /** * 캐시에 키가 존재하고 유효한지 확인 */ has(key) { return this.get(key) !== void 0; } /** * 특정 키 또는 전체 캐시 무효화 */ invalidate(key) { if (key) { this.cache.delete(key); } else { this.cache.clear(); } } /** * 파일 수정 시간 기반 캐시 유효성 검사 * 현재 mtime이 캐시된 mtime과 다르면 무효 */ isValid(key, currentMtime) { const entry = this.cache.get(key); if (!entry) return false; if (Date.now() - entry.timestamp > this.ttl) { this.cache.delete(key); return false; } if (currentMtime !== void 0 && entry.mtime !== void 0) { if (currentMtime > entry.mtime) { this.cache.delete(key); return false; } } return true; } /** * 캐시된 항목의 mtime 조회 */ getMtime(key) { return this.cache.get(key)?.mtime; } /** * 캐시 크기 반환 */ get size() { return this.cache.size; } /** * 캐시 초기화 */ clear() { this.cache.clear(); } }; function createCache(options) { return new Cache(options); } var execPromise = promisify(exec); async function executeAsync(command, options) { const { cwd, timeout = 500, encoding = "utf8" } = options ?? {}; try { const result = await execPromise(command, { cwd: cwd || process.cwd(), timeout, encoding, windowsHide: true }); return result.stdout.trim(); } catch (error) { return ""; } } async function batchExecute(commands, options) { const results = await Promise.allSettled( commands.map((cmd) => executeAsync(cmd, options)) ); return results.map( (result) => result.status === "fulfilled" ? result.value : "" ); } var DedupedExecutor = class { pending = /* @__PURE__ */ new Map(); async execute(key, fn) { const existing = this.pending.get(key); if (existing) { return existing; } const promise = fn().finally(() => { this.pending.delete(key); }); this.pending.set(key, promise); return promise; } /** * 진행 중인 요청 수 반환 */ get pendingCount() { return this.pending.size; } }; // src/utils/git-async.ts var gitCache2 = createCache({ ttl: 5e3, maxSize: 5 }); var dedupedExecutor = new DedupedExecutor(); function parseDiffStats(diff) { if (!diff) return { added: 0, removed: 0, files: 0 }; let added = 0; let removed = 0; let files = 0; for (const line of diff.split("\n")) { const parts = line.split(" "); if (parts.length >= 3) { const a = parseInt(parts[0], 10); const r = parseInt(parts[1], 10); if (!isNaN(a)) added += a; if (!isNaN(r)) removed += r; files++; } } return { added, removed, files }; } async function fetchGitInfoAsync(cwd) { const defaultResult = { branch: null, isDirty: false, linesAdded: 0, linesRemoved: 0, filesChanged: 0 }; const workDir = cwd || process.cwd(); const options = { cwd: workDir, timeout: 500 }; const commands = [ "git branch --show-current", "git status --porcelain", "git diff --numstat", "git diff --cached --numstat" ]; const [branch, status, diffUnstaged, diffStaged] = await batchExecute(commands, options); if (!branch) { return defaultResult; } const isDirty = status.length > 0; const unstaged = parseDiffStats(diffUnstaged); const staged = parseDiffStats(diffStaged); return { branch: branch || null, isDirty, linesAdded: unstaged.added + staged.added, linesRemoved: unstaged.removed + staged.removed, filesChanged: unstaged.files + staged.files }; } async function getGitInfoAsync(cwd) { const workDir = cwd || process.cwd(); const cacheKey = workDir; const cached = gitCache2.get(cacheKey); if (cached) { return cached; } const result = await dedupedExecutor.execute(cacheKey, () => fetchGitInfoAsync(cwd)); gitCache2.set(cacheKey, result); return result; } var transcriptCache = createCache({ ttl: 2e3, maxSize: 10 }); function getDefaultData() { return { messages: [], tokenUsage: { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalConsumed: 0, contextTokens: 0 }, todoProgress: { completed: 0, inProgress: 0, pending: 0, total: 0 } }; } function getTranscriptData(transcriptPath) { if (!transcriptPath || !existsSync(transcriptPath)) { return getDefaultData(); } try { const stats = statSync(transcriptPath); const currentMtime = stats.mtimeMs; if (transcriptCache.isValid(transcriptPath, currentMtime)) { const cached = transcriptCache.get(transcriptPath); if (cached) { return cached; } } const content = readFileSync(transcriptPath, "utf-8"); const lines = content.split("\n").filter((line) => line.trim()); const messages = []; let lastUsage = null; let totalConsumed = 0; let outputTokens = 0; let latestTodos = []; for (const line of lines) { try { const parsed = JSON.parse(line); if (parsed.type === "user" || parsed.type === "assistant") { let textContent = ""; if (parsed.message?.content) { if (typeof parsed.message.content === "string") { textContent = parsed.message.content; } else if (Array.isArray(parsed.message.content)) { textContent = parsed.message.content.map((item) => item.text || item.content || "").join(" "); } } messages.push({ type: parsed.type, content: textContent }); if (parsed.type === "assistant" && parsed.message?.usage) { const usage = parsed.message.usage; lastUsage = usage; totalConsumed += (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_creation_input_tokens || 0); outputTokens += usage.output_tokens || 0; } } if (parsed.type === "tool_use" || parsed.tool_name) { messages.push({ type: "tool_use", tool_name: parsed.tool_name, tool_input: parsed.tool_input }); if (parsed.tool_name === "TodoWrite" && parsed.tool_input) { const input = parsed.tool_input; if (input.todos && Array.isArray(input.todos)) { latestTodos = input.todos; } } } if (parsed.type === "tool_result" || parsed.tool_result !== void 0) { messages.push({ type: "tool_result", tool_result: parsed.tool_result }); } } catch { } } const result = { messages, tokenUsage: { inputTokens: lastUsage?.input_tokens || 0, outputTokens, cacheCreationTokens: lastUsage?.cache_creation_input_tokens || 0, cacheReadTokens: lastUsage?.cache_read_input_tokens || 0, totalTokens: totalConsumed, totalConsumed, contextTokens: (lastUsage?.input_tokens || 0) + (lastUsage?.cache_creation_input_tokens || 0) + (lastUsage?.cache_read_input_tokens || 0) }, todoProgress: { completed: latestTodos.filter((t2) => t2.st