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 CHANGED
@@ -3,75 +3,30 @@
3
3
  [![npm version](https://img.shields.io/npm/v/howmuchleft)](https://www.npmjs.com/package/howmuchleft)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/howmuchleft)](https://www.npmjs.com/package/howmuchleft)
5
5
  [![license](https://img.shields.io/npm/l/howmuchleft)](https://github.com/smm-h/howmuchleft/blob/main/LICENSE)
6
- [![node](https://img.shields.io/node/v/howmuchleft)](https://nodejs.org)
7
6
 
8
- Pixel-perfect progress bars showing how much context and usage you have left, right in your Claude Code statusline.
7
+ **Know exactly how much context and usage you have left, right in your Claude Code statusline.**
9
8
 
10
- Three bars with sub-cell precision (using Unicode fractional block characters):
9
+ ![Dark mode demo](./assets/demo-dark.gif)
11
10
 
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
11
+ ![Light mode demo](./assets/demo-light.gif)
15
12
 
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.
13
+ Three progress bars with sub-cell precision that shift from green to red as you approach your limits:
17
14
 
18
- ## Install
19
-
20
- ```bash
21
- npm install -g howmuchleft
22
- howmuchleft --install
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
- ```bash
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
- | Field | Default | Description |
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
- Check current config:
25
+ Two commands and you're done:
51
26
 
52
27
  ```bash
53
- howmuchleft --config
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
- ## Requirements
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
- - Node.js >= 18
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
- "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
@@ -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
- // Cached per-process to avoid redundant fs reads (called once per progressBar, 3x per render).
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 config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
202
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
203
+ const config = parseJsonc(raw);
83
204
  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
- }
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
- return JSON.parse(fs.readFileSync(path.join(claudeDir, '.credentials.json'), 'utf8'));
116
- } catch {
117
- return null;
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 { resolve(JSON.parse(data)); }
426
- catch { resolve({}); }
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
- // 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
- }
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
- // 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';
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 = `\x1b[48;5;${config.emptyBg}m`;
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 color = getProgressColor(clamped);
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 += bgColor + ' ';
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 + color + FRACTIONAL_CHARS[idx];
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
- module.exports = { main, CONFIG_PATH };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "howmuchleft",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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"