From 54f047c132053935efc61fc8937b111073c8fbf5 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 8 Aug 2025 17:22:54 -0400 Subject: [PATCH] Terminal width detection, multi line support, session timer widget --- README.md | 70 ++++++++------ package.json | 2 +- src/ccstatusline.ts | 226 ++++++++++++++++++++++++++++++++++++------- src/config.ts | 53 +++++++--- src/tui.tsx | 230 ++++++++++++++++++++++++++++++++++++++------ 5 files changed, 474 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 9e50d1f..e23a6a7 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ A customizable status line formatter for Claude Code CLI that displays model inf ## Features -- 📊 **Real-time metrics** - Display model name, git branch, token usage, and more +- 📊 **Real-time metrics** - Display model name, git branch, token usage, session duration, and more - 🎨 **Fully customizable** - Choose what to display and customize colors +- 📐 **Multi-line support** - Configure up to 3 status lines - 🖥️ **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 +- 📏 **Auto-width detection** - Automatically adapts to terminal width with flex separators ## Quick Start @@ -45,12 +46,15 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet") - **Git Branch** - Displays current git branch name +- **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10") +- **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m") - **Tokens Input** - Shows input tokens used - **Tokens Output** - Shows output tokens used - **Tokens Cached** - Shows cached tokens used - **Tokens Total** - Shows total tokens used - **Context Length** - Shows current context length in tokens - **Context Percentage** - Shows percentage of context limit used +- **Terminal Width** - Shows detected terminal width (for debugging) - **Separator** - Visual divider between items (|) - **Flex Separator** - Expands to fill available space @@ -60,36 +64,38 @@ The configuration file at `~/.config/ccstatusline/settings.json` looks like: ```json { - "items": [ - { - "type": "model", - "color": "cyan" - }, - { - "type": "separator", - "color": "gray" - }, - { - "type": "git-branch", - "color": "green" - }, - { - "type": "separator", - "color": "gray" - }, - { - "type": "tokens-total", - "color": "yellow" - }, - { - "type": "flex-separator", - "color": "gray" - }, - { - "type": "context-percentage", - "color": "blue" - } - ] + "lines": [ + [ + { + "id": "1", + "type": "model", + "color": "cyan" + }, + { + "id": "2", + "type": "separator" + }, + { + "id": "3", + "type": "git-branch", + "color": "magenta" + }, + { + "id": "4", + "type": "separator" + }, + { + "id": "5", + "type": "session-clock", + "color": "blue" + } + ] + ], + "colors": { + "model": "cyan", + "gitBranch": "magenta", + "separator": "dim" + } } ``` diff --git a/package.json b/package.json index c2fdc3b..583ceb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "1.0.6", + "version": "1.0.7", "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 bfdbc1a..36474b3 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { execSync } from 'child_process'; import { runTUI } from './tui.tsx'; -import { loadSettings } from './config'; +import { loadSettings, type StatusItem } from './config'; import * as fs from 'fs'; import { promisify } from 'util'; @@ -68,6 +68,54 @@ async function readStdin(): Promise { } } +function getTerminalWidth(): number | null { + try { + // First try to get the tty of the parent process + const tty = execSync('ps -o tty= -p $(ps -o ppid= -p $$)', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + shell: '/bin/sh' + }).trim(); + + // Check if we got a valid tty (not ?? which means no tty) + if (tty && tty !== '??' && tty !== '?') { + // Now get the terminal size + const width = execSync( + `stty size < /dev/${tty} | awk '{print $2}'`, + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + shell: '/bin/sh' + } + ).trim(); + + const parsed = parseInt(width, 10); + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } + } catch { + // Command failed, width detection not available + } + + // Fallback: try tput cols which might work in some environments + try { + const width = execSync('tput cols 2>/dev/null', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + + const parsed = parseInt(width, 10); + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } catch { + // tput also failed + } + + return null; +} + function getGitBranch(): string | null { try { const branch = execSync('git branch --show-current 2>/dev/null', { @@ -124,6 +172,77 @@ function getGitChanges(): { insertions: number; deletions: number } | null { } } +async function getSessionDuration(transcriptPath: string): Promise { + try { + if (!fs.existsSync(transcriptPath)) { + return null; + } + + const content = await readFile(transcriptPath, 'utf-8'); + const lines = content.trim().split('\n').filter(line => line.trim()); + + if (lines.length === 0) { + return null; + } + + let firstTimestamp: Date | null = null; + let lastTimestamp: Date | null = null; + + // Find first valid timestamp + for (const line of lines) { + try { + const data = JSON.parse(line); + if (data.timestamp) { + firstTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } + + // Find last valid timestamp (iterate backwards) + for (let i = lines.length - 1; i >= 0; i--) { + try { + const data = JSON.parse(lines[i]); + if (data.timestamp) { + lastTimestamp = new Date(data.timestamp); + break; + } + } catch { + // Skip invalid lines + } + } + + if (!firstTimestamp || !lastTimestamp) { + return null; + } + + // Calculate duration in milliseconds + const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); + + // Convert to minutes + const totalMinutes = Math.floor(durationMs / (1000 * 60)); + + if (totalMinutes < 1) { + return '<1m'; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours === 0) { + return `${minutes}m`; + } else if (minutes === 0) { + return `${hours}hr`; + } else { + return `${hours}hr ${minutes}m`; + } + } catch { + return null; + } +} + async function getTokenMetrics(transcriptPath: string): Promise<{ inputTokens: number; outputTokens: number; @@ -180,22 +299,13 @@ async function getTokenMetrics(transcriptPath: string): Promise<{ } } -async function renderStatusLine(data: StatusJSON) { - const settings = await loadSettings(); - const terminalWidth = 80; // Always use 80 chars for Claude Code status line +function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, tokenMetrics: any, sessionDuration: string | null): string { + const detectedWidth = getTerminalWidth(); + // Subtract 40 chars to account for right-side content like auto compact messages + const terminalWidth = detectedWidth ? detectedWidth - 40 : null; 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`; @@ -204,7 +314,7 @@ async function renderStatusLine(data: StatusJSON) { }; // Build elements based on configured items - for (const item of settings.items) { + for (const item of items) { switch (item.type) { case 'model': if (data.model) { @@ -274,6 +384,21 @@ async function renderStatusLine(data: StatusJSON) { } break; + case 'terminal-width': + const detectedWidth = terminalWidth || getTerminalWidth(); + if (detectedWidth) { + const color = (chalk as any)[item.color || 'dim'] || chalk.dim; + elements.push({ content: color(`Term: ${detectedWidth}`), type: 'terminal-width' }); + } + break; + + case 'session-clock': + if (sessionDuration) { + const color = (chalk as any)[item.color || 'blue'] || chalk.blue; + elements.push({ content: color(`Session: ${sessionDuration}`), type: 'session-clock' }); + } + 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]; @@ -290,12 +415,12 @@ async function renderStatusLine(data: StatusJSON) { } } - if (elements.length === 0) return; + if (elements.length === 0) return ''; // Build the final status line let statusLine = ''; - if (hasFlexSeparator) { + if (hasFlexSeparator && terminalWidth) { // Split elements by flex separators const parts: string[][] = [[]]; let currentPart = 0; @@ -336,26 +461,61 @@ async function renderStatusLine(data: StatusJSON) { } } } 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); + // No flex separator OR no width detected + if (hasFlexSeparator && !terminalWidth) { + // Treat flex separators as normal separators when width detection fails + statusLine = elements.map(e => e.type === 'flex-separator' ? chalk.dim(' | ') : e.content).join(''); + } else { + // Just join all elements normally + statusLine = elements.map(e => e.content).join(''); } } - // 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); + return statusLine; +} + +async function renderStatusLine(data: StatusJSON) { + const settings = await loadSettings(); + + // Get all lines to render (support both old items format and new lines format) + let lines: StatusItem[][] = []; + if (settings.lines) { + lines = settings.lines; + } else if (settings.items) { + // Legacy support for single line + lines = [settings.items]; } else { - console.log(statusLine); + lines = [[]]; + } + + // Get token metrics if needed (check all lines) + const hasTokenItems = lines.some(line => + line.some(item => + ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) + ) + ); + + // Check if session clock is needed + const hasSessionClock = lines.some(line => + line.some(item => item.type === 'session-clock') + ); + + let tokenMetrics: any = null; + if (hasTokenItems && data.transcript_path) { + tokenMetrics = await getTokenMetrics(data.transcript_path); + } + + let sessionDuration: string | null = null; + if (hasSessionClock && data.transcript_path) { + sessionDuration = await getSessionDuration(data.transcript_path); + } + + // Render each line + for (const lineItems of lines) { + if (lineItems.length > 0) { + const line = renderSingleLine(lineItems, settings, data, tokenMetrics, sessionDuration); + console.log(line); + } } } diff --git a/src/config.ts b/src/config.ts index 8862a7a..88b9d83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ const writeFile = fs.promises?.writeFile || promisify(fs.writeFile); const mkdir = fs.promises?.mkdir || promisify(fs.mkdir); export type StatusItemType = 'model' | 'git-branch' | 'git-changes' | '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' | 'terminal-width' | 'session-clock'; export interface StatusItem { id: string; @@ -18,7 +18,8 @@ export interface StatusItem { } export interface Settings { - items: StatusItem[]; + items?: StatusItem[]; // Legacy single line support + lines?: StatusItem[][]; // Multiple lines (up to 3) colors: { model: string; gitBranch: string; @@ -30,12 +31,16 @@ 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' }, - { id: '4', type: 'separator' }, - { id: '5', type: 'git-changes', color: 'yellow' }, + lines: [ + [ + { id: '1', type: 'model', color: 'cyan' }, + { id: '2', type: 'separator' }, + { id: '3', type: 'terminal-width', color: 'dim' }, + { id: '4', type: 'separator' }, + { id: '5', type: 'git-branch', color: 'magenta' }, + { id: '6', type: 'separator' }, + { id: '7', type: 'git-changes', color: 'yellow' }, + ] ], colors: { model: 'cyan', @@ -52,15 +57,39 @@ export async function loadSettings(): Promise { } const content = await readFile(SETTINGS_PATH, 'utf-8'); - const loaded = JSON.parse(content); + let loaded: any; - // Migrate old format if needed + try { + loaded = JSON.parse(content); + } catch (parseError) { + // If we can't parse the settings, return defaults + console.error('Failed to parse settings.json, using defaults'); + return DEFAULT_SETTINGS; + } + + // Migrate from old format with elements/layout if (loaded.elements || loaded.layout) { return migrateOldSettings(loaded); } + // Migrate from single items array to lines array + if (loaded.items && !loaded.lines) { + loaded.lines = [loaded.items]; + delete loaded.items; + } + + // Ensure lines is an array and limit to 3 lines + if (loaded.lines) { + if (!Array.isArray(loaded.lines)) { + loaded.lines = [[]]; + } + loaded.lines = loaded.lines.slice(0, 3); + } + return { ...DEFAULT_SETTINGS, ...loaded }; - } catch { + } catch (error) { + // Any other error, return defaults + console.error('Error loading settings:', error); return DEFAULT_SETTINGS; } } @@ -91,7 +120,7 @@ function migrateOldSettings(old: any): Settings { } return { - items, + lines: [items], // Put migrated items in first line colors: old.colors || DEFAULT_SETTINGS.colors, }; } diff --git a/src/tui.tsx b/src/tui.tsx index 0884c35..7b938d2 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -2,16 +2,62 @@ import React, { useState, useEffect } from 'react'; import { render, Box, Text, useInput, useApp } from 'ink'; import SelectInput from 'ink-select-input'; import chalk from 'chalk'; +import { execSync } from 'child_process'; import { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType } from './config'; import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './claude-settings'; +// Check if terminal width detection is available +function canDetectTerminalWidth(): boolean { + try { + // First try to get the tty of the parent process + const tty = execSync('ps -o tty= -p $(ps -o ppid= -p $$)', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + shell: '/bin/sh' + }).trim(); + + // Check if we got a valid tty + if (tty && tty !== '??' && tty !== '?') { + const width = execSync( + `stty size < /dev/${tty} | awk '{print $2}'`, + { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + shell: '/bin/sh' + } + ).trim(); + + const parsed = parseInt(width, 10); + if (!isNaN(parsed) && parsed > 0) { + return true; + } + } + } catch { + // Try fallback + } + + // Fallback: try tput cols + try { + const width = execSync('tput cols 2>/dev/null', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + + const parsed = parseInt(width, 10); + return !isNaN(parsed) && parsed > 0; + } catch { + return false; + } +} + interface StatusLinePreviewProps { - items: StatusItem[]; + lines: StatusItem[][]; terminalWidth: number; } -const StatusLinePreview: React.FC = ({ items, terminalWidth }) => { - const width = 80; // Status line is always 80 chars max +const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetectionAvailable: boolean): string => { + // Use full terminal width for calculations, we'll handle truncation at the end if needed + const width = widthDetectionAvailable ? terminalWidth : null; const elements: string[] = []; let hasFlexSeparator = false; @@ -53,6 +99,15 @@ const StatusLinePreview: React.FC = ({ items, terminalWi const ctxPctColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan; elements.push(ctxPctColor('Ctx: 9.3%')); break; + case 'session-clock': + const sessionColor = (chalk as any)[item.color || 'blue'] || chalk.blue; + elements.push(sessionColor('Session: 2hr 15m')); + break; + case 'terminal-width': + const termColor = (chalk as any)[item.color || 'dim'] || chalk.dim; + const detectedWidth = canDetectTerminalWidth() ? terminalWidth : '??'; + elements.push(termColor(`Term: ${detectedWidth}`)); + break; case 'separator': elements.push(chalk.dim(' | ')); break; @@ -65,7 +120,7 @@ const StatusLinePreview: React.FC = ({ items, terminalWi // Build the status line with flex separator support let statusLine = ''; - if (hasFlexSeparator) { + if (hasFlexSeparator && width) { const parts: string[][] = [[]]; let currentPart = 0; @@ -109,21 +164,34 @@ const StatusLinePreview: React.FC = ({ items, terminalWi } } } else { - statusLine = elements.filter(e => e !== 'FLEX').join(''); + // No width detection available - treat flex separators as normal separators + statusLine = elements.map(e => e === 'FLEX' ? chalk.dim(' | ') : e).join(''); } + return statusLine; +}; + +const StatusLinePreview: React.FC = ({ lines, terminalWidth }) => { + const widthDetectionAvailable = canDetectTerminalWidth(); // 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)) + '╯'); + // Render each configured line - use boxWidth for consistency + const renderedLines = lines.map(lineItems => + lineItems.length > 0 ? renderSingleLine(lineItems, boxWidth, widthDetectionAvailable) : '' + ).filter(line => line !== ''); // Remove empty lines + return ( {topLine} {middleLine} {bottomLine} - {statusLine} + {renderedLines.map((line, index) => ( + {line} + ))} ); }; @@ -153,6 +221,50 @@ const ConfirmDialog: React.FC = ({ message, onConfirm, onCan ); }; +interface LineSelectorProps { + lines: StatusItem[][]; + onSelect: (lineIndex: number) => void; + onBack: () => void; +} + +const LineSelector: React.FC = ({ lines, onSelect, onBack }) => { + const items = [ + { label: `📝 Line 1${lines[0] && lines[0].length > 0 ? ` (${lines[0].length} items)` : ' (empty)'}`, value: 0 }, + { label: `📝 Line 2${lines[1] && lines[1].length > 0 ? ` (${lines[1].length} items)` : ' (empty)'}`, value: 1 }, + { label: `📝 Line 3${lines[2] && lines[2].length > 0 ? ` (${lines[2].length} items)` : ' (empty)'}`, value: 2 }, + { label: '← Back', value: -1 }, + ]; + + const handleSelect = (item: { value: number }) => { + if (item.value === -1) { + onBack(); + } else { + onSelect(item.value); + } + }; + + // Handle ESC key + useInput((input, key) => { + if (key.escape) { + onBack(); + } + }); + + return ( + + Select Line to Edit + Choose which status line to configure (up to 3 lines supported) + Press ESC to go back + + + + + ); +}; + interface MainMenuProps { onSelect: (value: string) => void; isClaudeInstalled: boolean; @@ -161,7 +273,7 @@ interface MainMenuProps { const MainMenu: React.FC = ({ onSelect, isClaudeInstalled, hasChanges }) => { const items = [ - { label: '📝 Edit Status Line Items', value: 'items' }, + { label: '📝 Edit Lines', value: 'lines' }, { label: '🎨 Configure Colors', value: 'colors' }, { label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' }, ]; @@ -189,9 +301,10 @@ interface ItemsEditorProps { items: StatusItem[]; onUpdate: (items: StatusItem[]) => void; onBack: () => void; + lineNumber: number; } -const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => { +const ItemsEditor: React.FC = ({ items, onUpdate, onBack, lineNumber }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [moveMode, setMoveMode] = useState(false); @@ -228,8 +341,9 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => 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', 'git-changes', 'separator', 'flex-separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; + const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', + 'session-clock', 'terminal-width', 'flex-separator']; const currentItem = items[selectedIndex]; if (currentItem) { const currentType = currentItem.type; @@ -244,8 +358,9 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => } } else if (key.rightArrow && items.length > 0) { // Toggle item type forwards - const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', 'flex-separator', - 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; + const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', + 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', + 'session-clock', 'terminal-width', 'flex-separator']; const currentItem = items[selectedIndex]; if (currentItem) { const currentType = currentItem.type; @@ -267,7 +382,9 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => id: Date.now().toString(), type: 'separator', }; - onUpdate([...items, newItem]); + const newItems = [...items, newItem]; + onUpdate(newItems); + setSelectedIndex(newItems.length - 1); // Move selection to new item } else if (input === 'i') { // Insert item after selected const newItem: StatusItem = { @@ -277,7 +394,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => const newItems = [...items]; newItems.splice(selectedIndex + 1, 0, newItem); onUpdate(newItems); - setSelectedIndex(selectedIndex + 1); + setSelectedIndex(selectedIndex + 1); // Selection already moves to new item } else if (input === 'd' && items.length > 0) { // Delete selected item const newItems = items.filter((_, i) => i !== selectedIndex); @@ -285,6 +402,10 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => if (selectedIndex >= newItems.length && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } + } else if (input === 'c') { + // Clear entire line + onUpdate([]); + setSelectedIndex(0); } else if (key.escape) { onBack(); } @@ -315,16 +436,29 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => return chalk.cyan('Context Length'); case 'context-percentage': return chalk.cyan('Context %'); + case 'session-clock': + return chalk.blue('Session Clock'); + case 'terminal-width': + return chalk.dim('Terminal Width'); } }; + const hasFlexSeparator = items.some(item => item.type === 'flex-separator'); + const widthDetectionAvailable = canDetectTerminalWidth(); + return ( - Edit Status Line Items {moveMode && [MOVE MODE]} + Edit Line {lineNumber} {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 + ↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line, ESC back + )} + {hasFlexSeparator && !widthDetectionAvailable && ( + + ⚠ Note: Terminal width detection is currently unavailable in your environment. + Flex separators will act as normal separators until width detection is available. + )} {items.length === 0 ? ( @@ -352,7 +486,7 @@ interface ColorMenuProps { 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) + ['model', 'git-branch', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'session-clock'].includes(item.type) ); const [selectedIndex, setSelectedIndex] = useState(0); @@ -390,6 +524,7 @@ const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { case 'tokens-total': return 'Tokens Total'; case 'context-length': return 'Context Length'; case 'context-percentage': return 'Context Percentage'; + case 'session-clock': return 'Session Clock'; default: return item.type; } }; @@ -469,13 +604,21 @@ const App: React.FC = () => { 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 [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colors' | 'confirm'>('main'); + const [selectedLine, setSelectedLine] = useState(0); 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 => { + // Ensure lines array exists and has 3 slots + if (!loadedSettings.lines) { + loadedSettings.lines = [[]]; + } + while (loadedSettings.lines.length < 3) { + loadedSettings.lines.push([]); + } setSettings(loadedSettings); setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings))); // Deep copy }); @@ -550,8 +693,8 @@ const App: React.FC = () => { const handleMainMenuSelect = async (value: string) => { switch (value) { - case 'items': - setScreen('items'); + case 'lines': + setScreen('lines'); break; case 'colors': setScreen('colors'); @@ -571,8 +714,15 @@ const App: React.FC = () => { } }; - const updateItems = (items: StatusItem[]) => { - setSettings({ ...settings, items }); + const updateLine = (lineIndex: number, items: StatusItem[]) => { + const newLines = [...(settings.lines || [])]; + newLines[lineIndex] = items; + setSettings({ ...settings, lines: newLines }); + }; + + const handleLineSelect = (lineIndex: number) => { + setSelectedLine(lineIndex); + setScreen('items'); }; return ( @@ -584,21 +734,43 @@ const App: React.FC = () => { Preview: - + {screen === 'main' && } - {screen === 'items' && ( - setScreen('main')} /> )} + {screen === 'items' && settings.lines && ( + updateLine(selectedLine, items)} + onBack={() => setScreen('lines')} + lineNumber={selectedLine + 1} + /> + )} {screen === 'colors' && ( { + // This is a bit tricky - we need to update colors across all lines + // For now, just update the flat list + const newLines = settings.lines || [[]]; + let flatIndex = 0; + for (let lineIndex = 0; lineIndex < newLines.length; lineIndex++) { + for (let itemIndex = 0; itemIndex < newLines[lineIndex].length; itemIndex++) { + if (flatIndex < items.length) { + newLines[lineIndex][itemIndex] = items[flatIndex]; + flatIndex++; + } + } + } + setSettings({ ...settings, lines: newLines }); + }} onBack={() => setScreen('main')} /> )}