Terminal width detection, multi line support, session timer widget

This commit is contained in:
Matthew Breedlove committed 2025-08-08 17:22:54 -04:00
1 parent 925c2d1858
commit 54f047c132
5 files changed
+474 -107

No files matched your search

+38 -32
View File
@@ -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"
}
}
```
+1 -1
View File
@@ -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",
+193 -33
View File
@@ -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<string | null> {
}
}
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<string | null> {
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);
}
}
}
+41 -12
View File
@@ -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<Settings> {
}
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,
};
}
+201 -29
View File
@@ -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<StatusLinePreviewProps> = ({ 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<StatusLinePreviewProps> = ({ 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<StatusLinePreviewProps> = ({ 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<StatusLinePreviewProps> = ({ 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<StatusLinePreviewProps> = ({ 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 (
<Box flexDirection='column'>
<Text>{topLine}</Text>
<Text>{middleLine}</Text>
<Text>{bottomLine}</Text>
<Text>{statusLine}</Text>
{renderedLines.map((line, index) => (
<Text key={index}>{line}</Text>
))}
</Box>
);
};
@@ -153,6 +221,50 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCan
);
};
interface LineSelectorProps {
lines: StatusItem[][];
onSelect: (lineIndex: number) => void;
onBack: () => void;
}
const LineSelector: React.FC<LineSelectorProps> = ({ 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 (
<Box flexDirection='column'>
<Text bold>Select Line to Edit</Text>
<Text dimColor>Choose which status line to configure (up to 3 lines supported)</Text>
<Text dimColor>Press ESC to go back</Text>
<Box marginTop={1}>
<SelectInput
items={items}
onSelect={handleSelect}
/>
</Box>
</Box>
);
};
interface MainMenuProps {
onSelect: (value: string) => void;
isClaudeInstalled: boolean;
@@ -161,7 +273,7 @@ interface MainMenuProps {
const MainMenu: React.FC<MainMenuProps> = ({ 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<ItemsEditorProps> = ({ items, onUpdate, onBack }) => {
const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, lineNumber }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
@@ -228,8 +341,9 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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 (
<Box flexDirection='column'>
<Text bold>Edit Status Line Items {moveMode && <Text color='yellow'>[MOVE MODE]</Text>}</Text>
<Text bold>Edit Line {lineNumber} {moveMode && <Text color='yellow'>[MOVE MODE]</Text>}</Text>
{moveMode ? (
<Text dimColor>↑↓ to move item, ESC or Enter to exit move mode</Text>
) : (
<Text dimColor>↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, ESC back</Text>
<Text dimColor>↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line, ESC back</Text>
)}
{hasFlexSeparator && !widthDetectionAvailable && (
<Box marginTop={1}>
<Text color='yellow'>⚠ Note: Terminal width detection is currently unavailable in your environment.</Text>
<Text dimColor> Flex separators will act as normal separators until width detection is available.</Text>
</Box>
)}
<Box marginTop={1} flexDirection='column'>
{items.length === 0 ? (
@@ -352,7 +486,7 @@ interface ColorMenuProps {
const ColorMenu: React.FC<ColorMenuProps> = ({ 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<ColorMenuProps> = ({ 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<Settings | null>(null);
const [originalSettings, setOriginalSettings] = useState<Settings | null>(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<void> } | 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 = () => {
<Box marginBottom={1}>
<Text dimColor>Preview:</Text>
</Box>
<StatusLinePreview items={settings.items} terminalWidth={terminalWidth} />
<StatusLinePreview lines={settings.lines || [[]]} terminalWidth={terminalWidth} />
<Box marginTop={2}>
{screen === 'main' && <MainMenu onSelect={handleMainMenuSelect} isClaudeInstalled={isClaudeInstalled} hasChanges={hasChanges} />}
{screen === 'items' && (
<ItemsEditor
items={settings.items}
onUpdate={updateItems}
{screen === 'lines' && (
<LineSelector
lines={settings.lines || [[]]}
onSelect={handleLineSelect}
onBack={() => setScreen('main')}
/>
)}
{screen === 'items' && settings.lines && (
<ItemsEditor
items={settings.lines[selectedLine] || []}
onUpdate={(items) => updateLine(selectedLine, items)}
onBack={() => setScreen('lines')}
lineNumber={selectedLine + 1}
/>
)}
{screen === 'colors' && (
<ColorMenu
items={settings.items}
onUpdate={updateItems}
items={settings.lines?.flat() || []}
onUpdate={(items) => {
// 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')}
/>
)}