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 CHANGED
@@ -5,15 +5,23 @@
5
5
  [![license](https://img.shields.io/npm/l/howmuchleft)](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
6
6
  [![node](https://img.shields.io/node/v/howmuchleft)](https://nodejs.org)
7
7
 
8
- Pixel-perfect progress bars showing how much context and usage you have left, right in your Claude Code statusline.
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
- Three bars with sub-cell precision (using Unicode fractional block characters):
10
+ | Dark | Light |
11
+ |---|---|
12
+ | ![Dark mode demo](./assets/demo-dark.gif) | ![Light mode demo](./assets/demo-light.gif) |
11
13
 
12
- - **Context window** -- how full your conversation context is, plus subscription tier and model name
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
- Bars shift from green to yellow to orange to red as usage increases. Stale data is prefixed with `~`. Works with Pro, Max 5x, Max 20x, and Team subscriptions. API key users see an "API" label with context bar only.
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 subscriptions:
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
- ```json
44
+ ```jsonc
37
45
  {
38
46
  "progressLength": 12,
39
- "emptyBgDark": 236,
40
- "emptyBgLight": 252
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-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 |
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
- Check current config:
65
+ ### Color entries
51
66
 
52
- ```bash
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
- ## How it works
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
- 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:
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
- 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
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/Max/Team subscription)
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
- "emptyBgDark": 236, 256-color index for empty bar in dark mode
65
- "emptyBgLight": 252 256-color index for empty bar in light mode
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(` emptyBgDark: ${config.emptyBgDark ?? '(default: 236)'}`);
155
- console.log(` emptyBgLight: ${config.emptyBgLight ?? '(default: 252)'}`);
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(' emptyBgDark: 236');
160
- console.log(' emptyBgLight: 252');
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);
@@ -1,5 +1,37 @@
1
1
  {
2
2
  "progressLength": 12,
3
- "emptyBgDark": 236,
4
- "emptyBgLight": 252
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
- // Cached per-process to avoid redundant fs reads (called once per progressBar, 3x per render).
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 config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
201
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
202
+ const config = parseJsonc(raw);
83
203
  const w = Number(config.progressLength);
84
- const dark = isDarkMode();
85
- const rawBg = Number(dark ? config.emptyBgDark : config.emptyBgLight);
86
- _barConfig = {
87
- width: (Number.isInteger(w) && w >= 3 && w <= 40) ? w : 12,
88
- emptyBg: (Number.isInteger(rawBg) && rawBg >= 0 && rawBg <= 255) ? rawBg : (dark ? 236 : 252),
89
- };
90
- } catch {
91
- _barConfig = { width: 12, emptyBg: isDarkMode() ? 236 : 252 };
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 { resolve(JSON.parse(data)); }
426
- catch { resolve({}); }
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
- // Color buckets: green -> yellow -> orange -> red
432
- function getProgressColor(percent) {
433
- if (percent < 40) return colors.green;
434
- if (percent < 60) return colors.yellow;
435
- if (percent < 80) return colors.orange;
436
- return colors.red;
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
- // Background color variant for filled bar cells
440
- function getProgressBgColor(percent) {
441
- if (percent < 40) return '\x1b[42m';
442
- if (percent < 60) return '\x1b[43m';
443
- if (percent < 80) return '\x1b[48;5;208m';
444
- return '\x1b[41m';
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 = `\x1b[48;5;${config.emptyBg}m`;
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 color = getProgressColor(clamped);
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 += bgColor + ' ';
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 + color + FRACTIONAL_CHARS[idx];
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
- module.exports = { main, CONFIG_PATH };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "howmuchleft",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pixel-perfect progress bars showing how much context and usage you have left, right in your Claude Code statusline",
5
5
  "bin": {
6
6
  "howmuchleft": "bin/cli.js"