From 07528510be73a6ed1a73b10bd2ddad02085f073d Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 8 Aug 2025 07:51:01 -0400 Subject: [PATCH] Code cleanup and README --- README.md | 164 ++++++ package.json | 2 +- src/ccstatusline.ts | 530 +++++++++---------- src/claude-settings.ts | 82 +-- src/config.ts | 140 +++--- src/index.ts | 1 - src/tui.ts | 148 ------ src/tui.tsx | 1088 ++++++++++++++++++++-------------------- 8 files changed, 1100 insertions(+), 1055 deletions(-) create mode 100644 README.md delete mode 100644 src/index.ts delete mode 100644 src/tui.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..c753fbf --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# ccstatusline + +A customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics in your terminal. + +## Features + +- 📊 **Real-time metrics** - Display model name, git branch, token usage, and more +- 🎨 **Fully customizable** - Choose what to display and customize colors +- 🖥️ **Interactive TUI** - Built-in configuration interface using React/Ink +- 🚀 **Cross-platform** - Works with both Bun and Node.js +- 📏 **80-character format** - Perfectly sized for Claude Code CLI integration + +## Quick Start + +No installation needed! Use directly with npx: + +```bash +# Run the configuration TUI +npx ccstatusline +``` + +## Setup + +### Configure ccstatusline + +Run the interactive configuration tool: + +```bash +npx ccstatusline +``` + +This launches a TUI where you can: +- Add/remove status line items +- Reorder items with arrow keys +- Customize colors for each element +- Preview your status line in real-time + +Your settings are saved to `~/.config/ccstatusline/settings.json`. + +## Usage + +Once configured, ccstatusline automatically formats your Claude Code status line. The status line appears at the bottom of your terminal during Claude Code sessions. + +### Available Status Items + +- **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet") +- **Git Branch** - Displays current git branch name +- **Token Usage** - Shows input/output/total tokens used +- **Time** - Current time in HH:MM:SS format +- **Custom Text** - Add your own static text +- **Separator** - Visual divider between items +- **Flex Separator** - Expands to fill available space + +## Configuration File + +The configuration file at `~/.config/ccstatusline/settings.json` looks like: + +```json +{ + "items": [ + { + "type": "model", + "color": "cyan" + }, + { + "type": "separator", + "text": " │ ", + "color": "gray" + }, + { + "type": "git_branch", + "color": "green" + }, + { + "type": "separator", + "text": " │ ", + "color": "gray" + }, + { + "type": "tokens", + "color": "yellow" + }, + { + "type": "flex_separator", + "text": "─", + "color": "gray" + }, + { + "type": "time", + "color": "blue" + } + ] +} +``` + +### Color Options + +Available colors: +- `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray` +- `brightBlack`, `brightRed`, `brightGreen`, `brightYellow`, `brightBlue`, `brightMagenta`, `brightCyan`, `brightWhite` + +## Development + +### Prerequisites + +- [Bun](https://bun.sh) +- Git + +### Setup + +```bash +# Clone the repository +git clone https://github.com/yourusername/ccstatusline.git +cd ccstatusline + +# Install dependencies +bun install +``` + +### Development Commands + +```bash +# Run in TUI mode (configuration) +bun run src/ccstatusline.ts + +# Build for distribution +bun run build +``` + +### Project Structure + +``` +ccstatusline/ +├── src/ +│ ├── ccstatusline.ts # Main entry point +│ ├── tui.tsx # React/Ink configuration UI +│ ├── config.ts # Settings management +│ └── claude-settings.ts # Claude Code settings integration +├── dist/ # Built files (generated) +├── package.json +├── tsconfig.json +└── README.md +``` +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +MIT + +## Author + +Matthew Breedlove + +## Acknowledgments + +Built for use with [Claude Code CLI](https://claude.ai/code) by Anthropic. \ No newline at end of file diff --git a/package.json b/package.json index 614eaf6..767ec6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "1.0.0", + "version": "1.0.1", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index f386cdb..7658564 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -13,303 +13,307 @@ const readFile = fs.promises?.readFile || promisify(fs.readFile); chalk.level = 3; interface StatusJSON { - session_id: string; - transcript_path: string; - cwd: string; - model: { - id: string; - display_name: string; - }; - workspace: { - current_dir: string; - project_dir: string; - }; + session_id: string; + transcript_path: string; + cwd: string; + model: { + id: string; + display_name: string; + }; + workspace: { + current_dir: string; + project_dir: string; + }; } interface TokenUsage { - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; } interface TranscriptLine { - message?: { - usage?: TokenUsage; - }; + message?: { + usage?: TokenUsage; + }; } async function readStdin(): Promise { - // Check if stdin is a TTY (terminal) - if it is, there's no piped data - if (process.stdin.isTTY) { - return null; - } - - const chunks: string[] = []; - - try { - // Use Node.js compatible approach - if (typeof Bun !== 'undefined' && Bun.stdin) { - // Bun environment - const decoder = new TextDecoder(); - for await (const chunk of Bun.stdin.stream()) { - chunks.push(decoder.decode(chunk)); - } - } else { - // Node.js environment - process.stdin.setEncoding('utf8'); - for await (const chunk of process.stdin) { - chunks.push(chunk); - } + // Check if stdin is a TTY (terminal) - if it is, there's no piped data + if (process.stdin.isTTY) { + return null; + } + + const chunks: string[] = []; + + try { + // Use Node.js compatible approach + if (typeof Bun !== 'undefined' && Bun.stdin) { + // Bun environment + const decoder = new TextDecoder(); + for await (const chunk of Bun.stdin.stream()) { + chunks.push(decoder.decode(chunk)); + } + } else { + // Node.js environment + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + } + return chunks.join(''); + } catch { + return null; } - return chunks.join(''); - } catch { - return null; - } } function getGitBranch(): string | null { - try { - const branch = execSync('git branch --show-current 2>/dev/null', { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - return branch || null; - } catch { - return null; - } + try { + const branch = execSync('git branch --show-current 2>/dev/null', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + return branch || null; + } catch { + return null; + } } async function getTokenMetrics(transcriptPath: string): Promise<{ - inputTokens: number; - outputTokens: number; - cachedTokens: number; - totalTokens: number; - contextLength: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + totalTokens: number; + contextLength: number; }> { - try { - // Use Node.js-compatible file reading - if (!fs.existsSync(transcriptPath)) { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } - - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n'); - - let inputTokens = 0; - let outputTokens = 0; - let cachedTokens = 0; - - // Parse each line and sum up token usage - for (const line of lines) { - try { - const data: TranscriptLine = JSON.parse(line); - if (data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - cachedTokens += data.message.usage.cache_read_input_tokens || 0; - cachedTokens += data.message.usage.cache_creation_input_tokens || 0; + try { + // Use Node.js-compatible file reading + if (!fs.existsSync(transcriptPath)) { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; } - } catch { - // Skip invalid JSON lines - } + + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n'); + + let inputTokens = 0; + let outputTokens = 0; + let cachedTokens = 0; + + // Parse each line and sum up token usage + for (const line of lines) { + try { + const data: TranscriptLine = JSON.parse(line); + if (data.message?.usage) { + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + cachedTokens += data.message.usage.cache_read_input_tokens || 0; + cachedTokens += data.message.usage.cache_creation_input_tokens || 0; + } + } catch { + // Skip invalid JSON lines + } + } + + const totalTokens = inputTokens + outputTokens + cachedTokens; + const contextLength = inputTokens + outputTokens; // Current context size + + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; + } catch { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; } - - const totalTokens = inputTokens + outputTokens + cachedTokens; - const contextLength = inputTokens + outputTokens; // Current context size - - return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; - } catch { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } } async function renderStatusLine(data: StatusJSON) { - const settings = await loadSettings(); - const terminalWidth = 80; // Always use 80 chars for Claude Code status line - const elements: { content: string, type: string }[] = []; - let hasFlexSeparator = false; - - // Get token metrics if needed - const hasTokenItems = settings.items.some(item => - ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) - ); - - let tokenMetrics: any = null; - if (hasTokenItems && data.transcript_path) { - tokenMetrics = await getTokenMetrics(data.transcript_path); - } - - // Helper function to format token counts - const formatTokens = (count: number): string => { - if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; - if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; - return count.toString(); - }; - - // Build elements based on configured items - for (const item of settings.items) { - switch (item.type) { - case 'model': - if (data.model) { - const color = (chalk as any)[item.color || settings.colors.model] || chalk.cyan; - elements.push({ content: color(`Model: ${data.model.display_name}`), type: 'model' }); - } - break; - - case 'git-branch': - const branch = getGitBranch(); - if (branch) { - const color = (chalk as any)[item.color || settings.colors.gitBranch] || chalk.magenta; - elements.push({ content: color(`⎇ ${branch}`), type: 'git-branch' }); - } - break; - - case 'tokens-input': - if (tokenMetrics) { - const color = (chalk as any)[item.color || 'yellow'] || chalk.yellow; - elements.push({ content: color(`In: ${formatTokens(tokenMetrics.inputTokens)}`), type: 'tokens-input' }); - } - break; - - case 'tokens-output': - if (tokenMetrics) { - const color = (chalk as any)[item.color || 'green'] || chalk.green; - elements.push({ content: color(`Out: ${formatTokens(tokenMetrics.outputTokens)}`), type: 'tokens-output' }); - } - break; - - case 'tokens-cached': - if (tokenMetrics) { - const color = (chalk as any)[item.color || 'blue'] || chalk.blue; - elements.push({ content: color(`Cached: ${formatTokens(tokenMetrics.cachedTokens)}`), type: 'tokens-cached' }); - } - break; - - case 'tokens-total': - if (tokenMetrics) { - const color = (chalk as any)[item.color || 'white'] || chalk.white; - elements.push({ content: color(`Total: ${formatTokens(tokenMetrics.totalTokens)}`), type: 'tokens-total' }); - } - break; - - case 'context-length': - if (tokenMetrics) { - const color = (chalk as any)[item.color || 'cyan'] || chalk.cyan; - elements.push({ content: color(`Ctx: ${formatTokens(tokenMetrics.contextLength)}`), type: 'context-length' }); - } - break; - - case 'context-percentage': - if (tokenMetrics) { - const percentage = Math.min(100, (tokenMetrics.contextLength / 200000) * 100); - const color = (chalk as any)[item.color || 'cyan'] || chalk.cyan; - elements.push({ content: color(`Ctx: ${percentage.toFixed(1)}%`), type: 'context-percentage' }); - } - break; - - case 'separator': - // Only add separator if there are already elements and the last one isn't a separator - if (elements.length > 0 && elements[elements.length - 1].type !== 'separator') { - const sepColor = (chalk as any)[settings.colors.separator] || chalk.dim; - elements.push({ content: sepColor(' | '), type: 'separator' }); - } - break; - - case 'flex-separator': - elements.push({ content: 'FLEX', type: 'flex-separator' }); - hasFlexSeparator = true; - break; - } - } - - if (elements.length === 0) return; - - // Build the final status line - let statusLine = ''; - - if (hasFlexSeparator) { - // Split elements by flex separators - const parts: string[][] = [[]]; - let currentPart = 0; + const settings = await loadSettings(); + const terminalWidth = 80; // Always use 80 chars for Claude Code status line + const elements: { content: string, type: string }[] = []; + let hasFlexSeparator = false; - for (const elem of elements) { - if (elem.type === 'flex-separator') { - currentPart++; - parts[currentPart] = []; - } else { - parts[currentPart].push(elem.content); - } + // Get token metrics if needed + const hasTokenItems = settings.items.some(item => + ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) + ); + + let tokenMetrics: any = null; + if (hasTokenItems && data.transcript_path) { + tokenMetrics = await getTokenMetrics(data.transcript_path); } - // Calculate total length of all non-flex content - const partLengths = parts.map(part => { - const joined = part.join(''); - return joined.replace(/\x1b\[[0-9;]*m/g, '').length; - }); - const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0); + // Helper function to format token counts + const formatTokens = (count: number): string => { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return count.toString(); + }; - // Calculate space to distribute among flex separators - const flexCount = parts.length - 1; // Number of flex separators - const totalSpace = Math.max(0, terminalWidth - totalContentLength); - const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; - const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; - - // Build the status line with distributed spacing - statusLine = ''; - for (let i = 0; i < parts.length; i++) { - statusLine += parts[i].join(''); - if (i < parts.length - 1) { - // Add flex spacing - const spaces = spacePerFlex + (i < extraSpace ? 1 : 0); - statusLine += ' '.repeat(spaces); - } + // Build elements based on configured items + for (const item of settings.items) { + switch (item.type) { + case 'model': + if (data.model) { + const color = (chalk as any)[item.color || settings.colors.model] || chalk.cyan; + elements.push({ content: color(`Model: ${data.model.display_name}`), type: 'model' }); + } + break; + + case 'git-branch': + const branch = getGitBranch(); + if (branch) { + const color = (chalk as any)[item.color || settings.colors.gitBranch] || chalk.magenta; + elements.push({ content: color(`⎇ ${branch}`), type: 'git-branch' }); + } + break; + + case 'tokens-input': + if (tokenMetrics) { + const color = (chalk as any)[item.color || 'yellow'] || chalk.yellow; + elements.push({ content: color(`In: ${formatTokens(tokenMetrics.inputTokens)}`), type: 'tokens-input' }); + } + break; + + case 'tokens-output': + if (tokenMetrics) { + const color = (chalk as any)[item.color || 'green'] || chalk.green; + elements.push({ content: color(`Out: ${formatTokens(tokenMetrics.outputTokens)}`), type: 'tokens-output' }); + } + break; + + case 'tokens-cached': + if (tokenMetrics) { + const color = (chalk as any)[item.color || 'blue'] || chalk.blue; + elements.push({ content: color(`Cached: ${formatTokens(tokenMetrics.cachedTokens)}`), type: 'tokens-cached' }); + } + break; + + case 'tokens-total': + if (tokenMetrics) { + const color = (chalk as any)[item.color || 'white'] || chalk.white; + elements.push({ content: color(`Total: ${formatTokens(tokenMetrics.totalTokens)}`), type: 'tokens-total' }); + } + break; + + case 'context-length': + if (tokenMetrics) { + const color = (chalk as any)[item.color || 'cyan'] || chalk.cyan; + elements.push({ content: color(`Ctx: ${formatTokens(tokenMetrics.contextLength)}`), type: 'context-length' }); + } + break; + + case 'context-percentage': + if (tokenMetrics) { + const percentage = Math.min(100, (tokenMetrics.contextLength / 200000) * 100); + const color = (chalk as any)[item.color || 'cyan'] || chalk.cyan; + elements.push({ content: color(`Ctx: ${percentage.toFixed(1)}%`), type: 'context-percentage' }); + } + break; + + case 'separator': + // Only add separator if there are already elements and the last one isn't a separator + const lastElement = elements[elements.length - 1]; + if (elements.length > 0 && lastElement && lastElement.type !== 'separator') { + const sepColor = (chalk as any)[settings.colors.separator] || chalk.dim; + elements.push({ content: sepColor(' | '), type: 'separator' }); + } + break; + + case 'flex-separator': + elements.push({ content: 'FLEX', type: 'flex-separator' }); + hasFlexSeparator = true; + break; + } } - } else { - // No flex separator, just join all elements - statusLine = elements.map(e => e.content).join(''); - // Pad to full width with spaces - const contentLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; - const remainingSpace = terminalWidth - contentLength; - if (remainingSpace > 0) { - statusLine = statusLine + ' '.repeat(remainingSpace); + if (elements.length === 0) return; + + // Build the final status line + let statusLine = ''; + + if (hasFlexSeparator) { + // Split elements by flex separators + const parts: string[][] = [[]]; + let currentPart = 0; + + for (const elem of elements) { + if (elem.type === 'flex-separator') { + currentPart++; + parts[currentPart] = []; + } else { + parts[currentPart]!.push(elem.content); + } + } + + // Calculate total length of all non-flex content + const partLengths = parts.map(part => { + const joined = part.join(''); + return joined.replace(/\x1b\[[0-9;]*m/g, '').length; + }); + const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0); + + // Calculate space to distribute among flex separators + const flexCount = parts.length - 1; // Number of flex separators + const totalSpace = Math.max(0, terminalWidth - totalContentLength); + const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; + const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; + + // Build the status line with distributed spacing + statusLine = ''; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part) { + statusLine += part.join(''); + } + if (i < parts.length - 1) { + // Add flex spacing + const spaces = spacePerFlex + (i < extraSpace ? 1 : 0); + statusLine += ' '.repeat(spaces); + } + } + } else { + // No flex separator, just join all elements + statusLine = elements.map(e => e.content).join(''); + + // Pad to full width with spaces + const contentLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; + const remainingSpace = terminalWidth - contentLength; + if (remainingSpace > 0) { + statusLine = statusLine + ' '.repeat(remainingSpace); + } + } + + // Ensure we never exceed 80 chars + const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; + if (plainLength > 80) { + // Truncate with ellipsis if too long + const visibleText = statusLine.replace(/\x1b\[[0-9;]*m/g, ''); + const truncated = visibleText.substring(0, 77) + '...'; + console.log(truncated); + } else { + console.log(statusLine); } - } - - // Ensure we never exceed 80 chars - const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; - if (plainLength > 80) { - // Truncate with ellipsis if too long - const visibleText = statusLine.replace(/\x1b\[[0-9;]*m/g, ''); - const truncated = visibleText.substring(0, 77) + '...'; - console.log(truncated); - } else { - console.log(statusLine); - } } async function main() { - // Check if we're in a piped/non-TTY environment first - if (!process.stdin.isTTY) { - // We're receiving piped input - const input = await readStdin(); - if (input && input.trim() !== '') { - try { - const data: StatusJSON = JSON.parse(input); - await renderStatusLine(data); - } catch (error) { - console.error('Error parsing JSON:', error); - process.exit(1); - } + // Check if we're in a piped/non-TTY environment first + if (!process.stdin.isTTY) { + // We're receiving piped input + const input = await readStdin(); + if (input && input.trim() !== '') { + try { + const data: StatusJSON = JSON.parse(input); + await renderStatusLine(data); + } catch (error) { + console.error('Error parsing JSON:', error); + process.exit(1); + } + } else { + console.error('No input received'); + process.exit(1); + } } else { - console.error('No input received'); - process.exit(1); + // Interactive mode - run TUI + runTUI(); } - } else { - // Interactive mode - run TUI - await runTUI(); - } } main(); \ No newline at end of file diff --git a/src/claude-settings.ts b/src/claude-settings.ts index 543f4a8..96f4dc4 100644 --- a/src/claude-settings.ts +++ b/src/claude-settings.ts @@ -11,64 +11,64 @@ const mkdir = fs.promises?.mkdir || promisify(fs.mkdir); const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json'); interface ClaudeSettings { - permissions?: { - allow?: string[]; - deny?: string[]; - }; - statusLine?: { - type: string; - command: string; - padding?: number; - }; - [key: string]: any; + permissions?: { + allow?: string[]; + deny?: string[]; + }; + statusLine?: { + type: string; + command: string; + padding?: number; + }; + [key: string]: any; } export async function loadClaudeSettings(): Promise { - try { - if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) { - return {}; + try { + if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) { + return {}; + } + const content = await readFile(CLAUDE_SETTINGS_PATH, 'utf-8'); + return JSON.parse(content); + } catch { + return {}; } - const content = await readFile(CLAUDE_SETTINGS_PATH, 'utf-8'); - return JSON.parse(content); - } catch { - return {}; - } } export async function saveClaudeSettings(settings: ClaudeSettings): Promise { - const dir = path.dirname(CLAUDE_SETTINGS_PATH); - await mkdir(dir, { recursive: true }); - await writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8'); + const dir = path.dirname(CLAUDE_SETTINGS_PATH); + await mkdir(dir, { recursive: true }); + await writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8'); } export async function isInstalled(): Promise { - const settings = await loadClaudeSettings(); - return settings.statusLine?.command === 'npx ccstatusline'; + const settings = await loadClaudeSettings(); + return settings.statusLine?.command === 'npx ccstatusline'; } export async function installStatusLine(): Promise { - const settings = await loadClaudeSettings(); - - // Update settings with our status line (confirmation already handled in TUI) - settings.statusLine = { - type: 'command', - command: 'npx ccstatusline', - padding: 1 - }; - - await saveClaudeSettings(settings); + const settings = await loadClaudeSettings(); + + // Update settings with our status line (confirmation already handled in TUI) + settings.statusLine = { + type: 'command', + command: 'npx ccstatusline', + padding: 1 + }; + + await saveClaudeSettings(settings); } export async function uninstallStatusLine(): Promise { - const settings = await loadClaudeSettings(); - - if (settings.statusLine) { - delete settings.statusLine; - await saveClaudeSettings(settings); - } + const settings = await loadClaudeSettings(); + + if (settings.statusLine) { + delete settings.statusLine; + await saveClaudeSettings(settings); + } } export async function getExistingStatusLine(): Promise { - const settings = await loadClaudeSettings(); - return settings.statusLine?.command || null; + const settings = await loadClaudeSettings(); + return settings.statusLine?.command || null; } \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 572cb0a..334f356 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,95 +9,95 @@ const writeFile = fs.promises?.writeFile || promisify(fs.writeFile); const mkdir = fs.promises?.mkdir || promisify(fs.mkdir); export type StatusItemType = 'model' | 'git-branch' | 'separator' | 'flex-separator' | - 'tokens-input' | 'tokens-output' | 'tokens-cached' | 'tokens-total' | 'context-length' | 'context-percentage'; + 'tokens-input' | 'tokens-output' | 'tokens-cached' | 'tokens-total' | 'context-length' | 'context-percentage'; export interface StatusItem { - id: string; - type: StatusItemType; - color?: string; + id: string; + type: StatusItemType; + color?: string; } export interface Settings { - items: StatusItem[]; - colors: { - model: string; - gitBranch: string; - separator: string; - }; + items: StatusItem[]; + colors: { + model: string; + gitBranch: string; + separator: string; + }; } const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccstatusline'); const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json'); export const DEFAULT_SETTINGS: Settings = { - items: [ - { id: '1', type: 'model', color: 'cyan' }, - { id: '2', type: 'separator' }, - { id: '3', type: 'git-branch', color: 'magenta' }, - ], - colors: { - model: 'cyan', - gitBranch: 'magenta', - separator: 'dim', - }, + items: [ + { id: '1', type: 'model', color: 'cyan' }, + { id: '2', type: 'separator' }, + { id: '3', type: 'git-branch', color: 'magenta' }, + ], + colors: { + model: 'cyan', + gitBranch: 'magenta', + separator: 'dim', + }, }; export async function loadSettings(): Promise { - try { - // Use Node.js-compatible file reading - if (!fs.existsSync(SETTINGS_PATH)) { - return DEFAULT_SETTINGS; + try { + // Use Node.js-compatible file reading + if (!fs.existsSync(SETTINGS_PATH)) { + return DEFAULT_SETTINGS; + } + + const content = await readFile(SETTINGS_PATH, 'utf-8'); + const loaded = JSON.parse(content); + + // Migrate old format if needed + if (loaded.elements || loaded.layout) { + return migrateOldSettings(loaded); + } + + return { ...DEFAULT_SETTINGS, ...loaded }; + } catch { + return DEFAULT_SETTINGS; } - - const content = await readFile(SETTINGS_PATH, 'utf-8'); - const loaded = JSON.parse(content); - - // Migrate old format if needed - if (loaded.elements || loaded.layout) { - return migrateOldSettings(loaded); - } - - return { ...DEFAULT_SETTINGS, ...loaded }; - } catch { - return DEFAULT_SETTINGS; - } } function migrateOldSettings(old: any): Settings { - const items: StatusItem[] = []; - let id = 1; - - if (old.elements?.model) { - items.push({ id: String(id++), type: 'model', color: old.colors?.model }); - } - - if (items.length > 0 && old.elements?.gitBranch) { - items.push({ id: String(id++), type: 'separator' }); - } - - if (old.elements?.gitBranch) { - items.push({ id: String(id++), type: 'git-branch', color: old.colors?.gitBranch }); - } - - if (old.layout?.expandingSeparators) { - // Replace regular separators with flex separators - items.forEach(item => { - if (item.type === 'separator') { - item.type = 'flex-separator'; - } - }); - } - - return { - items, - colors: old.colors || DEFAULT_SETTINGS.colors, - }; + const items: StatusItem[] = []; + let id = 1; + + if (old.elements?.model) { + items.push({ id: String(id++), type: 'model', color: old.colors?.model }); + } + + if (items.length > 0 && old.elements?.gitBranch) { + items.push({ id: String(id++), type: 'separator' }); + } + + if (old.elements?.gitBranch) { + items.push({ id: String(id++), type: 'git-branch', color: old.colors?.gitBranch }); + } + + if (old.layout?.expandingSeparators) { + // Replace regular separators with flex separators + items.forEach(item => { + if (item.type === 'separator') { + item.type = 'flex-separator'; + } + }); + } + + return { + items, + colors: old.colors || DEFAULT_SETTINGS.colors, + }; } export async function saveSettings(settings: Settings): Promise { - // Ensure config directory exists - await mkdir(CONFIG_DIR, { recursive: true }); - - // Write settings using Node.js-compatible API - await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8'); + // Ensure config directory exists + await mkdir(CONFIG_DIR, { recursive: true }); + + // Write settings using Node.js-compatible API + await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8'); } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/src/tui.ts b/src/tui.ts deleted file mode 100644 index 3c80fdf..0000000 --- a/src/tui.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as p from '@clack/prompts'; -import chalk from 'chalk'; -import { loadSettings, saveSettings, type Settings } from './config'; - -const COLOR_OPTIONS = [ - { value: 'black', label: chalk.black('black') }, - { value: 'red', label: chalk.red('red') }, - { value: 'green', label: chalk.green('green') }, - { value: 'yellow', label: chalk.yellow('yellow') }, - { value: 'blue', label: chalk.blue('blue') }, - { value: 'magenta', label: chalk.magenta('magenta') }, - { value: 'cyan', label: chalk.cyan('cyan') }, - { value: 'white', label: chalk.white('white') }, - { value: 'gray', label: chalk.gray('gray') }, - { value: 'redBright', label: chalk.redBright('redBright') }, - { value: 'greenBright', label: chalk.greenBright('greenBright') }, - { value: 'yellowBright', label: chalk.yellowBright('yellowBright') }, - { value: 'blueBright', label: chalk.blueBright('blueBright') }, - { value: 'magentaBright', label: chalk.magentaBright('magentaBright') }, - { value: 'cyanBright', label: chalk.cyanBright('cyanBright') }, - { value: 'whiteBright', label: chalk.whiteBright('whiteBright') }, -]; - -function previewStatusLine(settings: Settings): string { - const elements: string[] = []; - - if (settings.elements.model) { - const modelColor = (chalk as any)[settings.colors.model] || chalk.white; - elements.push(modelColor('Model: Claude')); - } - - if (settings.elements.gitBranch) { - const branchColor = (chalk as any)[settings.colors.gitBranch] || chalk.white; - elements.push(branchColor('Branch: main')); - } - - const separatorColor = (chalk as any)[settings.colors.separator] || chalk.dim; - return elements.join(separatorColor(' | ')); -} - -export async function runTUI() { - let settings = await loadSettings(); - - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - - let continueLoop = true; - - while (continueLoop) { - console.log('\n' + chalk.dim('Current preview:')); - console.log(' ' + previewStatusLine(settings) + '\n'); - - const action = await p.select({ - message: 'What would you like to configure?', - options: [ - { value: 'elements', label: '🔧 Toggle Elements' }, - { value: 'colors', label: '🎨 Configure Colors' }, - { value: 'save', label: '💾 Save & Exit' }, - { value: 'exit', label: '❌ Exit without saving' }, - ], - }); - - if (p.isCancel(action)) { - continueLoop = false; - break; - } - - switch (action) { - case 'elements': - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - console.log('\n' + chalk.dim('Current preview:')); - console.log(' ' + previewStatusLine(settings) + '\n'); - const selectedElements = await p.multiselect({ - message: 'Select which elements to display:', - options: [ - { - value: 'model', - label: 'Model', - hint: settings.elements.model ? 'currently enabled' : 'currently disabled' - }, - { - value: 'gitBranch', - label: 'Git Branch', - hint: settings.elements.gitBranch ? 'currently enabled' : 'currently disabled' - }, - ], - initialValues: Object.entries(settings.elements) - .filter(([_, enabled]) => enabled) - .map(([key, _]) => key), - required: false, - }); - - if (!p.isCancel(selectedElements)) { - settings.elements.model = selectedElements.includes('model'); - settings.elements.gitBranch = selectedElements.includes('gitBranch'); - } - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - break; - - case 'colors': - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - console.log('\n' + chalk.dim('Current preview:')); - console.log(' ' + previewStatusLine(settings) + '\n'); - const colorElement = await p.select({ - message: 'Which element color to configure?', - options: [ - { value: 'model', label: 'Model' }, - { value: 'gitBranch', label: 'Git Branch' }, - { value: 'separator', label: 'Separator' }, - { value: 'back', label: '← Back' }, - ], - }); - - if (!p.isCancel(colorElement) && colorElement !== 'back') { - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - console.log('\n' + chalk.dim('Current preview:')); - console.log(' ' + previewStatusLine(settings) + '\n'); - const color = await p.select({ - message: `Select color for ${colorElement}:`, - options: COLOR_OPTIONS, - initialValue: settings.colors[colorElement as keyof typeof settings.colors], - }); - - if (!p.isCancel(color)) { - (settings.colors as any)[colorElement] = color; - } - } - console.clear(); - p.intro(chalk.bold.cyan('🎨 CCStatusline Configuration')); - break; - - case 'save': - await saveSettings(settings); - p.outro(chalk.green('✅ Settings saved successfully!')); - continueLoop = false; - break; - - case 'exit': - p.outro(chalk.yellow('Exited without saving')); - continueLoop = false; - break; - } - } -} \ No newline at end of file diff --git a/src/tui.tsx b/src/tui.tsx index 4eb01fa..5a6995e 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -6,584 +6,610 @@ import { loadSettings, saveSettings, type Settings, type StatusItem, type Status import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './claude-settings'; interface StatusLinePreviewProps { - items: StatusItem[]; - terminalWidth: number; + items: StatusItem[]; + terminalWidth: number; } const StatusLinePreview: React.FC = ({ items, terminalWidth }) => { - const width = 80; // Status line is always 80 chars max - const elements: string[] = []; - let hasFlexSeparator = false; - - items.forEach(item => { - switch (item.type) { - case 'model': - const modelColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; - elements.push(modelColor('Model: Claude')); - break; - case 'git-branch': - const branchColor = (chalk as any)[item.color || 'magenta'] || chalk.magenta; - elements.push(branchColor('⎇ main')); - break; - case 'tokens-input': - const inputColor = (chalk as any)[item.color || 'yellow'] || chalk.yellow; - elements.push(inputColor('In: 15.2k')); - break; - case 'tokens-output': - const outputColor = (chalk as any)[item.color || 'green'] || chalk.green; - elements.push(outputColor('Out: 3.4k')); - break; - case 'tokens-cached': - const cachedColor = (chalk as any)[item.color || 'blue'] || chalk.blue; - elements.push(cachedColor('Cached: 12k')); - break; - case 'tokens-total': - const totalColor = (chalk as any)[item.color || 'white'] || chalk.white; - elements.push(totalColor('Total: 30.6k')); - break; - case 'context-length': - const ctxColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; - elements.push(ctxColor('Ctx: 18.6k')); - break; - case 'context-percentage': - const ctxPctColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; - elements.push(ctxPctColor('Ctx: 9.3%')); - break; - case 'separator': - elements.push(chalk.dim(' | ')); - break; - case 'flex-separator': - elements.push('FLEX'); - hasFlexSeparator = true; - break; - } - }); - - // Build the status line with flex separator support - let statusLine = ''; - if (hasFlexSeparator) { - const parts: string[][] = [[]]; - let currentPart = 0; + const width = 80; // Status line is always 80 chars max + const elements: string[] = []; + let hasFlexSeparator = false; - for (let i = 0; i < items.length; i++) { - if (items[i].type === 'flex-separator') { - currentPart++; - parts[currentPart] = []; - } else { - const element = elements[i]; - if (element !== 'FLEX') { - parts[currentPart].push(element); + items.forEach(item => { + switch (item.type) { + case 'model': + const modelColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; + elements.push(modelColor('Model: Claude')); + break; + case 'git-branch': + const branchColor = (chalk as any)[item.color || 'magenta'] || chalk.magenta; + elements.push(branchColor('⎇ main')); + break; + case 'tokens-input': + const inputColor = (chalk as any)[item.color || 'yellow'] || chalk.yellow; + elements.push(inputColor('In: 15.2k')); + break; + case 'tokens-output': + const outputColor = (chalk as any)[item.color || 'green'] || chalk.green; + elements.push(outputColor('Out: 3.4k')); + break; + case 'tokens-cached': + const cachedColor = (chalk as any)[item.color || 'blue'] || chalk.blue; + elements.push(cachedColor('Cached: 12k')); + break; + case 'tokens-total': + const totalColor = (chalk as any)[item.color || 'white'] || chalk.white; + elements.push(totalColor('Total: 30.6k')); + break; + case 'context-length': + const ctxColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; + elements.push(ctxColor('Ctx: 18.6k')); + break; + case 'context-percentage': + const ctxPctColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; + elements.push(ctxPctColor('Ctx: 9.3%')); + break; + case 'separator': + elements.push(chalk.dim(' | ')); + break; + case 'flex-separator': + elements.push('FLEX'); + hasFlexSeparator = true; + break; } - } - } - - // Calculate total length of all non-flex content - const partLengths = parts.map(part => { - const joined = part.join(''); - return joined.replace(/\x1b\[[0-9;]*m/g, '').length; }); - const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0); - // Calculate space to distribute among flex separators - const flexCount = parts.length - 1; // Number of flex separators - const totalSpace = Math.max(0, width - totalContentLength); - const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; - const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; - - // Build the status line with distributed spacing - statusLine = ''; - for (let i = 0; i < parts.length; i++) { - statusLine += parts[i].join(''); - if (i < parts.length - 1) { - // Add flex spacing - const spaces = spacePerFlex + (i < extraSpace ? 1 : 0); - statusLine += ' '.repeat(spaces); - } + // Build the status line with flex separator support + let statusLine = ''; + if (hasFlexSeparator) { + const parts: string[][] = [[]]; + let currentPart = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item && item.type === 'flex-separator') { + currentPart++; + parts[currentPart] = []; + } else { + const element = elements[i]; + if (element !== 'FLEX' && parts[currentPart]) { + parts[currentPart]!.push(element as string); + } + } + } + + // Calculate total length of all non-flex content + const partLengths = parts.map(part => { + const joined = part.join(''); + return joined.replace(/\x1b\[[0-9;]*m/g, '').length; + }); + const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0); + + // Calculate space to distribute among flex separators + const flexCount = parts.length - 1; // Number of flex separators + const totalSpace = Math.max(0, width - totalContentLength); + const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0; + const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0; + + // Build the status line with distributed spacing + statusLine = ''; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part) { + statusLine += part.join(''); + } + if (i < parts.length - 1) { + // Add flex spacing + const spaces = spacePerFlex + (i < extraSpace ? 1 : 0); + statusLine += ' '.repeat(spaces); + } + } + } else { + statusLine = elements.filter(e => e !== 'FLEX').join(''); } - } else { - statusLine = elements.filter(e => e !== 'FLEX').join(''); - } - - // Build the Claude Code input box - account for ink's padding - const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76); - const topLine = chalk.dim('╭' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╮'); - const middleLine = chalk.dim('│') + ' > ' + ' '.repeat(Math.max(0, boxWidth - 5)) + chalk.dim('│'); - const bottomLine = chalk.dim('╰' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╯'); - - return ( - - {topLine} - {middleLine} - {bottomLine} - {statusLine} - - ); + + // Build the Claude Code input box - account for ink's padding + const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76); + const topLine = chalk.dim('╭' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╮'); + const middleLine = chalk.dim('│') + ' > ' + ' '.repeat(Math.max(0, boxWidth - 5)) + chalk.dim('│'); + const bottomLine = chalk.dim('╰' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╯'); + + return ( + + {topLine} + {middleLine} + {bottomLine} + {statusLine} + + ); }; interface ConfirmDialogProps { - message: string; - onConfirm: () => void; - onCancel: () => void; + message: string; + onConfirm: () => void; + onCancel: () => void; } const ConfirmDialog: React.FC = ({ message, onConfirm, onCancel }) => { - const items = [ - { label: '✅ Yes', value: 'yes' }, - { label: '❌ No', value: 'no' }, - ]; - - return ( - - {message} - - item.value === 'yes' ? onConfirm() : onCancel()} - /> - - - ); + const items = [ + { label: '✅ Yes', value: 'yes' }, + { label: '❌ No', value: 'no' }, + ]; + + return ( + + {message} + + item.value === 'yes' ? onConfirm() : onCancel()} + /> + + + ); }; interface MainMenuProps { - onSelect: (value: string) => void; - isClaudeInstalled: boolean; - hasChanges: boolean; + onSelect: (value: string) => void; + isClaudeInstalled: boolean; + hasChanges: boolean; } const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, hasChanges }) => { - const items = [ - { label: '📝 Edit Status Line Items', value: 'items' }, - { label: '🎨 Configure Colors', value: 'colors' }, - { label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' }, - ]; - - if (hasChanges) { - items.push( - { label: '💾 Save & Exit', value: 'save' }, - { label: '❌ Exit without saving', value: 'exit' } + const items = [ + { label: '📝 Edit Status Line Items', value: 'items' }, + { label: '🎨 Configure Colors', value: 'colors' }, + { label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' }, + ]; + + if (hasChanges) { + items.push( + { label: '💾 Save & Exit', value: 'save' }, + { label: '❌ Exit without saving', value: 'exit' } + ); + } else { + items.push({ label: '🚪 Exit', value: 'exit' }); + } + + return ( + + Main Menu + + onSelect(item.value)} /> + + ); - } else { - items.push({ label: '🚪 Exit', value: 'exit' }); - } - - return ( - - Main Menu - - onSelect(item.value)} /> - - - ); }; interface ItemsEditorProps { - items: StatusItem[]; - onUpdate: (items: StatusItem[]) => void; - onBack: () => void; + items: StatusItem[]; + onUpdate: (items: StatusItem[]) => void; + onBack: () => void; } const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const [moveMode, setMoveMode] = useState(false); - - useInput((input, key) => { - if (moveMode) { - // In move mode, use up/down to move the selected item - if (key.upArrow && selectedIndex > 0) { - const newItems = [...items]; - [newItems[selectedIndex], newItems[selectedIndex - 1]] = - [newItems[selectedIndex - 1], newItems[selectedIndex]]; - onUpdate(newItems); - setSelectedIndex(selectedIndex - 1); - } else if (key.downArrow && selectedIndex < items.length - 1) { - const newItems = [...items]; - [newItems[selectedIndex], newItems[selectedIndex + 1]] = - [newItems[selectedIndex + 1], newItems[selectedIndex]]; - onUpdate(newItems); - setSelectedIndex(selectedIndex + 1); - } else if (key.escape || key.return) { - // Exit move mode - setMoveMode(false); - } - } else { - // Normal mode - if (key.upArrow) { - setSelectedIndex(Math.max(0, selectedIndex - 1)); - } else if (key.downArrow) { - setSelectedIndex(Math.min(items.length - 1, selectedIndex + 1)); - } else if (key.leftArrow && items.length > 0) { - // Toggle item type backwards - const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; - const currentType = items[selectedIndex].type; - const currentIndex = types.indexOf(currentType); - const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1; - const newItems = [...items]; - newItems[selectedIndex] = { ...newItems[selectedIndex], type: types[prevIndex] }; - onUpdate(newItems); - } else if (key.rightArrow && items.length > 0) { - // Toggle item type forwards - const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; - const currentType = items[selectedIndex].type; - const currentIndex = types.indexOf(currentType); - const nextIndex = (currentIndex + 1) % types.length; - const newItems = [...items]; - newItems[selectedIndex] = { ...newItems[selectedIndex], type: types[nextIndex] }; - onUpdate(newItems); - } else if (key.return && items.length > 0) { - // Enter move mode - setMoveMode(true); - } else if (input === 'a') { - // Add item at end - const newItem: StatusItem = { - id: Date.now().toString(), - type: 'separator', - }; - onUpdate([...items, newItem]); - } else if (input === 'i') { - // Insert item after selected - const newItem: StatusItem = { - id: Date.now().toString(), - type: 'separator', - }; - const newItems = [...items]; - newItems.splice(selectedIndex + 1, 0, newItem); - onUpdate(newItems); - setSelectedIndex(selectedIndex + 1); - } else if (input === 'd' && items.length > 0) { - // Delete selected item - const newItems = items.filter((_, i) => i !== selectedIndex); - onUpdate(newItems); - if (selectedIndex >= newItems.length && selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1); + const [selectedIndex, setSelectedIndex] = useState(0); + const [moveMode, setMoveMode] = useState(false); + + useInput((input, key) => { + if (moveMode) { + // In move mode, use up/down to move the selected item + if (key.upArrow && selectedIndex > 0) { + const newItems = [...items]; + const temp = newItems[selectedIndex]; + const prev = newItems[selectedIndex - 1]; + if (temp && prev) { + [newItems[selectedIndex], newItems[selectedIndex - 1]] = [prev, temp]; + } + onUpdate(newItems); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < items.length - 1) { + const newItems = [...items]; + const temp = newItems[selectedIndex]; + const next = newItems[selectedIndex + 1]; + if (temp && next) { + [newItems[selectedIndex], newItems[selectedIndex + 1]] = [next, temp]; + } + onUpdate(newItems); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + // Exit move mode + setMoveMode(false); + } + } else { + // Normal mode + if (key.upArrow) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + } else if (key.downArrow) { + setSelectedIndex(Math.min(items.length - 1, selectedIndex + 1)); + } else if (key.leftArrow && items.length > 0) { + // Toggle item type backwards + const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; + const currentItem = items[selectedIndex]; + if (currentItem) { + const currentType = currentItem.type; + const currentIndex = types.indexOf(currentType); + const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1; + const newItems = [...items]; + const prevType = types[prevIndex]; + if (prevType) { + newItems[selectedIndex] = { ...currentItem, type: prevType }; + onUpdate(newItems); + } + } + } else if (key.rightArrow && items.length > 0) { + // Toggle item type forwards + const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; + const currentItem = items[selectedIndex]; + if (currentItem) { + const currentType = currentItem.type; + const currentIndex = types.indexOf(currentType); + const nextIndex = (currentIndex + 1) % types.length; + const newItems = [...items]; + const nextType = types[nextIndex]; + if (nextType) { + newItems[selectedIndex] = { ...currentItem, type: nextType }; + onUpdate(newItems); + } + } + } else if (key.return && items.length > 0) { + // Enter move mode + setMoveMode(true); + } else if (input === 'a') { + // Add item at end + const newItem: StatusItem = { + id: Date.now().toString(), + type: 'separator', + }; + onUpdate([...items, newItem]); + } else if (input === 'i') { + // Insert item after selected + const newItem: StatusItem = { + id: Date.now().toString(), + type: 'separator', + }; + const newItems = [...items]; + newItems.splice(selectedIndex + 1, 0, newItem); + onUpdate(newItems); + setSelectedIndex(selectedIndex + 1); + } else if (input === 'd' && items.length > 0) { + // Delete selected item + const newItems = items.filter((_, i) => i !== selectedIndex); + onUpdate(newItems); + if (selectedIndex >= newItems.length && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + } else if (key.escape) { + onBack(); + } } - } else if (key.escape) { - onBack(); - } - } - }); - - const getItemDisplay = (item: StatusItem) => { - switch (item.type) { - case 'model': - return chalk.cyan('Model'); - case 'git-branch': - return chalk.magenta('Git Branch'); - case 'separator': - return chalk.dim('Separator |'); - case 'flex-separator': - return chalk.yellow('Flex Separator ─────'); - case 'tokens-input': - return chalk.yellow('Tokens Input'); - case 'tokens-output': - return chalk.green('Tokens Output'); - case 'tokens-cached': - return chalk.blue('Tokens Cached'); - case 'tokens-total': - return chalk.white('Tokens Total'); - case 'context-length': - return chalk.cyan('Context Length'); - case 'context-percentage': - return chalk.cyan('Context %'); - } - }; - - return ( - - Edit Status Line Items {moveMode && [MOVE MODE]} - {moveMode ? ( - ↑↓ to move item, ESC or Enter to exit move mode - ) : ( - ↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, ESC back - )} - - {items.length === 0 ? ( - No items. Press 'a' to add one. - ) : ( - items.map((item, index) => ( - - - {index === selectedIndex ? (moveMode ? '◆ ' : '▶ ') : ' '} - {index + 1}. {getItemDisplay(item)} - + }); + + const getItemDisplay = (item: StatusItem) => { + switch (item.type) { + case 'model': + return chalk.cyan('Model'); + case 'git-branch': + return chalk.magenta('Git Branch'); + case 'separator': + return chalk.dim('Separator |'); + case 'flex-separator': + return chalk.yellow('Flex Separator ─────'); + case 'tokens-input': + return chalk.yellow('Tokens Input'); + case 'tokens-output': + return chalk.green('Tokens Output'); + case 'tokens-cached': + return chalk.blue('Tokens Cached'); + case 'tokens-total': + return chalk.white('Tokens Total'); + case 'context-length': + return chalk.cyan('Context Length'); + case 'context-percentage': + return chalk.cyan('Context %'); + } + }; + + return ( + + Edit Status Line Items {moveMode && [MOVE MODE]} + {moveMode ? ( + ↑↓ to move item, ESC or Enter to exit move mode + ) : ( + ↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, ESC back + )} + + {items.length === 0 ? ( + No items. Press 'a' to add one. + ) : ( + items.map((item, index) => ( + + + {index === selectedIndex ? (moveMode ? '◆ ' : '▶ ') : ' '} + {index + 1}. {getItemDisplay(item)} + + + )) + )} - )) - )} - - - ); + + ); }; interface ColorMenuProps { - items: StatusItem[]; - onUpdate: (items: StatusItem[]) => void; - onBack: () => void; + items: StatusItem[]; + onUpdate: (items: StatusItem[]) => void; + onBack: () => void; } const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { - const colorableItems = items.filter(item => - ['model', 'git-branch', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) - ); - const [selectedIndex, setSelectedIndex] = useState(0); - - // Handle ESC key - useInput((input, key) => { - if (key.escape) { - onBack(); - } - }); - - if (colorableItems.length === 0) { - return ( - - Configure Colors - No colorable items in the status line. - Add a Model or Git Branch item first. - Press any key to go back... - {useInput(() => onBack())} - + const colorableItems = items.filter(item => + ['model', 'git-branch', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) ); - } - - const getItemLabel = (item: StatusItem) => { - switch (item.type) { - case 'model': return 'Model'; - case 'git-branch': return 'Git Branch'; - case 'tokens-input': return 'Tokens Input'; - case 'tokens-output': return 'Tokens Output'; - case 'tokens-cached': return 'Tokens Cached'; - case 'tokens-total': return 'Tokens Total'; - case 'context-length': return 'Context Length'; - case 'context-percentage': return 'Context Percentage'; - default: return item.type; - } - }; - - // Create menu items with colored labels - const menuItems = colorableItems.map((item, index) => { - const color = item.color || 'white'; - const colorFunc = (chalk as any)[color] || chalk.white; - return { - label: colorFunc(`${getItemLabel(item)} #${index + 1}`), - value: item.id, - }; - }); - menuItems.push({ label: '← Back', value: 'back' }); - - const handleSelect = (selected: { value: string }) => { - if (selected.value === 'back') { - onBack(); - } else { - // Cycle through colors - const newItems = items.map(item => { - if (item.id === selected.value) { - const currentColorIndex = colors.indexOf(item.color || 'white'); - const nextColor = colors[(currentColorIndex + 1) % colors.length]; - return { ...item, color: nextColor }; + const [selectedIndex, setSelectedIndex] = useState(0); + + // Handle ESC key + useInput((input, key) => { + if (key.escape) { + onBack(); } - return item; - }); - onUpdate(newItems); + }); + + if (colorableItems.length === 0) { + return ( + + Configure Colors + No colorable items in the status line. + Add a Model or Git Branch item first. + Press any key to go back... + {/* Press any key handler */} + {(() => { + useInput(() => { onBack(); }); + return null; + })()} + + ); } - }; - - const handleHighlight = (item: { value: string }) => { - if (item.value !== 'back') { - const itemIndex = colorableItems.findIndex(i => i.id === item.value); - if (itemIndex !== -1) { - setSelectedIndex(itemIndex); - } - } - }; - - // Color list for cycling - const colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', - 'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', - 'magentaBright', 'cyanBright', 'whiteBright']; - - // Get current color for selected item (if a valid colorable item is selected) - const selectedItem = selectedIndex < colorableItems.length ? colorableItems[selectedIndex] : null; - const currentColor = selectedItem ? (selectedItem.color || 'white') : 'white'; - const colorIndex = colors.indexOf(currentColor); - const colorNumber = colorIndex === -1 ? 8 : colorIndex + 1; // Default to white (8) if not found - const colorDisplay = (chalk as any)[currentColor] ? (chalk as any)[currentColor](currentColor) : chalk.white(currentColor); - - return ( - - Configure Colors - ↑↓ to select item, Enter to cycle color, ESC to go back - {selectedItem && ( - - Current color ({colorNumber}/{colors.length}): {colorDisplay} - - )} - - - - - ); + + const getItemLabel = (item: StatusItem) => { + switch (item.type) { + case 'model': return 'Model'; + case 'git-branch': return 'Git Branch'; + case 'tokens-input': return 'Tokens Input'; + case 'tokens-output': return 'Tokens Output'; + case 'tokens-cached': return 'Tokens Cached'; + case 'tokens-total': return 'Tokens Total'; + case 'context-length': return 'Context Length'; + case 'context-percentage': return 'Context Percentage'; + default: return item.type; + } + }; + + // Create menu items with colored labels + const menuItems = colorableItems.map((item, index) => { + const color = item.color || 'white'; + const colorFunc = (chalk as any)[color] || chalk.white; + return { + label: colorFunc(`${getItemLabel(item)} #${index + 1}`), + value: item.id, + }; + }); + menuItems.push({ label: '← Back', value: 'back' }); + + const handleSelect = (selected: { value: string }) => { + if (selected.value === 'back') { + onBack(); + } else { + // Cycle through colors + const newItems = items.map(item => { + if (item.id === selected.value) { + const currentColorIndex = colors.indexOf(item.color || 'white'); + const nextColor = colors[(currentColorIndex + 1) % colors.length]; + return { ...item, color: nextColor }; + } + return item; + }); + onUpdate(newItems); + } + }; + + const handleHighlight = (item: { value: string }) => { + if (item.value !== 'back') { + const itemIndex = colorableItems.findIndex(i => i.id === item.value); + if (itemIndex !== -1) { + setSelectedIndex(itemIndex); + } + } + }; + + // Color list for cycling + const colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', + 'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright', + 'magentaBright', 'cyanBright', 'whiteBright']; + + // Get current color for selected item (if a valid colorable item is selected) + const selectedItem = selectedIndex < colorableItems.length ? colorableItems[selectedIndex] : null; + const currentColor = selectedItem ? (selectedItem.color || 'white') : 'white'; + const colorIndex = colors.indexOf(currentColor); + const colorNumber = colorIndex === -1 ? 8 : colorIndex + 1; // Default to white (8) if not found + const colorDisplay = (chalk as any)[currentColor] ? (chalk as any)[currentColor](currentColor) : chalk.white(currentColor); + + return ( + + Configure Colors + ↑↓ to select item, Enter to cycle color, ESC to go back + {selectedItem && ( + + Current color ({colorNumber}/{colors.length}): {colorDisplay} + + )} + + + + + ); }; const App: React.FC = () => { - const { exit } = useApp(); - const [settings, setSettings] = useState(null); - const [originalSettings, setOriginalSettings] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - const [screen, setScreen] = useState<'main' | 'items' | 'colors' | 'confirm'>('main'); - const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise } | null>(null); - const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); - const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); - - useEffect(() => { - loadSettings().then(loadedSettings => { - setSettings(loadedSettings); - setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings))); // Deep copy + const { exit } = useApp(); + const [settings, setSettings] = useState(null); + const [originalSettings, setOriginalSettings] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + const [screen, setScreen] = useState<'main' | 'items' | 'colors' | 'confirm'>('main'); + const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise } | null>(null); + const [isClaudeInstalled, setIsClaudeInstalled] = useState(false); + const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80); + + useEffect(() => { + loadSettings().then(loadedSettings => { + setSettings(loadedSettings); + setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings))); // Deep copy + }); + isInstalled().then(setIsClaudeInstalled); + + const handleResize = () => { + setTerminalWidth(process.stdout.columns || 80); + }; + + process.stdout.on('resize', handleResize); + return () => { + process.stdout.off('resize', handleResize); + }; + }, []); + + // Check for changes whenever settings update + useEffect(() => { + if (settings && originalSettings) { + const hasAnyChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings); + setHasChanges(hasAnyChanges); + } + }, [settings, originalSettings]); + + useInput((input, key) => { + if (key.ctrl && input === 'c') { + exit(); + } }); - isInstalled().then(setIsClaudeInstalled); - const handleResize = () => { - setTerminalWidth(process.stdout.columns || 80); + if (!settings) { + return Loading settings...; + } + + const handleInstallUninstall = async () => { + if (isClaudeInstalled) { + // Uninstall + setConfirmDialog({ + message: 'This will remove ccstatusline from ~/.claude/settings.json. Continue?', + action: async () => { + await uninstallStatusLine(); + setIsClaudeInstalled(false); + setScreen('main'); + setConfirmDialog(null); + } + }); + setScreen('confirm'); + } else { + // Always ask for consent before modifying Claude settings + const existing = await getExistingStatusLine(); + let message: string; + + if (existing && existing !== 'npx ccstatusline') { + message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with ccstatusline?`; + } else if (existing === 'npx ccstatusline') { + message = 'ccstatusline is already installed in ~/.claude/settings.json\nReinstall it?'; + } else { + message = 'This will modify ~/.claude/settings.json to add ccstatusline.\nContinue?'; + } + + setConfirmDialog({ + message, + action: async () => { + await installStatusLine(); + setIsClaudeInstalled(true); + setScreen('main'); + setConfirmDialog(null); + } + }); + setScreen('confirm'); + } }; - process.stdout.on('resize', handleResize); - return () => { - process.stdout.off('resize', handleResize); + const handleMainMenuSelect = async (value: string) => { + switch (value) { + case 'items': + setScreen('items'); + break; + case 'colors': + setScreen('colors'); + break; + case 'install': + await handleInstallUninstall(); + break; + case 'save': + await saveSettings(settings); + setOriginalSettings(JSON.parse(JSON.stringify(settings))); // Update original after save + setHasChanges(false); + exit(); + break; + case 'exit': + exit(); + break; + } }; - }, []); - - // Check for changes whenever settings update - useEffect(() => { - if (settings && originalSettings) { - const hasAnyChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings); - setHasChanges(hasAnyChanges); - } - }, [settings, originalSettings]); - - useInput((input, key) => { - if (key.ctrl && input === 'c') { - exit(); - } - }); - - if (!settings) { - return Loading settings...; - } - - const handleInstallUninstall = async () => { - if (isClaudeInstalled) { - // Uninstall - setConfirmDialog({ - message: 'This will remove ccstatusline from ~/.claude/settings.json. Continue?', - action: async () => { - await uninstallStatusLine(); - setIsClaudeInstalled(false); - setScreen('main'); - setConfirmDialog(null); - } - }); - setScreen('confirm'); - } else { - // Always ask for consent before modifying Claude settings - const existing = await getExistingStatusLine(); - let message: string; - - if (existing && existing !== 'npx ccstatusline') { - message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with ccstatusline?`; - } else if (existing === 'npx ccstatusline') { - message = 'ccstatusline is already installed in ~/.claude/settings.json\nReinstall it?'; - } else { - message = 'This will modify ~/.claude/settings.json to add ccstatusline.\nContinue?'; - } - - setConfirmDialog({ - message, - action: async () => { - await installStatusLine(); - setIsClaudeInstalled(true); - setScreen('main'); - setConfirmDialog(null); - } - }); - setScreen('confirm'); - } - }; - - const handleMainMenuSelect = async (value: string) => { - switch (value) { - case 'items': - setScreen('items'); - break; - case 'colors': - setScreen('colors'); - break; - case 'install': - await handleInstallUninstall(); - break; - case 'save': - await saveSettings(settings); - setOriginalSettings(JSON.parse(JSON.stringify(settings))); // Update original after save - setHasChanges(false); - exit(); - break; - case 'exit': - exit(); - break; - } - }; - - const updateItems = (items: StatusItem[]) => { - setSettings({ ...settings, items }); - }; - - return ( - - - 🎨 CCStatusline Configuration - - - - Preview: - - - - - {screen === 'main' && } - {screen === 'items' && ( - setScreen('main')} - /> - )} - {screen === 'colors' && ( - setScreen('main')} - /> - )} - {screen === 'confirm' && confirmDialog && ( - { - setScreen('main'); - setConfirmDialog(null); - }} - /> - )} - - - ); + + const updateItems = (items: StatusItem[]) => { + setSettings({ ...settings, items }); + }; + + return ( + + + 🎨 CCStatusline Configuration + + + + Preview: + + + + + {screen === 'main' && } + {screen === 'items' && ( + setScreen('main')} + /> + )} + {screen === 'colors' && ( + setScreen('main')} + /> + )} + {screen === 'confirm' && confirmDialog && ( + { + setScreen('main'); + setConfirmDialog(null); + }} + /> + )} + + + ); }; export function runTUI() { - render(); + render(); } \ No newline at end of file