howmuchleft 0.1.0 → 0.2.0
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 +54 -28
- package/bin/cli.js +26 -8
- package/config.example.json +34 -2
- package/lib/demo.js +139 -0
- package/lib/statusline.js +205 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,15 +5,23 @@
|
|
|
5
5
|
[](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
|
|
8
|
-
Pixel-perfect progress bars
|
|
8
|
+
Pixel-perfect progress bars for your Claude Code statusline. See how much context and usage you have left at a glance.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
| Dark | Light |
|
|
11
|
+
|---|---|
|
|
12
|
+
|  |  |
|
|
11
13
|
|
|
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
|
|
14
|
+
## What you get
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Three bars with sub-cell precision using Unicode fractional block characters:
|
|
17
|
+
|
|
18
|
+
| Bar | Shows | Extra info |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Context window | How full your conversation is | Subscription tier, model name |
|
|
21
|
+
| 5-hour usage | Rolling rate limit utilization | Time until reset, git branch/changes, lines added/removed |
|
|
22
|
+
| Weekly usage | Rolling 7-day rate limit utilization | Time until reset, current directory |
|
|
23
|
+
|
|
24
|
+
Bars shift green to red as usage increases. Stale data (API unreachable) is prefixed with `~`. Works with Pro, Max 5x, Max 20x, and Team subscriptions. API key users see an "API" label with context bar only.
|
|
17
25
|
|
|
18
26
|
## Install
|
|
19
27
|
|
|
@@ -22,7 +30,7 @@ npm install -g howmuchleft
|
|
|
22
30
|
howmuchleft --install
|
|
23
31
|
```
|
|
24
32
|
|
|
25
|
-
For multiple Claude Code
|
|
33
|
+
For multiple Claude Code config directories:
|
|
26
34
|
|
|
27
35
|
```bash
|
|
28
36
|
howmuchleft --install ~/.claude-work
|
|
@@ -31,49 +39,67 @@ howmuchleft --install ~/.claude-personal
|
|
|
31
39
|
|
|
32
40
|
## Configuration
|
|
33
41
|
|
|
34
|
-
Config file: `~/.config/howmuchleft.json`
|
|
42
|
+
Config file: `~/.config/howmuchleft.json` (JSONC -- comments and trailing commas are allowed).
|
|
35
43
|
|
|
36
|
-
```
|
|
44
|
+
```jsonc
|
|
37
45
|
{
|
|
38
46
|
"progressLength": 12,
|
|
39
|
-
"
|
|
40
|
-
"
|
|
47
|
+
"colorMode": "auto",
|
|
48
|
+
"colors": [
|
|
49
|
+
// Truecolor dark: RGB gradient + RGB background
|
|
50
|
+
{ "dark-mode": true, "true-color": true, "bg": [48, 48, 48], "gradient": [[0,215,0], [255,255,0], [255,0,0]] },
|
|
51
|
+
// 256-color dark: index gradient + index background
|
|
52
|
+
{ "dark-mode": true, "true-color": false, "bg": 236, "gradient": [46, 226, 196] }
|
|
53
|
+
]
|
|
41
54
|
}
|
|
42
55
|
```
|
|
43
56
|
|
|
57
|
+
### Top-level settings
|
|
58
|
+
|
|
44
59
|
| Field | Default | Description |
|
|
45
60
|
|---|---|---|
|
|
46
|
-
| `progressLength` | `12` | Bar width in characters (3
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
61
|
+
| `progressLength` | `12` | Bar width in characters (3--40) |
|
|
62
|
+
| `colorMode` | `"auto"` | `"auto"` (detect via `COLORTERM`), `"truecolor"`, or `"256"` |
|
|
63
|
+
| `colors` | built-in | Array of color entries (see below) |
|
|
49
64
|
|
|
50
|
-
|
|
65
|
+
### Color entries
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
howmuchleft --config
|
|
54
|
-
```
|
|
67
|
+
Each entry in the `colors` array is matched against the current terminal. First match wins. Omit a condition to match both modes.
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
| Field | Required | Description |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `gradient` | Yes | Color stops: `[R,G,B]` arrays for truecolor, or integers (0--255) for 256-color |
|
|
72
|
+
| `bg` | No | Empty bar background: `[R,G,B]` for truecolor, or integer (0--255) for 256-color |
|
|
73
|
+
| `dark-mode` | No | Match dark (`true`) or light (`false`) terminals only |
|
|
74
|
+
| `true-color` | No | Match truecolor (`true`) or 256-color (`false`) terminals only |
|
|
57
75
|
|
|
58
|
-
|
|
76
|
+
Truecolor gradients are smoothly interpolated between stops -- 3 stops (green, yellow, red) is enough for a smooth bar. 256-color gradients snap to the nearest stop.
|
|
59
77
|
|
|
60
|
-
|
|
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
|
|
78
|
+
To preview your current gradient: `howmuchleft --test-colors`
|
|
65
79
|
|
|
66
80
|
## CLI
|
|
67
81
|
|
|
68
82
|
```
|
|
69
83
|
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
|
|
84
|
+
howmuchleft --install [config-dir] Add to Claude Code settings.json
|
|
85
|
+
howmuchleft --uninstall [config-dir] Remove from Claude Code settings.json
|
|
72
86
|
howmuchleft --config Show config path and current settings
|
|
87
|
+
howmuchleft --demo [seconds] Time-lapse animation (default 60s)
|
|
88
|
+
howmuchleft --test-colors Preview gradient at seven sample levels
|
|
73
89
|
howmuchleft --version Show version
|
|
74
90
|
howmuchleft --help Show help
|
|
75
91
|
```
|
|
76
92
|
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
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:
|
|
96
|
+
|
|
97
|
+
1. Parses the JSON from stdin
|
|
98
|
+
2. Fetches usage data from Anthropic's OAuth API (cached 60s, stale-data fallback on failure)
|
|
99
|
+
3. Auto-refreshes expired OAuth tokens
|
|
100
|
+
4. Runs `git status --porcelain=v2` in parallel for branch/change info
|
|
101
|
+
5. Renders three lines of ANSI-colored progress bars to stdout
|
|
102
|
+
|
|
77
103
|
## Uninstall
|
|
78
104
|
|
|
79
105
|
```bash
|
|
@@ -84,6 +110,6 @@ npm uninstall -g howmuchleft
|
|
|
84
110
|
## Requirements
|
|
85
111
|
|
|
86
112
|
- Node.js >= 18
|
|
87
|
-
- Claude Code with OAuth login (Pro
|
|
113
|
+
- Claude Code with OAuth login (Pro, Max, or Team subscription)
|
|
88
114
|
- `git` (optional, for branch/change display)
|
|
89
115
|
- `gsettings` (Linux/GNOME) or `defaults` (macOS) for dark/light mode detection
|
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
|
@@ -28,6 +28,11 @@ const execFileAsync = promisify(execFile);
|
|
|
28
28
|
let _isDark = null;
|
|
29
29
|
function isDarkMode() {
|
|
30
30
|
if (_isDark !== null) return _isDark;
|
|
31
|
+
// Allow override for GIF recording without changing system settings
|
|
32
|
+
if (process.env.HOWMUCHLEFT_DARK !== undefined) {
|
|
33
|
+
_isDark = process.env.HOWMUCHLEFT_DARK === '1';
|
|
34
|
+
return _isDark;
|
|
35
|
+
}
|
|
31
36
|
try {
|
|
32
37
|
if (process.platform === 'darwin') {
|
|
33
38
|
// Returns "Dark" in dark mode, throws in light mode (key doesn't exist)
|
|
@@ -71,25 +76,148 @@ const TIER_NAMES = {
|
|
|
71
76
|
// -- Progress bar: left fractional block characters for sub-cell precision --
|
|
72
77
|
const FRACTIONAL_CHARS = ['\u258F','\u258E','\u258D','\u258C','\u258B','\u258A','\u2589'];
|
|
73
78
|
|
|
79
|
+
// -- JSONC support: strip // and /* */ comments for config file parsing --
|
|
80
|
+
function stripJsonComments(text) {
|
|
81
|
+
let result = '';
|
|
82
|
+
let inString = false;
|
|
83
|
+
let escape = false;
|
|
84
|
+
for (let i = 0; i < text.length; i++) {
|
|
85
|
+
const ch = text[i];
|
|
86
|
+
if (escape) { result += ch; escape = false; continue; }
|
|
87
|
+
if (inString) {
|
|
88
|
+
if (ch === '\\') escape = true;
|
|
89
|
+
else if (ch === '"') inString = false;
|
|
90
|
+
result += ch;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === '"') { inString = true; result += ch; continue; }
|
|
94
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
95
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
96
|
+
result += '\n';
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
100
|
+
i += 2;
|
|
101
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
102
|
+
i++; // skip past closing /
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
result += ch;
|
|
106
|
+
}
|
|
107
|
+
// Strip trailing commas before } or ] (common JSONC foot-gun)
|
|
108
|
+
return result.replace(/,\s*([}\]])/g, '$1');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseJsonc(text) {
|
|
112
|
+
return JSON.parse(stripJsonComments(text));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// -- Truecolor detection --
|
|
116
|
+
function isTruecolorSupported() {
|
|
117
|
+
const ct = process.env.COLORTERM;
|
|
118
|
+
return ct === 'truecolor' || ct === '24bit';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// -- Built-in gradient defaults for all 4 combos --
|
|
122
|
+
// Each entry: optional dark-mode/true-color conditions, gradient stops.
|
|
123
|
+
// RGB arrays for truecolor, integer indices for 256-color.
|
|
124
|
+
const BUILTIN_COLORS = [
|
|
125
|
+
{ 'dark-mode': true, 'true-color': true, bg: [48, 48, 48], gradient: [
|
|
126
|
+
[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],
|
|
127
|
+
]},
|
|
128
|
+
{ 'dark-mode': false, 'true-color': true, bg: [208, 208, 208], gradient: [
|
|
129
|
+
[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],
|
|
130
|
+
]},
|
|
131
|
+
{ 'dark-mode': true, 'true-color': false, bg: 236, gradient: [46, 82, 118, 154, 190, 226, 220, 214, 208, 202, 196] },
|
|
132
|
+
{ 'dark-mode': false, 'true-color': false, bg: 252, gradient: [40, 76, 112, 148, 184, 178, 172, 166, 160] },
|
|
133
|
+
];
|
|
134
|
+
|
|
74
135
|
// Config is loaded from ~/.config/howmuchleft.json (XDG-style, install-method-agnostic).
|
|
75
|
-
//
|
|
136
|
+
// Supports JSONC (comments and trailing commas).
|
|
137
|
+
// Cached per-process to avoid redundant fs reads.
|
|
76
138
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'howmuchleft.json');
|
|
77
139
|
|
|
140
|
+
// Check if a gradient stop is an RGB array [R,G,B]
|
|
141
|
+
function isRgbStop(stop) {
|
|
142
|
+
return Array.isArray(stop) && stop.length === 3 &&
|
|
143
|
+
stop.every(v => Number.isInteger(v) && v >= 0 && v <= 255);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate a gradient: non-empty array of either all ints or all RGB arrays
|
|
147
|
+
function isValidGradient(arr) {
|
|
148
|
+
if (!Array.isArray(arr) || arr.length === 0) return false;
|
|
149
|
+
if (isRgbStop(arr[0])) return arr.every(isRgbStop);
|
|
150
|
+
return arr.every(v => Number.isInteger(v) && v >= 0 && v <= 255);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Find first matching color entry from a colors array (returns full entry)
|
|
154
|
+
function findColorMatch(colorsArr, isDark, isTruecolor) {
|
|
155
|
+
for (const entry of colorsArr) {
|
|
156
|
+
if (entry['dark-mode'] !== undefined && entry['dark-mode'] !== isDark) continue;
|
|
157
|
+
if (entry['true-color'] !== undefined && entry['true-color'] !== isTruecolor) continue;
|
|
158
|
+
if (isValidGradient(entry.gradient)) return entry;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Format a bg value (integer 0-255 or [R,G,B]) as an ANSI background escape
|
|
164
|
+
function formatBgEscape(bg, truecolor) {
|
|
165
|
+
if (isRgbStop(bg)) {
|
|
166
|
+
if (truecolor) return `\x1b[48;2;${bg[0]};${bg[1]};${bg[2]}m`;
|
|
167
|
+
return `\x1b[48;5;${rgbTo256(bg)}m`;
|
|
168
|
+
}
|
|
169
|
+
if (Number.isInteger(bg) && bg >= 0 && bg <= 255) return `\x1b[48;5;${bg}m`;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Convert RGB [R,G,B] to nearest 256-color cube index
|
|
174
|
+
function rgbTo256(rgb) {
|
|
175
|
+
return 16 + 36 * Math.round(rgb[0] / 255 * 5) + 6 * Math.round(rgb[1] / 255 * 5) + Math.round(rgb[2] / 255 * 5);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Linearly interpolate between RGB gradient stops for smooth truecolor
|
|
179
|
+
function interpolateRgb(stops, t) {
|
|
180
|
+
const pos = t * (stops.length - 1);
|
|
181
|
+
const lo = Math.floor(pos);
|
|
182
|
+
const hi = Math.min(lo + 1, stops.length - 1);
|
|
183
|
+
const frac = pos - lo;
|
|
184
|
+
return [
|
|
185
|
+
Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * frac),
|
|
186
|
+
Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * frac),
|
|
187
|
+
Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * frac),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
78
191
|
let _barConfig = null;
|
|
79
192
|
function getBarConfig() {
|
|
80
193
|
if (_barConfig) return _barConfig;
|
|
194
|
+
|
|
195
|
+
const dark = isDarkMode();
|
|
196
|
+
let colorMode = 'auto';
|
|
197
|
+
let width = 12;
|
|
198
|
+
let userColors = [];
|
|
199
|
+
|
|
81
200
|
try {
|
|
82
|
-
const
|
|
201
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
202
|
+
const config = parseJsonc(raw);
|
|
83
203
|
const w = Number(config.progressLength);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
204
|
+
if (Number.isInteger(w) && w >= 3 && w <= 40) width = w;
|
|
205
|
+
if (config.colorMode === 'truecolor' || config.colorMode === '256') colorMode = config.colorMode;
|
|
206
|
+
if (Array.isArray(config.colors)) userColors = config.colors;
|
|
207
|
+
} catch {}
|
|
208
|
+
|
|
209
|
+
const truecolor = colorMode === 'truecolor' || (colorMode === 'auto' && isTruecolorSupported());
|
|
210
|
+
const userMatch = findColorMatch(userColors, dark, truecolor);
|
|
211
|
+
const builtinMatch = findColorMatch(BUILTIN_COLORS, dark, truecolor);
|
|
212
|
+
const gradient = userMatch?.gradient || builtinMatch.gradient;
|
|
213
|
+
const isRgb = isRgbStop(gradient[0]);
|
|
214
|
+
|
|
215
|
+
// bg: user entry wins, then builtin, then hardcoded fallback
|
|
216
|
+
const emptyBg = formatBgEscape(userMatch?.bg, truecolor) ||
|
|
217
|
+
formatBgEscape(builtinMatch?.bg, truecolor) ||
|
|
218
|
+
`\x1b[48;5;${dark ? 236 : 252}m`;
|
|
219
|
+
|
|
220
|
+
_barConfig = { width, emptyBg, gradient, truecolor, isRgb };
|
|
93
221
|
return _barConfig;
|
|
94
222
|
}
|
|
95
223
|
|
|
@@ -422,26 +550,41 @@ async function readStdin() {
|
|
|
422
550
|
process.stdin.setEncoding('utf8');
|
|
423
551
|
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
424
552
|
process.stdin.on('end', () => {
|
|
425
|
-
try {
|
|
426
|
-
|
|
553
|
+
try {
|
|
554
|
+
resolve(data.trim() ? JSON.parse(data) : {});
|
|
555
|
+
} catch (err) {
|
|
556
|
+
console.warn(`howmuchleft: failed to parse stdin JSON: ${err.message}`);
|
|
557
|
+
resolve({});
|
|
558
|
+
}
|
|
427
559
|
});
|
|
428
560
|
});
|
|
429
561
|
}
|
|
430
562
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Get foreground and background ANSI escape codes for a given percent.
|
|
565
|
+
* Uses smooth RGB interpolation for truecolor, nearest-stop for 256-color.
|
|
566
|
+
*/
|
|
567
|
+
function getGradientStop(percent) {
|
|
568
|
+
const { gradient, truecolor, isRgb } = getBarConfig();
|
|
569
|
+
const t = Math.max(0, Math.min(1, percent / 100));
|
|
438
570
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
571
|
+
if (isRgb) {
|
|
572
|
+
const rgb = interpolateRgb(gradient, t);
|
|
573
|
+
if (truecolor) {
|
|
574
|
+
return {
|
|
575
|
+
fg: `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`,
|
|
576
|
+
bg: `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
// RGB gradient on 256-color terminal: convert to nearest index
|
|
580
|
+
const idx = rgbTo256(rgb);
|
|
581
|
+
return { fg: `\x1b[38;5;${idx}m`, bg: `\x1b[48;5;${idx}m` };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 256-color gradient: snap to nearest stop
|
|
585
|
+
const idx = Math.round(t * (gradient.length - 1));
|
|
586
|
+
const colorIdx = gradient[idx];
|
|
587
|
+
return { fg: `\x1b[38;5;${colorIdx}m`, bg: `\x1b[48;5;${colorIdx}m` };
|
|
445
588
|
}
|
|
446
589
|
|
|
447
590
|
/**
|
|
@@ -452,27 +595,26 @@ function getProgressBgColor(percent) {
|
|
|
452
595
|
function progressBar(percent, width) {
|
|
453
596
|
const config = getBarConfig();
|
|
454
597
|
if (width == null) width = config.width;
|
|
455
|
-
const emptyBg =
|
|
598
|
+
const emptyBg = config.emptyBg;
|
|
456
599
|
|
|
457
600
|
if (percent === null || percent === undefined) {
|
|
458
601
|
return `${emptyBg}${' '.repeat(width)}${colors.reset}`;
|
|
459
602
|
}
|
|
460
603
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
461
|
-
const
|
|
462
|
-
const bgColor = getProgressBgColor(clamped);
|
|
604
|
+
const { fg, bg } = getGradientStop(clamped);
|
|
463
605
|
const fillFrac = clamped / 100;
|
|
464
606
|
let out = '';
|
|
465
607
|
for (let i = 0; i < width; i++) {
|
|
466
608
|
const cellStart = i / width;
|
|
467
609
|
const cellEnd = (i + 1) / width;
|
|
468
610
|
if (cellEnd <= fillFrac) {
|
|
469
|
-
out +=
|
|
611
|
+
out += bg + ' ';
|
|
470
612
|
} else if (cellStart >= fillFrac) {
|
|
471
613
|
out += emptyBg + ' ';
|
|
472
614
|
} else {
|
|
473
615
|
const cellFill = (fillFrac - cellStart) / (1 / width);
|
|
474
616
|
const idx = Math.max(0, Math.min(FRACTIONAL_CHARS.length - 1, Math.floor(cellFill * FRACTIONAL_CHARS.length)));
|
|
475
|
-
out += emptyBg +
|
|
617
|
+
out += emptyBg + fg + FRACTIONAL_CHARS[idx];
|
|
476
618
|
}
|
|
477
619
|
}
|
|
478
620
|
return out + colors.reset;
|
|
@@ -608,7 +750,38 @@ async function main() {
|
|
|
608
750
|
console.log(lines.join('\n'));
|
|
609
751
|
}
|
|
610
752
|
|
|
611
|
-
|
|
753
|
+
/**
|
|
754
|
+
* Render a gradient swatch so users can preview their color config.
|
|
755
|
+
* Shows bars at 0%, 25%, 50%, 75%, 100% plus a continuous gradient strip.
|
|
756
|
+
*/
|
|
757
|
+
function testColors() {
|
|
758
|
+
const config = getBarConfig();
|
|
759
|
+
const dark = isDarkMode();
|
|
760
|
+
const tc = config.truecolor;
|
|
761
|
+
|
|
762
|
+
console.log(`Mode: ${dark ? 'dark' : 'light'}, Color: ${tc ? 'truecolor' : '256-color'}, Width: ${config.width}`);
|
|
763
|
+
console.log();
|
|
764
|
+
|
|
765
|
+
// Sample bars at key percentages
|
|
766
|
+
for (const pct of [5, 20, 35, 50, 65, 80, 95]) {
|
|
767
|
+
const bar = progressBar(pct);
|
|
768
|
+
console.log(`${bar} ${pct}%`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
console.log();
|
|
772
|
+
|
|
773
|
+
// Continuous gradient strip (40 cells, each colored by position)
|
|
774
|
+
const stripWidth = 40;
|
|
775
|
+
let strip = '';
|
|
776
|
+
for (let i = 0; i < stripWidth; i++) {
|
|
777
|
+
const pct = (i / (stripWidth - 1)) * 100;
|
|
778
|
+
const { bg } = getGradientStop(pct);
|
|
779
|
+
strip += bg + ' ';
|
|
780
|
+
}
|
|
781
|
+
console.log(strip + colors.reset + ' gradient');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
module.exports = { main, CONFIG_PATH, progressBar, formatPercent, formatTimeRemaining, colors, testColors };
|
|
612
785
|
|
|
613
786
|
// Run directly when invoked as a script (not when required by cli.js)
|
|
614
787
|
if (require.main === module) {
|
package/package.json
CHANGED