mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 00:14:20 +00:00
Terminal width detection, multi line support, session timer widget
This commit is contained in:
1 parent
925c2d1858
commit
54f047c132
5 files changed
+474
-107
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user