howmuchleft 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -64
- package/bin/cli.js +26 -8
- package/config.example.json +34 -2
- package/lib/demo.js +139 -0
- package/lib/statusline.js +254 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,75 +3,30 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/howmuchleft)
|
|
4
4
|
[](https://www.npmjs.com/package/howmuchleft)
|
|
5
5
|
[](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
|
|
6
|
-
[](https://nodejs.org)
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
**Know exactly how much context and usage you have left, right in your Claude Code statusline.**
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+

|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
- **5-hour usage** -- your rolling 5-hour rate limit utilization, time until reset, git branch/changes, lines added/removed
|
|
14
|
-
- **Weekly usage** -- your rolling 7-day rate limit utilization, time until reset, current directory
|
|
11
|
+

|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
Three progress bars with sub-cell precision that shift from green to red as you approach your limits:
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
For multiple Claude Code subscriptions:
|
|
15
|
+
| Bar | What it tracks |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **Context window** | How full your conversation is, plus subscription tier and model |
|
|
18
|
+
| **5-hour usage** | Rolling rate limit, time until reset, git branch and diff stats |
|
|
19
|
+
| **Weekly usage** | Rolling 7-day rate limit, time until reset, current directory |
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
howmuchleft --install ~/.claude-work
|
|
29
|
-
howmuchleft --install ~/.claude-personal
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Configuration
|
|
33
|
-
|
|
34
|
-
Config file: `~/.config/howmuchleft.json`
|
|
35
|
-
|
|
36
|
-
```json
|
|
37
|
-
{
|
|
38
|
-
"progressLength": 12,
|
|
39
|
-
"emptyBgDark": 236,
|
|
40
|
-
"emptyBgLight": 252
|
|
41
|
-
}
|
|
42
|
-
```
|
|
21
|
+
Works with Pro, Max 5x, Max 20x, and Team subscriptions. API key users see context bar only.
|
|
43
22
|
|
|
44
|
-
|
|
45
|
-
|---|---|---|
|
|
46
|
-
| `progressLength` | `12` | Bar width in characters (3-40) |
|
|
47
|
-
| `emptyBgDark` | `236` | 256-color index for empty bar background in dark terminals |
|
|
48
|
-
| `emptyBgLight` | `252` | 256-color index for empty bar background in light terminals |
|
|
23
|
+
## Install
|
|
49
24
|
|
|
50
|
-
|
|
25
|
+
Two commands and you're done:
|
|
51
26
|
|
|
52
27
|
```bash
|
|
53
|
-
howmuchleft
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
## How it works
|
|
57
|
-
|
|
58
|
-
Claude Code invokes `howmuchleft` as a child process on each statusline render, piping a JSON object to stdin with model info, context window usage, cwd, and cost data. The script:
|
|
59
|
-
|
|
60
|
-
1. Reads the JSON from stdin
|
|
61
|
-
2. Fetches usage data from Anthropic's OAuth API (cached for 60s, with stale-data fallback)
|
|
62
|
-
3. Auto-refreshes expired OAuth tokens
|
|
63
|
-
4. Runs `git status` for branch/change info (in parallel with the API call)
|
|
64
|
-
5. Renders three lines of progress bars to stdout
|
|
65
|
-
|
|
66
|
-
## CLI
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
howmuchleft [config-dir] Run the statusline (called by Claude Code)
|
|
70
|
-
howmuchleft --install [config-dir] Add to Claude Code settings
|
|
71
|
-
howmuchleft --uninstall [config-dir] Remove from Claude Code settings
|
|
72
|
-
howmuchleft --config Show config path and current settings
|
|
73
|
-
howmuchleft --version Show version
|
|
74
|
-
howmuchleft --help Show help
|
|
28
|
+
npm install -g howmuchleft
|
|
29
|
+
howmuchleft --install
|
|
75
30
|
```
|
|
76
31
|
|
|
77
32
|
## Uninstall
|
|
@@ -81,9 +36,12 @@ howmuchleft --uninstall
|
|
|
81
36
|
npm uninstall -g howmuchleft
|
|
82
37
|
```
|
|
83
38
|
|
|
84
|
-
##
|
|
39
|
+
## Customize
|
|
40
|
+
|
|
41
|
+
Config lives at `~/.config/howmuchleft.json` (JSONC -- comments allowed). See [`config.example.json`](./config.example.json) for all options.
|
|
42
|
+
|
|
43
|
+
- **`progressLength`** -- bar width in characters (default 12)
|
|
44
|
+
- **`colorMode`** -- `"auto"`, `"truecolor"`, or `"256"`
|
|
45
|
+
- **`colors`** -- custom gradient stops and background colors per theme/color-depth combo
|
|
85
46
|
|
|
86
|
-
|
|
87
|
-
- Claude Code with OAuth login (Pro/Max/Team subscription)
|
|
88
|
-
- `git` (optional, for branch/change display)
|
|
89
|
-
- `gsettings` (Linux/GNOME) or `defaults` (macOS) for dark/light mode detection
|
|
47
|
+
Preview your current gradient: `howmuchleft --test-colors`
|
package/bin/cli.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const os = require('os');
|
|
12
|
-
const { main, CONFIG_PATH } = require('../lib/statusline');
|
|
12
|
+
const { main, CONFIG_PATH, testColors } = require('../lib/statusline');
|
|
13
13
|
|
|
14
14
|
const VERSION = require('../package.json').version;
|
|
15
15
|
|
|
@@ -56,13 +56,15 @@ Usage:
|
|
|
56
56
|
howmuchleft --install [config-dir] Add howmuchleft to your Claude Code settings
|
|
57
57
|
howmuchleft --uninstall [config-dir] Remove howmuchleft from your Claude Code settings
|
|
58
58
|
howmuchleft --config Show config file path and current settings
|
|
59
|
+
howmuchleft --demo [seconds] Run a time-lapse demo (default 60s)
|
|
60
|
+
howmuchleft --test-colors Preview gradient colors for your terminal
|
|
59
61
|
howmuchleft --version Show version
|
|
60
62
|
|
|
61
63
|
Config file: ${CONFIG_PATH}
|
|
62
64
|
{
|
|
63
65
|
"progressLength": 12, Bar width in characters (3-40, default 12)
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
+
"colorMode": "auto", Color depth: "auto", "truecolor", or "256"
|
|
67
|
+
"colors": [...] Gradient and bg color entries (see README)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
Examples:
|
|
@@ -151,13 +153,17 @@ function showConfig() {
|
|
|
151
153
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
152
154
|
console.log('Current settings:');
|
|
153
155
|
console.log(` progressLength: ${config.progressLength ?? '(default: 12)'}`);
|
|
154
|
-
console.log(`
|
|
155
|
-
|
|
156
|
+
console.log(` colorMode: ${config.colorMode ?? '(default: auto)'}`);
|
|
157
|
+
if (Array.isArray(config.colors)) {
|
|
158
|
+
console.log(` colors: ${config.colors.length} entries`);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(' colors: (using built-in defaults)');
|
|
161
|
+
}
|
|
156
162
|
} catch {
|
|
157
163
|
console.log('No config file found. Using defaults:');
|
|
158
164
|
console.log(' progressLength: 12');
|
|
159
|
-
console.log('
|
|
160
|
-
console.log('
|
|
165
|
+
console.log(' colorMode: auto');
|
|
166
|
+
console.log(' colors: built-in gradients');
|
|
161
167
|
console.log();
|
|
162
168
|
console.log(`Create one with: cp ${path.resolve(__dirname, '..', 'config.example.json')} ${CONFIG_PATH}`);
|
|
163
169
|
}
|
|
@@ -177,8 +183,20 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
177
183
|
uninstall(args);
|
|
178
184
|
} else if (args.includes('--config')) {
|
|
179
185
|
showConfig();
|
|
186
|
+
} else if (args.includes('--test-colors')) {
|
|
187
|
+
testColors();
|
|
188
|
+
} else if (args.includes('--demo')) {
|
|
189
|
+
const { runDemo } = require('../lib/demo');
|
|
190
|
+
const demoIdx = args.indexOf('--demo');
|
|
191
|
+
const duration = parseInt(args[demoIdx + 1], 10);
|
|
192
|
+
runDemo(duration > 0 ? duration : undefined);
|
|
193
|
+
} else if (process.stdin.isTTY) {
|
|
194
|
+
// Running from a terminal, not piped by Claude Code
|
|
195
|
+
console.log('This command is meant to be called by Claude Code, not run directly.');
|
|
196
|
+
console.log('Try: howmuchleft --help or howmuchleft --demo');
|
|
197
|
+
process.exit(0);
|
|
180
198
|
} else {
|
|
181
|
-
// Default: run the statusline
|
|
199
|
+
// Default: run the statusline (stdin is piped by Claude Code)
|
|
182
200
|
main().catch(err => {
|
|
183
201
|
console.error('Statusline error:', err.message);
|
|
184
202
|
process.exit(1);
|
package/config.example.json
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"progressLength": 12,
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
// "auto" (default), "truecolor", or "256"
|
|
4
|
+
"colorMode": "auto",
|
|
5
|
+
// First matching entry wins. Omit dark-mode/true-color to match both.
|
|
6
|
+
// bg: empty bar background color (integer for 256-color, [R,G,B] for truecolor)
|
|
7
|
+
"colors": [
|
|
8
|
+
// Truecolor on dark terminal: bright smooth gradient
|
|
9
|
+
{
|
|
10
|
+
"dark-mode": true,
|
|
11
|
+
"true-color": true,
|
|
12
|
+
"bg": [48, 48, 48],
|
|
13
|
+
"gradient": [[0,215,0], [95,215,0], [175,215,0], [255,255,0], [255,215,0], [255,175,0], [255,135,0], [255,95,0], [255,55,0], [255,0,0]]
|
|
14
|
+
},
|
|
15
|
+
// Truecolor on light terminal: medium intensity
|
|
16
|
+
{
|
|
17
|
+
"dark-mode": false,
|
|
18
|
+
"true-color": true,
|
|
19
|
+
"bg": [208, 208, 208],
|
|
20
|
+
"gradient": [[0,170,0], [75,170,0], [140,170,0], [200,200,0], [200,170,0], [200,135,0], [200,100,0], [200,65,0], [200,30,0], [190,0,0]]
|
|
21
|
+
},
|
|
22
|
+
// 256-color on dark terminal
|
|
23
|
+
{
|
|
24
|
+
"dark-mode": true,
|
|
25
|
+
"true-color": false,
|
|
26
|
+
"bg": 236,
|
|
27
|
+
"gradient": [46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196]
|
|
28
|
+
},
|
|
29
|
+
// 256-color on light terminal
|
|
30
|
+
{
|
|
31
|
+
"dark-mode": false,
|
|
32
|
+
"true-color": false,
|
|
33
|
+
"bg": 252,
|
|
34
|
+
"gradient": [40, 76, 112, 148, 184, 178, 172, 166, 160]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
5
37
|
}
|
package/lib/demo.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo mode: time-lapse animation showing the statusline filling up.
|
|
3
|
+
*
|
|
4
|
+
* Single continuous run (default 60s, configurable):
|
|
5
|
+
* - Weekly bar: 0→100%, 7d remaining → 0 (1 cycle)
|
|
6
|
+
* - 5-hour bar: 0→100%, 5h remaining → 0 (8 cycles)
|
|
7
|
+
* - Context bar: 0→100% (15 cycles)
|
|
8
|
+
*
|
|
9
|
+
* Git changes and lines added/removed accumulate on each context reset.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { progressBar, formatPercent, formatTimeRemaining, colors } = require('./statusline');
|
|
13
|
+
|
|
14
|
+
const FRAME_MS = 100;
|
|
15
|
+
|
|
16
|
+
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
17
|
+
const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const MODEL = 'Opus 4.5';
|
|
20
|
+
const TIER = 'Max 5x';
|
|
21
|
+
const BRANCH = 'feature/auth';
|
|
22
|
+
const CWD = '~/Projects/myapp';
|
|
23
|
+
|
|
24
|
+
const FRAME_LINES = 3;
|
|
25
|
+
|
|
26
|
+
const CONTEXT_CYCLES = 15;
|
|
27
|
+
const FIVE_HOUR_CYCLES = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render one frame: 3 statusline bars.
|
|
31
|
+
* Overwrites previous frame by moving cursor up.
|
|
32
|
+
*/
|
|
33
|
+
function renderFrame(state, isFirst) {
|
|
34
|
+
if (!isFirst) process.stdout.write(`\x1b[${FRAME_LINES}A`);
|
|
35
|
+
|
|
36
|
+
const lines = [];
|
|
37
|
+
|
|
38
|
+
// Line 1: context bar
|
|
39
|
+
const ctxBar = progressBar(state.context);
|
|
40
|
+
lines.push(
|
|
41
|
+
`${ctxBar} ${colors.cyan}${Math.round(state.context)}%${colors.reset} ` +
|
|
42
|
+
`${colors.magenta}${TIER}${colors.reset} ` +
|
|
43
|
+
`${colors.white}${MODEL}${colors.reset}`
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Line 2: 5-hour bar + git info
|
|
47
|
+
const fiveBar = progressBar(state.fiveHour);
|
|
48
|
+
const fivePct = formatPercent(state.fiveHour);
|
|
49
|
+
const fiveReset = formatTimeRemaining(state.fiveHourResetIn);
|
|
50
|
+
lines.push(
|
|
51
|
+
`${fiveBar} ${fivePct} ` +
|
|
52
|
+
`${colors.dim}${fiveReset}${colors.reset} ` +
|
|
53
|
+
`${colors.cyan}${BRANCH}${colors.reset}` +
|
|
54
|
+
(state.changes > 0 ? ` ${colors.yellow}+${state.changes}${colors.reset}` : '') +
|
|
55
|
+
` ${colors.green}+${state.linesAdded}${colors.reset}/${colors.red}-${state.linesRemoved}${colors.reset}`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Line 3: weekly bar + cwd
|
|
59
|
+
const weekBar = progressBar(state.weekly);
|
|
60
|
+
const weekPct = formatPercent(state.weekly);
|
|
61
|
+
const weekReset = formatTimeRemaining(state.weeklyResetIn);
|
|
62
|
+
lines.push(
|
|
63
|
+
`${weekBar} ${weekPct} ` +
|
|
64
|
+
`${colors.dim}${weekReset}${colors.reset} ` +
|
|
65
|
+
`${colors.white}${CWD}${colors.reset}`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
process.stdout.write(lines.map(l => '\x1b[2K' + l).join('\n') + '\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the demo animation.
|
|
73
|
+
* @param {number} [durationSec=60] Total duration in seconds.
|
|
74
|
+
*/
|
|
75
|
+
function runDemo(durationSec = 60) {
|
|
76
|
+
process.stdout.write('\x1b[?25l');
|
|
77
|
+
|
|
78
|
+
function cleanup() {
|
|
79
|
+
process.stdout.write('\x1b[?25h');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
process.on('SIGINT', cleanup);
|
|
83
|
+
process.on('SIGTERM', cleanup);
|
|
84
|
+
|
|
85
|
+
const totalFrames = durationSec * (1000 / FRAME_MS);
|
|
86
|
+
let frame = 0;
|
|
87
|
+
let changes = 0;
|
|
88
|
+
let linesAdded = 0;
|
|
89
|
+
let linesRemoved = 0;
|
|
90
|
+
let prevCtxCycle = 0;
|
|
91
|
+
|
|
92
|
+
const interval = setInterval(() => {
|
|
93
|
+
if (frame >= totalFrames) {
|
|
94
|
+
clearInterval(interval);
|
|
95
|
+
cleanup();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const t = frame / totalFrames; // 0→1 over full duration
|
|
100
|
+
const isLast = frame === totalFrames - 1;
|
|
101
|
+
|
|
102
|
+
// Weekly: single ramp 0→100%, 7d→0
|
|
103
|
+
const weekly = isLast ? 100 : t * 100;
|
|
104
|
+
const weeklyResetIn = isLast ? 0 : (1 - t) * SEVEN_DAY_MS;
|
|
105
|
+
|
|
106
|
+
// 5-hour: 8 sawtooth cycles (last frame pinned to 100%)
|
|
107
|
+
const fiveCycleT = (t * FIVE_HOUR_CYCLES) % 1;
|
|
108
|
+
const fiveHour = isLast ? 100 : fiveCycleT * 100;
|
|
109
|
+
const fiveHourResetIn = isLast ? 0 : (1 - fiveCycleT) * FIVE_HOUR_MS;
|
|
110
|
+
|
|
111
|
+
// Context: 15 sawtooth cycles (last frame pinned to 100%)
|
|
112
|
+
const ctxCycleT = (t * CONTEXT_CYCLES) % 1;
|
|
113
|
+
const context = isLast ? 100 : ctxCycleT * 100;
|
|
114
|
+
|
|
115
|
+
// Accumulate git stats on each context reset
|
|
116
|
+
const currCtxCycle = Math.floor(t * CONTEXT_CYCLES);
|
|
117
|
+
if (frame > 0 && currCtxCycle > prevCtxCycle) {
|
|
118
|
+
changes += Math.floor(Math.random() * 3) + 1;
|
|
119
|
+
linesAdded += Math.floor(Math.random() * 50) + 10;
|
|
120
|
+
linesRemoved += Math.floor(Math.random() * 20);
|
|
121
|
+
}
|
|
122
|
+
prevCtxCycle = currCtxCycle;
|
|
123
|
+
|
|
124
|
+
renderFrame({
|
|
125
|
+
context,
|
|
126
|
+
fiveHour,
|
|
127
|
+
fiveHourResetIn,
|
|
128
|
+
weekly,
|
|
129
|
+
weeklyResetIn,
|
|
130
|
+
changes,
|
|
131
|
+
linesAdded,
|
|
132
|
+
linesRemoved,
|
|
133
|
+
}, frame === 0);
|
|
134
|
+
|
|
135
|
+
frame++;
|
|
136
|
+
}, FRAME_MS);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { runDemo };
|
package/lib/statusline.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
19
20
|
const https = require('https');
|
|
20
21
|
const { execFile, execFileSync } = require('child_process');
|
|
21
22
|
const { promisify } = require('util');
|
|
@@ -28,6 +29,11 @@ const execFileAsync = promisify(execFile);
|
|
|
28
29
|
let _isDark = null;
|
|
29
30
|
function isDarkMode() {
|
|
30
31
|
if (_isDark !== null) return _isDark;
|
|
32
|
+
// Allow override for GIF recording without changing system settings
|
|
33
|
+
if (process.env.HOWMUCHLEFT_DARK !== undefined) {
|
|
34
|
+
_isDark = process.env.HOWMUCHLEFT_DARK === '1';
|
|
35
|
+
return _isDark;
|
|
36
|
+
}
|
|
31
37
|
try {
|
|
32
38
|
if (process.platform === 'darwin') {
|
|
33
39
|
// Returns "Dark" in dark mode, throws in light mode (key doesn't exist)
|
|
@@ -71,25 +77,148 @@ const TIER_NAMES = {
|
|
|
71
77
|
// -- Progress bar: left fractional block characters for sub-cell precision --
|
|
72
78
|
const FRACTIONAL_CHARS = ['\u258F','\u258E','\u258D','\u258C','\u258B','\u258A','\u2589'];
|
|
73
79
|
|
|
80
|
+
// -- JSONC support: strip // and /* */ comments for config file parsing --
|
|
81
|
+
function stripJsonComments(text) {
|
|
82
|
+
let result = '';
|
|
83
|
+
let inString = false;
|
|
84
|
+
let escape = false;
|
|
85
|
+
for (let i = 0; i < text.length; i++) {
|
|
86
|
+
const ch = text[i];
|
|
87
|
+
if (escape) { result += ch; escape = false; continue; }
|
|
88
|
+
if (inString) {
|
|
89
|
+
if (ch === '\\') escape = true;
|
|
90
|
+
else if (ch === '"') inString = false;
|
|
91
|
+
result += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"') { inString = true; result += ch; continue; }
|
|
95
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
96
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
97
|
+
result += '\n';
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
101
|
+
i += 2;
|
|
102
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
103
|
+
i++; // skip past closing /
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
result += ch;
|
|
107
|
+
}
|
|
108
|
+
// Strip trailing commas before } or ] (common JSONC foot-gun)
|
|
109
|
+
return result.replace(/,\s*([}\]])/g, '$1');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseJsonc(text) {
|
|
113
|
+
return JSON.parse(stripJsonComments(text));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -- Truecolor detection --
|
|
117
|
+
function isTruecolorSupported() {
|
|
118
|
+
const ct = process.env.COLORTERM;
|
|
119
|
+
return ct === 'truecolor' || ct === '24bit';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -- Built-in gradient defaults for all 4 combos --
|
|
123
|
+
// Each entry: optional dark-mode/true-color conditions, gradient stops.
|
|
124
|
+
// RGB arrays for truecolor, integer indices for 256-color.
|
|
125
|
+
const BUILTIN_COLORS = [
|
|
126
|
+
{ 'dark-mode': true, 'true-color': true, bg: [48, 48, 48], gradient: [
|
|
127
|
+
[0,215,0], [95,215,0], [175,215,0], [255,255,0], [255,215,0], [255,175,0], [255,135,0], [255,95,0], [255,55,0], [255,0,0],
|
|
128
|
+
]},
|
|
129
|
+
{ 'dark-mode': false, 'true-color': true, bg: [208, 208, 208], gradient: [
|
|
130
|
+
[0,170,0], [75,170,0], [140,170,0], [200,200,0], [200,170,0], [200,135,0], [200,100,0], [200,65,0], [200,30,0], [190,0,0],
|
|
131
|
+
]},
|
|
132
|
+
{ 'dark-mode': true, 'true-color': false, bg: 236, gradient: [46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196] },
|
|
133
|
+
{ 'dark-mode': false, 'true-color': false, bg: 252, gradient: [40, 76, 112, 148, 184, 178, 172, 166, 160] },
|
|
134
|
+
];
|
|
135
|
+
|
|
74
136
|
// Config is loaded from ~/.config/howmuchleft.json (XDG-style, install-method-agnostic).
|
|
75
|
-
//
|
|
137
|
+
// Supports JSONC (comments and trailing commas).
|
|
138
|
+
// Cached per-process to avoid redundant fs reads.
|
|
76
139
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'howmuchleft.json');
|
|
77
140
|
|
|
141
|
+
// Check if a gradient stop is an RGB array [R,G,B]
|
|
142
|
+
function isRgbStop(stop) {
|
|
143
|
+
return Array.isArray(stop) && stop.length === 3 &&
|
|
144
|
+
stop.every(v => Number.isInteger(v) && v >= 0 && v <= 255);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validate a gradient: non-empty array of either all ints or all RGB arrays
|
|
148
|
+
function isValidGradient(arr) {
|
|
149
|
+
if (!Array.isArray(arr) || arr.length === 0) return false;
|
|
150
|
+
if (isRgbStop(arr[0])) return arr.every(isRgbStop);
|
|
151
|
+
return arr.every(v => Number.isInteger(v) && v >= 0 && v <= 255);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Find first matching color entry from a colors array (returns full entry)
|
|
155
|
+
function findColorMatch(colorsArr, isDark, isTruecolor) {
|
|
156
|
+
for (const entry of colorsArr) {
|
|
157
|
+
if (entry['dark-mode'] !== undefined && entry['dark-mode'] !== isDark) continue;
|
|
158
|
+
if (entry['true-color'] !== undefined && entry['true-color'] !== isTruecolor) continue;
|
|
159
|
+
if (isValidGradient(entry.gradient)) return entry;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Format a bg value (integer 0-255 or [R,G,B]) as an ANSI background escape
|
|
165
|
+
function formatBgEscape(bg, truecolor) {
|
|
166
|
+
if (isRgbStop(bg)) {
|
|
167
|
+
if (truecolor) return `\x1b[48;2;${bg[0]};${bg[1]};${bg[2]}m`;
|
|
168
|
+
return `\x1b[48;5;${rgbTo256(bg)}m`;
|
|
169
|
+
}
|
|
170
|
+
if (Number.isInteger(bg) && bg >= 0 && bg <= 255) return `\x1b[48;5;${bg}m`;
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Convert RGB [R,G,B] to nearest 256-color cube index
|
|
175
|
+
function rgbTo256(rgb) {
|
|
176
|
+
return 16 + 36 * Math.round(rgb[0] / 255 * 5) + 6 * Math.round(rgb[1] / 255 * 5) + Math.round(rgb[2] / 255 * 5);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Linearly interpolate between RGB gradient stops for smooth truecolor
|
|
180
|
+
function interpolateRgb(stops, t) {
|
|
181
|
+
const pos = t * (stops.length - 1);
|
|
182
|
+
const lo = Math.floor(pos);
|
|
183
|
+
const hi = Math.min(lo + 1, stops.length - 1);
|
|
184
|
+
const frac = pos - lo;
|
|
185
|
+
return [
|
|
186
|
+
Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * frac),
|
|
187
|
+
Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * frac),
|
|
188
|
+
Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * frac),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
78
192
|
let _barConfig = null;
|
|
79
193
|
function getBarConfig() {
|
|
80
194
|
if (_barConfig) return _barConfig;
|
|
195
|
+
|
|
196
|
+
const dark = isDarkMode();
|
|
197
|
+
let colorMode = 'auto';
|
|
198
|
+
let width = 12;
|
|
199
|
+
let userColors = [];
|
|
200
|
+
|
|
81
201
|
try {
|
|
82
|
-
const
|
|
202
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
203
|
+
const config = parseJsonc(raw);
|
|
83
204
|
const w = Number(config.progressLength);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
205
|
+
if (Number.isInteger(w) && w >= 3 && w <= 40) width = w;
|
|
206
|
+
if (config.colorMode === 'truecolor' || config.colorMode === '256') colorMode = config.colorMode;
|
|
207
|
+
if (Array.isArray(config.colors)) userColors = config.colors;
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
const truecolor = colorMode === 'truecolor' || (colorMode === 'auto' && isTruecolorSupported());
|
|
211
|
+
const userMatch = findColorMatch(userColors, dark, truecolor);
|
|
212
|
+
const builtinMatch = findColorMatch(BUILTIN_COLORS, dark, truecolor);
|
|
213
|
+
const gradient = userMatch?.gradient || builtinMatch.gradient;
|
|
214
|
+
const isRgb = isRgbStop(gradient[0]);
|
|
215
|
+
|
|
216
|
+
// bg: user entry wins, then builtin, then hardcoded fallback
|
|
217
|
+
const emptyBg = formatBgEscape(userMatch?.bg, truecolor) ||
|
|
218
|
+
formatBgEscape(builtinMatch?.bg, truecolor) ||
|
|
219
|
+
`\x1b[48;5;${dark ? 236 : 252}m`;
|
|
220
|
+
|
|
221
|
+
_barConfig = { width, emptyBg, gradient, truecolor, isRgb };
|
|
93
222
|
return _barConfig;
|
|
94
223
|
}
|
|
95
224
|
|
|
@@ -110,12 +239,57 @@ function getClaudeDir() {
|
|
|
110
239
|
return resolvePath(arg);
|
|
111
240
|
}
|
|
112
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Read credentials from the macOS Keychain.
|
|
244
|
+
* Claude Code stores OAuth tokens in the Keychain on macOS instead of
|
|
245
|
+
* (or in addition to) the .credentials.json file. The service name includes
|
|
246
|
+
* a hash of the config dir path to support multiple accounts.
|
|
247
|
+
*/
|
|
248
|
+
function readKeychainCredentials(claudeDir) {
|
|
249
|
+
if (process.platform !== 'darwin') return null;
|
|
250
|
+
const hash = crypto.createHash('sha256').update(claudeDir).digest('hex').slice(0, 8);
|
|
251
|
+
// Try current hashed service name, then legacy unhashed name
|
|
252
|
+
for (const svc of [`Claude Code-credentials-${hash}`, 'Claude Code-credentials']) {
|
|
253
|
+
try {
|
|
254
|
+
const raw = execFileSync('security', [
|
|
255
|
+
'find-generic-password', '-s', svc, '-w',
|
|
256
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
257
|
+
const parsed = JSON.parse(raw.trim());
|
|
258
|
+
if (parsed?.claudeAiOauth?.accessToken) return parsed;
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Cached per-process: credentials won't change during a single render cycle.
|
|
265
|
+
let _credentialsRead = false;
|
|
266
|
+
let _credentialsCache = null;
|
|
267
|
+
|
|
113
268
|
function readCredentialsFile(claudeDir) {
|
|
269
|
+
if (_credentialsRead) return _credentialsCache;
|
|
270
|
+
_credentialsRead = true;
|
|
271
|
+
|
|
272
|
+
// Try file first (fast, no system prompt)
|
|
273
|
+
let fileData = null;
|
|
114
274
|
try {
|
|
115
|
-
|
|
116
|
-
} catch {
|
|
117
|
-
|
|
275
|
+
fileData = JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
|
|
276
|
+
} catch {}
|
|
277
|
+
if (fileData?.claudeAiOauth?.accessToken) {
|
|
278
|
+
_credentialsCache = fileData;
|
|
279
|
+
return fileData;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// macOS: try Keychain when file lacks OAuth credentials
|
|
283
|
+
const keychainData = readKeychainCredentials(claudeDir);
|
|
284
|
+
if (keychainData) {
|
|
285
|
+
// Merge: preserve file data (mcpOAuth etc.) with keychain OAuth
|
|
286
|
+
const merged = { ...(fileData || {}), claudeAiOauth: keychainData.claudeAiOauth };
|
|
287
|
+
_credentialsCache = merged;
|
|
288
|
+
return merged;
|
|
118
289
|
}
|
|
290
|
+
|
|
291
|
+
_credentialsCache = fileData;
|
|
292
|
+
return fileData;
|
|
119
293
|
}
|
|
120
294
|
|
|
121
295
|
/**
|
|
@@ -422,26 +596,41 @@ async function readStdin() {
|
|
|
422
596
|
process.stdin.setEncoding('utf8');
|
|
423
597
|
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
424
598
|
process.stdin.on('end', () => {
|
|
425
|
-
try {
|
|
426
|
-
|
|
599
|
+
try {
|
|
600
|
+
resolve(data.trim() ? JSON.parse(data) : {});
|
|
601
|
+
} catch (err) {
|
|
602
|
+
console.warn(`howmuchleft: failed to parse stdin JSON: ${err.message}`);
|
|
603
|
+
resolve({});
|
|
604
|
+
}
|
|
427
605
|
});
|
|
428
606
|
});
|
|
429
607
|
}
|
|
430
608
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
609
|
+
/**
|
|
610
|
+
* Get foreground and background ANSI escape codes for a given percent.
|
|
611
|
+
* Uses smooth RGB interpolation for truecolor, nearest-stop for 256-color.
|
|
612
|
+
*/
|
|
613
|
+
function getGradientStop(percent) {
|
|
614
|
+
const { gradient, truecolor, isRgb } = getBarConfig();
|
|
615
|
+
const t = Math.max(0, Math.min(1, percent / 100));
|
|
438
616
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
617
|
+
if (isRgb) {
|
|
618
|
+
const rgb = interpolateRgb(gradient, t);
|
|
619
|
+
if (truecolor) {
|
|
620
|
+
return {
|
|
621
|
+
fg: `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`,
|
|
622
|
+
bg: `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
// RGB gradient on 256-color terminal: convert to nearest index
|
|
626
|
+
const idx = rgbTo256(rgb);
|
|
627
|
+
return { fg: `\x1b[38;5;${idx}m`, bg: `\x1b[48;5;${idx}m` };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 256-color gradient: snap to nearest stop
|
|
631
|
+
const idx = Math.round(t * (gradient.length - 1));
|
|
632
|
+
const colorIdx = gradient[idx];
|
|
633
|
+
return { fg: `\x1b[38;5;${colorIdx}m`, bg: `\x1b[48;5;${colorIdx}m` };
|
|
445
634
|
}
|
|
446
635
|
|
|
447
636
|
/**
|
|
@@ -452,27 +641,26 @@ function getProgressBgColor(percent) {
|
|
|
452
641
|
function progressBar(percent, width) {
|
|
453
642
|
const config = getBarConfig();
|
|
454
643
|
if (width == null) width = config.width;
|
|
455
|
-
const emptyBg =
|
|
644
|
+
const emptyBg = config.emptyBg;
|
|
456
645
|
|
|
457
646
|
if (percent === null || percent === undefined) {
|
|
458
647
|
return `${emptyBg}${' '.repeat(width)}${colors.reset}`;
|
|
459
648
|
}
|
|
460
649
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
461
|
-
const
|
|
462
|
-
const bgColor = getProgressBgColor(clamped);
|
|
650
|
+
const { fg, bg } = getGradientStop(clamped);
|
|
463
651
|
const fillFrac = clamped / 100;
|
|
464
652
|
let out = '';
|
|
465
653
|
for (let i = 0; i < width; i++) {
|
|
466
654
|
const cellStart = i / width;
|
|
467
655
|
const cellEnd = (i + 1) / width;
|
|
468
656
|
if (cellEnd <= fillFrac) {
|
|
469
|
-
out +=
|
|
657
|
+
out += bg + ' ';
|
|
470
658
|
} else if (cellStart >= fillFrac) {
|
|
471
659
|
out += emptyBg + ' ';
|
|
472
660
|
} else {
|
|
473
661
|
const cellFill = (fillFrac - cellStart) / (1 / width);
|
|
474
662
|
const idx = Math.max(0, Math.min(FRACTIONAL_CHARS.length - 1, Math.floor(cellFill * FRACTIONAL_CHARS.length)));
|
|
475
|
-
out += emptyBg +
|
|
663
|
+
out += emptyBg + fg + FRACTIONAL_CHARS[idx];
|
|
476
664
|
}
|
|
477
665
|
}
|
|
478
666
|
return out + colors.reset;
|
|
@@ -608,7 +796,38 @@ async function main() {
|
|
|
608
796
|
console.log(lines.join('\n'));
|
|
609
797
|
}
|
|
610
798
|
|
|
611
|
-
|
|
799
|
+
/**
|
|
800
|
+
* Render a gradient swatch so users can preview their color config.
|
|
801
|
+
* Shows bars at 0%, 25%, 50%, 75%, 100% plus a continuous gradient strip.
|
|
802
|
+
*/
|
|
803
|
+
function testColors() {
|
|
804
|
+
const config = getBarConfig();
|
|
805
|
+
const dark = isDarkMode();
|
|
806
|
+
const tc = config.truecolor;
|
|
807
|
+
|
|
808
|
+
console.log(`Mode: ${dark ? 'dark' : 'light'}, Color: ${tc ? 'truecolor' : '256-color'}, Width: ${config.width}`);
|
|
809
|
+
console.log();
|
|
810
|
+
|
|
811
|
+
// Sample bars at key percentages
|
|
812
|
+
for (const pct of [5, 20, 35, 50, 65, 80, 95]) {
|
|
813
|
+
const bar = progressBar(pct);
|
|
814
|
+
console.log(`${bar} ${pct}%`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
console.log();
|
|
818
|
+
|
|
819
|
+
// Continuous gradient strip (40 cells, each colored by position)
|
|
820
|
+
const stripWidth = 40;
|
|
821
|
+
let strip = '';
|
|
822
|
+
for (let i = 0; i < stripWidth; i++) {
|
|
823
|
+
const pct = (i / (stripWidth - 1)) * 100;
|
|
824
|
+
const { bg } = getGradientStop(pct);
|
|
825
|
+
strip += bg + ' ';
|
|
826
|
+
}
|
|
827
|
+
console.log(strip + colors.reset + ' gradient');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
module.exports = { main, CONFIG_PATH, progressBar, formatPercent, formatTimeRemaining, colors, testColors };
|
|
612
831
|
|
|
613
832
|
// Run directly when invoked as a script (not when required by cli.js)
|
|
614
833
|
if (require.main === module) {
|
package/package.json
CHANGED