Initial commit

This commit is contained in:
Matthew Breedlove committed 2025-08-08 07:20:28 -04:00
commit d5c193c209
13 files changed
+2222

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+35
View File
@@ -0,0 +1,35 @@
# Source files
src/
*.ts
*.tsx
# Development files
bun.lock
CLAUDE.md
.claude/
test-settings.json
# Build files
tsconfig.json
# Test and example files
*.test.*
*.spec.*
# Git files
.git/
.gitignore
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Node modules (in case someone uses npm instead of bun)
node_modules/
+90
View File
@@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ccstatusline is a customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics. It functions as both:
1. A piped command processor for Claude Code status lines
2. An interactive TUI configuration tool when run without input
## Development Commands
```bash
# Install dependencies
bun install
# Run locally (TUI mode)
bun run src/ccstatusline.ts
# Test with piped input
echo '{"model":{"display_name":"Claude 3.5 Sonnet"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts
# Build for npm distribution
bun run build # Creates dist/ccstatusline.js
# Test npm package locally
npm link
echo '{"model":{"display_name":"Claude 3.5 Sonnet"},"transcript_path":"test.jsonl"}' | ccstatusline
```
## Architecture
The project has dual runtime compatibility - it works with both Bun and Node.js:
- **src/ccstatusline.ts**: Main entry point that detects piped vs interactive mode
- Piped mode: Parses JSON from stdin and renders formatted status line
- Interactive mode: Launches React/Ink TUI for configuration
- **src/tui.tsx**: React-based terminal UI using Ink for configuration interface
- Item management (add/remove/reorder status items)
- Color customization for different elements
- **src/config.ts**: Settings management
- Loads from `~/.config/ccstatusline/settings.json`
- Handles migration from old settings format
- Default configuration if no settings exist
- **npm publishing**: Uses `--packages=external` to keep dependencies external for cross-platform compatibility
## Key Implementation Details
- **Cross-platform stdin reading**: Code detects Bun vs Node.js environment and uses appropriate stdin API
- **Token metrics**: Parses Claude Code transcript files (JSONL format) to calculate token usage
- **Git integration**: Uses child_process.execSync to get current branch
- **80-char limit**: Status line always formats to exactly 80 characters for Claude Code compatibility
- **Flex separators**: Special separator type that expands to fill available space
## Bun Usage Preferences
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
+137
View File
@@ -0,0 +1,137 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "ccstatusline",
"dependencies": {
"@types/react": "^19.1.9",
"chalk": "^5.5.0",
"ink": "^6.1.0",
"ink-select-input": "^6.2.0",
"ink-text-input": "^6.0.0",
"react": "^19.1.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="],
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="],
"@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="],
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
"chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-toolkit": ["es-toolkit@1.39.8", "", {}, "sha512-A8QO9TfF+rltS8BXpdu8OS+rpGgEdnRhqIVxO/ZmNvnXBYgOdSsxukT55ELyP94gZIntWJ+Li9QRrT2u1Kitpg=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"ink": ["ink@6.1.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.32.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-YQ+lbMD79y3FBAJXXZnuRajLEgaMFp102361eY5NrBIEVCi9oFo7gNZU4z2LBWlcjZFiTt7jetlkIbKCCH4KJA=="],
"ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="],
"ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
"react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
"wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
}
}
+619
View File
@@ -0,0 +1,619 @@
{
"name": "ccstatusline",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ccstatusline",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@types/react": "^19.1.9",
"chalk": "^5.5.0",
"ink": "^6.1.0",
"ink-select-input": "^6.2.0",
"ink-text-input": "^6.0.0",
"react": "^19.1.1"
},
"bin": {
"ccstatusline": "dist/ccstatusline.js"
},
"devDependencies": {
"@types/bun": "latest"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"typescript": "^5"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@types/bun": {
"version": "1.2.19",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.19.tgz",
"integrity": "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.2.19"
}
},
"node_modules/@types/node": {
"version": "24.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/react": {
"version": "19.1.9",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/ansi-escapes": {
"version": "7.0.0",
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/auto-bind": {
"version": "5.0.1",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bun-types": {
"version": "1.2.19",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"peerDependencies": {
"@types/react": "^19"
}
},
"node_modules/chalk": {
"version": "5.5.0",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"license": "MIT",
"dependencies": {
"restore-cursor": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "4.0.0",
"license": "MIT",
"dependencies": {
"slice-ansi": "^5.0.0",
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/slice-ansi": {
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.0.0",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/code-excerpt": {
"version": "4.0.0",
"license": "MIT",
"dependencies": {
"convert-to-spaces": "^2.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/convert-to-spaces": {
"version": "2.0.1",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "10.4.0",
"license": "MIT"
},
"node_modules/environment": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-toolkit": {
"version": "1.39.8",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escape-string-regexp": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/figures": {
"version": "6.1.0",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-east-asian-width": {
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/indent-string": {
"version": "5.0.0",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink": {
"version": "6.1.0",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.3.0",
"cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0",
"indent-string": "^5.0.0",
"is-in-ci": "^1.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.32.0",
"scheduler": "^0.23.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"string-width": "^7.2.0",
"type-fest": "^4.27.0",
"widest-line": "^5.0.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yoga-layout": "~3.2.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@types/react": ">=19.0.0",
"react": ">=19.0.0",
"react-devtools-core": "^4.19.1"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react-devtools-core": {
"optional": true
}
}
},
"node_modules/ink-select-input": {
"version": "6.2.0",
"license": "MIT",
"dependencies": {
"figures": "^6.1.0",
"to-rotated": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"ink": ">=5.0.0",
"react": ">=18.0.0"
}
},
"node_modules/ink-text-input": {
"version": "6.0.0",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"type-fest": "^4.18.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"ink": ">=5",
"react": ">=18"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-in-ci": {
"version": "1.0.0",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-console": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/react": {
"version": "19.1.1",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-reconciler": {
"version": "0.32.0",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.26.0",
"license": "MIT"
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"license": "MIT",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"license": "ISC"
},
"node_modules/slice-ansi": {
"version": "7.1.0",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.1.0",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/to-rotated": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.10.0",
"dev": true,
"license": "MIT"
},
"node_modules/widest-line": {
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.0",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.18.3",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"license": "MIT"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "ccstatusline",
"version": "1.0.0",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
"bin": {
"ccstatusline": "dist/ccstatusline.js"
},
"files": [
"dist/"
],
"scripts": {
"start": "bun run src/ccstatusline.ts",
"build": "bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --packages=external --target-version=14",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/react": "^19.1.9",
"chalk": "^5.5.0",
"ink": "^6.1.0",
"ink-select-input": "^6.2.0",
"ink-text-input": "^6.0.0",
"react": "^19.1.1"
},
"keywords": [
"claude",
"claude-code",
"cli",
"status-line",
"terminal"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/sirmalloc/ccstatusline.git"
},
"engines": {
"node": ">=14.0.0"
}
}
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { execSync } from 'child_process';
import { runTUI } from './tui.tsx';
import { loadSettings } from './config';
import * as fs from 'fs';
import { promisify } from 'util';
// Ensure fs.promises compatibility for older Node versions
const readFile = fs.promises?.readFile || promisify(fs.readFile);
// Force chalk to use colors even when piped
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;
};
}
interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
interface TranscriptLine {
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);
}
}
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;
}
}
async function getTokenMetrics(transcriptPath: string): Promise<{
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;
}
} 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 };
}
}
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;
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++) {
statusLine += parts[i].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);
}
}
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);
}
} else {
console.error('No input received');
process.exit(1);
}
} else {
// Interactive mode - run TUI
await runTUI();
}
}
main();
+74
View File
@@ -0,0 +1,74 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promisify } from 'util';
// Ensure fs.promises compatibility
const readFile = fs.promises?.readFile || promisify(fs.readFile);
const writeFile = fs.promises?.writeFile || promisify(fs.writeFile);
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;
}
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
try {
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
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');
}
export async function isInstalled(): Promise<boolean> {
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);
}
export async function uninstallStatusLine(): Promise<void> {
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;
}
+103
View File
@@ -0,0 +1,103 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promisify } from 'util';
// Ensure fs.promises compatibility
const readFile = fs.promises?.readFile || promisify(fs.readFile);
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';
export interface StatusItem {
id: string;
type: StatusItemType;
color?: string;
}
export interface Settings {
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',
},
};
export async function loadSettings(): Promise<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;
}
}
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,
};
}
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');
}
+1
View File
@@ -0,0 +1 @@
console.log("Hello via Bun!");
+148
View File
@@ -0,0 +1,148 @@
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;
}
}
}
+589
View File
@@ -0,0 +1,589 @@
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 { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType } from './config';
import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './claude-settings';
interface StatusLinePreviewProps {
items: StatusItem[];
terminalWidth: number;
}
const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ items, terminalWidth }) => {
const width = 80; // Status line is always 80 chars max
const elements: string[] = [];
let hasFlexSeparator = false;
items.forEach(item => {
switch (item.type) {
case 'model':
const modelColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan;
elements.push(modelColor('Model: Claude'));
break;
case 'git-branch':
const branchColor = (chalk as any)[item.color || 'magenta'] || chalk.magenta;
elements.push(branchColor('⎇ main'));
break;
case 'tokens-input':
const inputColor = (chalk as any)[item.color || 'yellow'] || chalk.yellow;
elements.push(inputColor('In: 15.2k'));
break;
case 'tokens-output':
const outputColor = (chalk as any)[item.color || 'green'] || chalk.green;
elements.push(outputColor('Out: 3.4k'));
break;
case 'tokens-cached':
const cachedColor = (chalk as any)[item.color || 'blue'] || chalk.blue;
elements.push(cachedColor('Cached: 12k'));
break;
case 'tokens-total':
const totalColor = (chalk as any)[item.color || 'white'] || chalk.white;
elements.push(totalColor('Total: 30.6k'));
break;
case 'context-length':
const ctxColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan;
elements.push(ctxColor('Ctx: 18.6k'));
break;
case 'context-percentage':
const ctxPctColor = (chalk as any)[item.color || 'cyan'] || chalk.cyan;
elements.push(ctxPctColor('Ctx: 9.3%'));
break;
case 'separator':
elements.push(chalk.dim(' | '));
break;
case 'flex-separator':
elements.push('FLEX');
hasFlexSeparator = true;
break;
}
});
// Build the status line with flex separator support
let statusLine = '';
if (hasFlexSeparator) {
const parts: string[][] = [[]];
let currentPart = 0;
for (let i = 0; i < items.length; i++) {
if (items[i].type === 'flex-separator') {
currentPart++;
parts[currentPart] = [];
} else {
const element = elements[i];
if (element !== 'FLEX') {
parts[currentPart].push(element);
}
}
}
// Calculate total length of all non-flex content
const partLengths = parts.map(part => {
const joined = part.join('');
return joined.replace(/\x1b\[[0-9;]*m/g, '').length;
});
const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0);
// Calculate space to distribute among flex separators
const flexCount = parts.length - 1; // Number of flex separators
const totalSpace = Math.max(0, width - totalContentLength);
const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0;
const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0;
// Build the status line with distributed spacing
statusLine = '';
for (let i = 0; i < parts.length; i++) {
statusLine += parts[i].join('');
if (i < parts.length - 1) {
// Add flex spacing
const spaces = spacePerFlex + (i < extraSpace ? 1 : 0);
statusLine += ' '.repeat(spaces);
}
}
} else {
statusLine = elements.filter(e => e !== 'FLEX').join('');
}
// Build the Claude Code input box - account for ink's padding
const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76);
const topLine = chalk.dim('╭' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╮');
const middleLine = chalk.dim('│') + ' > ' + ' '.repeat(Math.max(0, boxWidth - 5)) + chalk.dim('│');
const bottomLine = chalk.dim('╰' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╯');
return (
<Box flexDirection="column">
<Text>{topLine}</Text>
<Text>{middleLine}</Text>
<Text>{bottomLine}</Text>
<Text>{statusLine}</Text>
</Box>
);
};
interface ConfirmDialogProps {
message: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ message, onConfirm, onCancel }) => {
const items = [
{ label: '✅ Yes', value: 'yes' },
{ label: '❌ No', value: 'no' },
];
return (
<Box flexDirection="column">
<Text>{message}</Text>
<Box marginTop={1}>
<SelectInput
items={items}
onSelect={(item) => item.value === 'yes' ? onConfirm() : onCancel()}
/>
</Box>
</Box>
);
};
interface MainMenuProps {
onSelect: (value: string) => void;
isClaudeInstalled: boolean;
hasChanges: boolean;
}
const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasChanges }) => {
const items = [
{ label: '📝 Edit Status Line Items', value: 'items' },
{ label: '🎨 Configure Colors', value: 'colors' },
{ label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' },
];
if (hasChanges) {
items.push(
{ label: '💾 Save & Exit', value: 'save' },
{ label: '❌ Exit without saving', value: 'exit' }
);
} else {
items.push({ label: '🚪 Exit', value: 'exit' });
}
return (
<Box flexDirection="column">
<Text bold>Main Menu</Text>
<Box marginTop={1}>
<SelectInput items={items} onSelect={(item) => onSelect(item.value)} />
</Box>
</Box>
);
};
interface ItemsEditorProps {
items: StatusItem[];
onUpdate: (items: StatusItem[]) => void;
onBack: () => void;
}
const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [moveMode, setMoveMode] = useState(false);
useInput((input, key) => {
if (moveMode) {
// In move mode, use up/down to move the selected item
if (key.upArrow && selectedIndex > 0) {
const newItems = [...items];
[newItems[selectedIndex], newItems[selectedIndex - 1]] =
[newItems[selectedIndex - 1], newItems[selectedIndex]];
onUpdate(newItems);
setSelectedIndex(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < items.length - 1) {
const newItems = [...items];
[newItems[selectedIndex], newItems[selectedIndex + 1]] =
[newItems[selectedIndex + 1], newItems[selectedIndex]];
onUpdate(newItems);
setSelectedIndex(selectedIndex + 1);
} else if (key.escape || key.return) {
// Exit move mode
setMoveMode(false);
}
} else {
// Normal mode
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (key.downArrow) {
setSelectedIndex(Math.min(items.length - 1, selectedIndex + 1));
} else if (key.leftArrow && items.length > 0) {
// Toggle item type backwards
const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator',
'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'];
const currentType = items[selectedIndex].type;
const currentIndex = types.indexOf(currentType);
const prevIndex = currentIndex === 0 ? types.length - 1 : currentIndex - 1;
const newItems = [...items];
newItems[selectedIndex] = { ...newItems[selectedIndex], type: types[prevIndex] };
onUpdate(newItems);
} else if (key.rightArrow && items.length > 0) {
// Toggle item type forwards
const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator',
'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'];
const currentType = items[selectedIndex].type;
const currentIndex = types.indexOf(currentType);
const nextIndex = (currentIndex + 1) % types.length;
const newItems = [...items];
newItems[selectedIndex] = { ...newItems[selectedIndex], type: types[nextIndex] };
onUpdate(newItems);
} else if (key.return && items.length > 0) {
// Enter move mode
setMoveMode(true);
} else if (input === 'a') {
// Add item at end
const newItem: StatusItem = {
id: Date.now().toString(),
type: 'separator',
};
onUpdate([...items, newItem]);
} else if (input === 'i') {
// Insert item after selected
const newItem: StatusItem = {
id: Date.now().toString(),
type: 'separator',
};
const newItems = [...items];
newItems.splice(selectedIndex + 1, 0, newItem);
onUpdate(newItems);
setSelectedIndex(selectedIndex + 1);
} else if (input === 'd' && items.length > 0) {
// Delete selected item
const newItems = items.filter((_, i) => i !== selectedIndex);
onUpdate(newItems);
if (selectedIndex >= newItems.length && selectedIndex > 0) {
setSelectedIndex(selectedIndex - 1);
}
} else if (key.escape) {
onBack();
}
}
});
const getItemDisplay = (item: StatusItem) => {
switch (item.type) {
case 'model':
return chalk.cyan('Model');
case 'git-branch':
return chalk.magenta('Git Branch');
case 'separator':
return chalk.dim('Separator |');
case 'flex-separator':
return chalk.yellow('Flex Separator ─────');
case 'tokens-input':
return chalk.yellow('Tokens Input');
case 'tokens-output':
return chalk.green('Tokens Output');
case 'tokens-cached':
return chalk.blue('Tokens Cached');
case 'tokens-total':
return chalk.white('Tokens Total');
case 'context-length':
return chalk.cyan('Context Length');
case 'context-percentage':
return chalk.cyan('Context %');
}
};
return (
<Box flexDirection="column">
<Text bold>Edit Status Line Items {moveMode && <Text color="yellow">[MOVE MODE]</Text>}</Text>
{moveMode ? (
<Text dim>↑↓ to move item, ESC or Enter to exit move mode</Text>
) : (
<Text dim>↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, ESC back</Text>
)}
<Box marginTop={1} flexDirection="column">
{items.length === 0 ? (
<Text dim>No items. Press 'a' to add one.</Text>
) : (
items.map((item, index) => (
<Box key={item.id}>
<Text color={index === selectedIndex ? (moveMode ? 'yellow' : 'green') : undefined}>
{index === selectedIndex ? (moveMode ? '◆ ' : '▶ ') : ' '}
{index + 1}. {getItemDisplay(item)}
</Text>
</Box>
))
)}
</Box>
</Box>
);
};
interface ColorMenuProps {
items: StatusItem[];
onUpdate: (items: StatusItem[]) => void;
onBack: () => void;
}
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)
);
const [selectedIndex, setSelectedIndex] = useState(0);
// Handle ESC key
useInput((input, key) => {
if (key.escape) {
onBack();
}
});
if (colorableItems.length === 0) {
return (
<Box flexDirection="column">
<Text bold>Configure Colors</Text>
<Text dim marginTop={1}>No colorable items in the status line.</Text>
<Text dim>Add a Model or Git Branch item first.</Text>
<Text marginTop={1}>Press any key to go back...</Text>
{useInput(() => onBack())}
</Box>
);
}
const getItemLabel = (item: StatusItem) => {
switch (item.type) {
case 'model': return 'Model';
case 'git-branch': return 'Git Branch';
case 'tokens-input': return 'Tokens Input';
case 'tokens-output': return 'Tokens Output';
case 'tokens-cached': return 'Tokens Cached';
case 'tokens-total': return 'Tokens Total';
case 'context-length': return 'Context Length';
case 'context-percentage': return 'Context Percentage';
default: return item.type;
}
};
// Create menu items with colored labels
const menuItems = colorableItems.map((item, index) => {
const color = item.color || 'white';
const colorFunc = (chalk as any)[color] || chalk.white;
return {
label: colorFunc(`${getItemLabel(item)} #${index + 1}`),
value: item.id,
};
});
menuItems.push({ label: '← Back', value: 'back' });
const handleSelect = (selected: { value: string }) => {
if (selected.value === 'back') {
onBack();
} else {
// Cycle through colors
const newItems = items.map(item => {
if (item.id === selected.value) {
const currentColorIndex = colors.indexOf(item.color || 'white');
const nextColor = colors[(currentColorIndex + 1) % colors.length];
return { ...item, color: nextColor };
}
return item;
});
onUpdate(newItems);
}
};
const handleHighlight = (item: { value: string }) => {
if (item.value !== 'back') {
const itemIndex = colorableItems.findIndex(i => i.id === item.value);
if (itemIndex !== -1) {
setSelectedIndex(itemIndex);
}
}
};
// Color list for cycling
const colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright',
'magentaBright', 'cyanBright', 'whiteBright'];
// Get current color for selected item (if a valid colorable item is selected)
const selectedItem = selectedIndex < colorableItems.length ? colorableItems[selectedIndex] : null;
const currentColor = selectedItem ? (selectedItem.color || 'white') : 'white';
const colorIndex = colors.indexOf(currentColor);
const colorNumber = colorIndex === -1 ? 8 : colorIndex + 1; // Default to white (8) if not found
const colorDisplay = (chalk as any)[currentColor] ? (chalk as any)[currentColor](currentColor) : chalk.white(currentColor);
return (
<Box flexDirection="column">
<Text bold>Configure Colors</Text>
<Text dim>↑↓ to select item, Enter to cycle color, ESC to go back</Text>
{selectedItem && (
<Text marginTop={1}>
Current color ({colorNumber}/{colors.length}): {colorDisplay}
</Text>
)}
<Box marginTop={1}>
<SelectInput
items={menuItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
initialIndex={selectedIndex}
/>
</Box>
</Box>
);
};
const App: React.FC = () => {
const { exit } = useApp();
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 [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 => {
setSettings(loadedSettings);
setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings))); // Deep copy
});
isInstalled().then(setIsClaudeInstalled);
const handleResize = () => {
setTerminalWidth(process.stdout.columns || 80);
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Check for changes whenever settings update
useEffect(() => {
if (settings && originalSettings) {
const hasAnyChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
setHasChanges(hasAnyChanges);
}
}, [settings, originalSettings]);
useInput((input, key) => {
if (key.ctrl && input === 'c') {
exit();
}
});
if (!settings) {
return <Text>Loading settings...</Text>;
}
const handleInstallUninstall = async () => {
if (isClaudeInstalled) {
// Uninstall
setConfirmDialog({
message: 'This will remove ccstatusline from ~/.claude/settings.json. Continue?',
action: async () => {
await uninstallStatusLine();
setIsClaudeInstalled(false);
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
} else {
// Always ask for consent before modifying Claude settings
const existing = await getExistingStatusLine();
let message: string;
if (existing && existing !== 'npx ccstatusline') {
message = `This will modify ~/.claude/settings.json\n\nA status line is already configured: "${existing}"\nReplace it with ccstatusline?`;
} else if (existing === 'npx ccstatusline') {
message = 'ccstatusline is already installed in ~/.claude/settings.json\nReinstall it?';
} else {
message = 'This will modify ~/.claude/settings.json to add ccstatusline.\nContinue?';
}
setConfirmDialog({
message,
action: async () => {
await installStatusLine();
setIsClaudeInstalled(true);
setScreen('main');
setConfirmDialog(null);
}
});
setScreen('confirm');
}
};
const handleMainMenuSelect = async (value: string) => {
switch (value) {
case 'items':
setScreen('items');
break;
case 'colors':
setScreen('colors');
break;
case 'install':
await handleInstallUninstall();
break;
case 'save':
await saveSettings(settings);
setOriginalSettings(JSON.parse(JSON.stringify(settings))); // Update original after save
setHasChanges(false);
exit();
break;
case 'exit':
exit();
break;
}
};
const updateItems = (items: StatusItem[]) => {
setSettings({ ...settings, items });
};
return (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
<Text bold color="cyan">🎨 CCStatusline Configuration</Text>
</Box>
<Box marginBottom={1}>
<Text dim>Preview:</Text>
</Box>
<StatusLinePreview items={settings.items} terminalWidth={terminalWidth} />
<Box marginTop={2}>
{screen === 'main' && <MainMenu onSelect={handleMainMenuSelect} isClaudeInstalled={isClaudeInstalled} hasChanges={hasChanges} />}
{screen === 'items' && (
<ItemsEditor
items={settings.items}
onUpdate={updateItems}
onBack={() => setScreen('main')}
/>
)}
{screen === 'colors' && (
<ColorMenu
items={settings.items}
onUpdate={updateItems}
onBack={() => setScreen('main')}
/>
)}
{screen === 'confirm' && confirmDialog && (
<ConfirmDialog
message={confirmDialog.message}
onConfirm={confirmDialog.action}
onCancel={() => {
setScreen('main');
setConfirmDialog(null);
}}
/>
)}
</Box>
</Box>
);
};
export function runTUI() {
render(<App />);
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}