mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-09-05 12:16:44 +00:00
Code cleanup and README
This commit is contained in:
@@ -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.
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+267
-263
@@ -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<string | null> {
|
||||
// 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();
|
||||
+41
-41
@@ -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<ClaudeSettings> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
const settings = await loadClaudeSettings();
|
||||
return settings.statusLine?.command || null;
|
||||
const settings = await loadClaudeSettings();
|
||||
return settings.statusLine?.command || null;
|
||||
}
|
||||
+70
-70
@@ -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<Settings> {
|
||||
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<void> {
|
||||
// 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');
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
console.log("Hello via Bun!");
|
||||
-148
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+557
-531
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user