claude-limitline 1.0.1 → 1.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.
Files changed (3) hide show
  1. package/README.md +84 -66
  2. package/dist/index.js +449 -119
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,26 +1,36 @@
1
1
  # claude-limitline
2
2
 
3
- A statusline for Claude Code showing real-time usage limits and weekly tracking.
3
+ A powerline-style statusline for Claude Code showing real-time usage limits, git info, and model details.
4
4
 
5
5
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
6
6
  ![Node](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)
7
7
  ![TypeScript](https://img.shields.io/badge/typescript-5.3-blue.svg)
8
8
 
9
+ ![Theme Preview](imgs/themes-preview.png)
10
+
9
11
  ## Features
10
12
 
13
+ - **Powerline Style** - Beautiful segmented display with smooth transitions
11
14
  - **5-Hour Block Limit** - Shows current usage percentage with time remaining until reset
12
15
  - **7-Day Rolling Limit** - Tracks weekly usage with progress indicator
13
- - **Real-time Tracking** - Uses Anthropic's OAuth usage API for accurate usage data
14
- - **Progress Bar Display** - Visual progress bars for quick status checks
16
+ - **Repository Name** - Displays current project/directory name
17
+ - **Git Branch** - Shows current branch with dirty indicator (●)
18
+ - **Claude Model** - Displays the active model (Opus 4.5, Sonnet 4, etc.)
19
+ - **Multiple Themes** - Dark, light, nord, gruvbox, tokyo-night, and rose-pine
20
+ - **Real-time Tracking** - Uses Anthropic's OAuth usage API for accurate data
15
21
  - **Cross-Platform** - Works on Windows, macOS, and Linux
16
- - **Zero Runtime Dependencies** - Lightweight and fast
17
- - **Multiple Themes** - Dark, light, nord, and gruvbox themes included
22
+
23
+ ## Example Output
24
+
25
+ ```
26
+ claude-limitline main ● Opus 4.5 12% (3h20m) 45% (wk 85%)
27
+ ```
18
28
 
19
29
  ## Prerequisites
20
30
 
21
31
  - **Node.js** 18.0.0 or higher
22
32
  - **Claude Code** CLI installed and authenticated (for OAuth token)
23
- - **Nerd Font** (optional, for powerline symbols)
33
+ - **Nerd Font** (recommended, for powerline symbols)
24
34
 
25
35
  ## Installation
26
36
 
@@ -40,16 +50,6 @@ npm run build
40
50
  npm link
41
51
  ```
42
52
 
43
- ### Using Docker
44
-
45
- ```bash
46
- # Build the image
47
- docker build -t claude-limitline .
48
-
49
- # Run (mount your .claude directory for OAuth token access)
50
- docker run --rm -v ~/.claude:/root/.claude claude-limitline
51
- ```
52
-
53
53
  ## Quick Start
54
54
 
55
55
  The easiest way to use claude-limitline is to add it directly to your Claude Code settings.
@@ -83,9 +83,7 @@ Here's a complete example with other common settings:
83
83
  }
84
84
  ```
85
85
 
86
- ### Alternative: Global Install
87
-
88
- If you prefer a global installation (slightly faster startup):
86
+ ### Global Install (faster startup)
89
87
 
90
88
  ```bash
91
89
  npm install -g claude-limitline
@@ -107,33 +105,40 @@ Then update your settings:
107
105
  Run standalone to verify it's working:
108
106
 
109
107
  ```bash
110
- npx claude-limitline
111
- ```
112
-
113
- You should see output like:
114
- ```
115
- ⏳ ████████░░ 45% (2h 30m left) | 📅 ██████░░░░ 62% (wk 43%)
108
+ # Simulate Claude Code hook data
109
+ echo '{"model":{"id":"claude-opus-4-5-20251101"}}' | npx claude-limitline
116
110
  ```
117
111
 
118
112
  ## Configuration
119
113
 
120
- Create a `.claude-limitline.json` file in your home directory (`~/.claude-limitline.json`) or current working directory:
114
+ Create a `claude-limitline.json` file in your Claude config directory (`~/.claude/claude-limitline.json`) or `.claude-limitline.json` in your current working directory:
121
115
 
122
116
  ```json
123
117
  {
124
118
  "display": {
125
- "style": "minimal",
126
- "useNerdFonts": true
119
+ "style": "powerline",
120
+ "useNerdFonts": true,
121
+ "compactMode": "auto",
122
+ "compactWidth": 80
123
+ },
124
+ "directory": {
125
+ "enabled": true
126
+ },
127
+ "git": {
128
+ "enabled": true
129
+ },
130
+ "model": {
131
+ "enabled": true
127
132
  },
128
133
  "block": {
129
134
  "enabled": true,
130
- "displayStyle": "bar",
135
+ "displayStyle": "text",
131
136
  "barWidth": 10,
132
137
  "showTimeRemaining": true
133
138
  },
134
139
  "weekly": {
135
140
  "enabled": true,
136
- "displayStyle": "bar",
141
+ "displayStyle": "text",
137
142
  "barWidth": 10,
138
143
  "showWeekProgress": true
139
144
  },
@@ -141,7 +146,9 @@ Create a `.claude-limitline.json` file in your home directory (`~/.claude-limitl
141
146
  "pollInterval": 15,
142
147
  "warningThreshold": 80
143
148
  },
144
- "theme": "dark"
149
+ "theme": "dark",
150
+ "segmentOrder": ["directory", "git", "model", "block", "weekly"],
151
+ "showTrend": true
145
152
  }
146
153
  ```
147
154
 
@@ -149,29 +156,55 @@ Create a `.claude-limitline.json` file in your home directory (`~/.claude-limitl
149
156
 
150
157
  | Option | Description | Default |
151
158
  |--------|-------------|---------|
152
- | `display.useNerdFonts` | Use Nerd Font symbols (⏳📅) vs ASCII | `true` |
159
+ | `display.useNerdFonts` | Use Nerd Font symbols for powerline | `true` |
160
+ | `display.compactMode` | `"auto"`, `"always"`, or `"never"` | `"auto"` |
161
+ | `display.compactWidth` | Terminal width threshold for compact mode | `80` |
162
+ | `directory.enabled` | Show repository/directory name | `true` |
163
+ | `git.enabled` | Show git branch with dirty indicator | `true` |
164
+ | `model.enabled` | Show Claude model name | `true` |
153
165
  | `block.enabled` | Show 5-hour block usage | `true` |
154
- | `block.displayStyle` | `"bar"` or `"text"` | `"bar"` |
166
+ | `block.displayStyle` | `"bar"` or `"text"` | `"text"` |
155
167
  | `block.barWidth` | Width of progress bar in characters | `10` |
156
168
  | `block.showTimeRemaining` | Show time until block resets | `true` |
157
169
  | `weekly.enabled` | Show 7-day rolling usage | `true` |
158
- | `weekly.displayStyle` | `"bar"` or `"text"` | `"bar"` |
170
+ | `weekly.displayStyle` | `"bar"` or `"text"` | `"text"` |
159
171
  | `weekly.barWidth` | Width of progress bar in characters | `10` |
160
172
  | `weekly.showWeekProgress` | Show week progress percentage | `true` |
161
173
  | `budget.pollInterval` | Minutes between API calls | `15` |
162
174
  | `budget.warningThreshold` | Percentage to trigger warning color | `80` |
163
175
  | `theme` | Color theme name | `"dark"` |
176
+ | `segmentOrder` | Array to customize segment order | `["directory", "git", "model", "block", "weekly"]` |
177
+ | `showTrend` | Show ↑↓ arrows for usage changes | `true` |
164
178
 
165
179
  ### Available Themes
166
180
 
167
- - `dark` - Default dark theme
168
- - `light` - Light background theme
181
+ - `dark` - Default dark theme with warm browns and cool cyans
182
+ - `light` - Light background theme with vibrant colors
169
183
  - `nord` - Nord color palette
170
184
  - `gruvbox` - Gruvbox color palette
185
+ - `tokyo-night` - Tokyo Night color palette
186
+ - `rose-pine` - Rosé Pine color palette
187
+
188
+ ## Segments
189
+
190
+ The statusline displays the following segments (all configurable):
191
+
192
+ | Segment | Description | Color (dark theme) |
193
+ |---------|-------------|-------------------|
194
+ | **Directory** | Current repo/project name | Brown/Orange |
195
+ | **Git** | Branch name + dirty indicator (●) | Dark Gray |
196
+ | **Model** | Claude model (Opus 4.5, Sonnet 4, etc.) | Dark Gray |
197
+ | **Block** | 5-hour usage % + time remaining | Cyan (warning: Orange, critical: Red) |
198
+ | **Weekly** | 7-day usage % + week progress | Green |
171
199
 
172
200
  ## How It Works
173
201
 
174
- claude-limitline retrieves your Claude usage data from Anthropic's OAuth usage API. It reads your OAuth token from:
202
+ claude-limitline retrieves data from two sources:
203
+
204
+ 1. **Hook Data (stdin)** - Claude Code passes JSON with model info, workspace, and session data
205
+ 2. **Usage API** - Fetches usage limits from Anthropic's OAuth usage endpoint
206
+
207
+ ### OAuth Token Location
175
208
 
176
209
  | Platform | Location |
177
210
  |----------|----------|
@@ -179,15 +212,6 @@ claude-limitline retrieves your Claude usage data from Anthropic's OAuth usage A
179
212
  | **macOS** | Keychain or `~/.claude/.credentials.json` |
180
213
  | **Linux** | secret-tool (GNOME Keyring) or `~/.claude/.credentials.json` |
181
214
 
182
- The usage data is cached locally to respect API rate limits. The cache duration is configurable via `budget.pollInterval` (default: 15 minutes).
183
-
184
- ### API Response
185
-
186
- The tool queries Anthropic's usage endpoint which returns:
187
-
188
- - **5-hour block**: Usage percentage and reset time for the rolling 5-hour window
189
- - **7-day rolling**: Usage percentage and reset time for the rolling 7-day window
190
-
191
215
  ## Development
192
216
 
193
217
  ### Setup
@@ -210,16 +234,13 @@ npm run build
210
234
  npm run dev
211
235
  ```
212
236
 
213
- ### Type Checking
214
-
215
- ```bash
216
- npm run typecheck
217
- ```
218
-
219
237
  ### Run Locally
220
238
 
221
239
  ```bash
222
240
  node dist/index.js
241
+
242
+ # With simulated hook data
243
+ echo '{"model":{"id":"claude-opus-4-5-20251101"}}' | node dist/index.js
223
244
  ```
224
245
 
225
246
  ## Debug Mode
@@ -241,25 +262,22 @@ Debug output is written to stderr so it won't interfere with the status line out
241
262
 
242
263
  ## Troubleshooting
243
264
 
244
- ### "No data" or empty output
245
-
246
- 1. **Check OAuth token**: Make sure you're logged into Claude Code (`claude --login`)
247
- 2. **Check credentials file**: Verify `~/.claude/.credentials.json` exists and contains `claudeAiOauth.accessToken`
248
- 3. **Enable debug mode**: Run with `CLAUDE_LIMITLINE_DEBUG=true` to see detailed logs
249
-
250
- ### Token not found
251
-
252
- The OAuth token is stored by Claude Code when you authenticate. Try:
265
+ ### Model not showing
253
266
 
267
+ The model is passed via stdin from Claude Code. If running standalone, pipe in hook data:
254
268
  ```bash
255
- # Re-authenticate with Claude Code
256
- claude --login
269
+ echo '{"model":{"id":"claude-opus-4-5-20251101"}}' | claude-limitline
257
270
  ```
258
271
 
259
- ### API returns errors
272
+ ### "No data" or empty output
273
+
274
+ 1. **Check OAuth token**: Make sure you're logged into Claude Code (`claude --login`)
275
+ 2. **Check credentials file**: Verify `~/.claude/.credentials.json` exists
276
+ 3. **Enable debug mode**: Run with `CLAUDE_LIMITLINE_DEBUG=true`
277
+
278
+ ### Git branch not showing
260
279
 
261
- - Ensure your Claude subscription is active
262
- - Check if you've exceeded API rate limits (try increasing `pollInterval`)
280
+ Make sure you're in a git repository. The git segment only appears when a `.git` directory is found.
263
281
 
264
282
  ## Contributing
265
283
 
package/dist/index.js CHANGED
@@ -17,18 +17,29 @@ function debug(...args) {
17
17
  // src/config/types.ts
18
18
  var DEFAULT_CONFIG = {
19
19
  display: {
20
- style: "minimal",
21
- useNerdFonts: true
20
+ style: "powerline",
21
+ useNerdFonts: true,
22
+ compactMode: "auto",
23
+ compactWidth: 80
24
+ },
25
+ directory: {
26
+ enabled: true
27
+ },
28
+ git: {
29
+ enabled: true
30
+ },
31
+ model: {
32
+ enabled: true
22
33
  },
23
34
  block: {
24
35
  enabled: true,
25
- displayStyle: "bar",
36
+ displayStyle: "text",
26
37
  barWidth: 10,
27
38
  showTimeRemaining: true
28
39
  },
29
40
  weekly: {
30
41
  enabled: true,
31
- displayStyle: "bar",
42
+ displayStyle: "text",
32
43
  barWidth: 10,
33
44
  showWeekProgress: true
34
45
  },
@@ -36,7 +47,9 @@ var DEFAULT_CONFIG = {
36
47
  pollInterval: 15,
37
48
  warningThreshold: 80
38
49
  },
39
- theme: "dark"
50
+ theme: "dark",
51
+ segmentOrder: ["directory", "git", "model", "block", "weekly"],
52
+ showTrend: true
40
53
  };
41
54
 
42
55
  // src/config/loader.ts
@@ -56,8 +69,7 @@ function deepMerge(target, source) {
56
69
  function loadConfig() {
57
70
  const configPaths = [
58
71
  path.join(process.cwd(), ".claude-limitline.json"),
59
- path.join(os.homedir(), ".claude-limitline.json"),
60
- path.join(os.homedir(), ".config", "claude-limitline", "config.json")
72
+ path.join(os.homedir(), ".claude", "claude-limitline.json")
61
73
  ];
62
74
  for (const configPath of configPaths) {
63
75
  try {
@@ -255,8 +267,31 @@ async function fetchUsageFromAPI(token) {
255
267
  }
256
268
  }
257
269
  var cachedUsage = null;
270
+ var previousUsage = null;
258
271
  var cacheTimestamp = 0;
259
272
  var cachedToken = null;
273
+ function getUsageTrend() {
274
+ const result = {
275
+ fiveHourTrend: null,
276
+ sevenDayTrend: null
277
+ };
278
+ if (!cachedUsage || !previousUsage) {
279
+ return result;
280
+ }
281
+ if (cachedUsage.fiveHour && previousUsage.fiveHour) {
282
+ const diff = cachedUsage.fiveHour.percentUsed - previousUsage.fiveHour.percentUsed;
283
+ if (diff > 0.5) result.fiveHourTrend = "up";
284
+ else if (diff < -0.5) result.fiveHourTrend = "down";
285
+ else result.fiveHourTrend = "same";
286
+ }
287
+ if (cachedUsage.sevenDay && previousUsage.sevenDay) {
288
+ const diff = cachedUsage.sevenDay.percentUsed - previousUsage.sevenDay.percentUsed;
289
+ if (diff > 0.5) result.sevenDayTrend = "up";
290
+ else if (diff < -0.5) result.sevenDayTrend = "down";
291
+ else result.sevenDayTrend = "same";
292
+ }
293
+ return result;
294
+ }
260
295
  async function getRealtimeUsage(pollIntervalMinutes = 15) {
261
296
  const now = Date.now();
262
297
  const cacheAgeMs = now - cacheTimestamp;
@@ -274,6 +309,7 @@ async function getRealtimeUsage(pollIntervalMinutes = 15) {
274
309
  }
275
310
  const usage = await fetchUsageFromAPI(cachedToken);
276
311
  if (usage) {
312
+ previousUsage = cachedUsage;
277
313
  cachedUsage = usage;
278
314
  cacheTimestamp = now;
279
315
  debug("Refreshed realtime usage cache");
@@ -414,10 +450,12 @@ var SYMBOLS = {
414
450
  left: "\uE0B2",
415
451
  branch: "\uE0A0",
416
452
  separator: "\uE0B1",
417
- block_cost: "\uF252",
418
- // Hourglass
419
- weekly_cost: "\uF073",
420
- // Calendar
453
+ model: "\u2731",
454
+ // Heavy asterisk ✱
455
+ block_cost: "\u25EB",
456
+ // White square with vertical bisecting line ◫
457
+ weekly_cost: "\u25CB",
458
+ // Circle ○
421
459
  progress_full: "\u2588",
422
460
  // Full block
423
461
  progress_empty: "\u2591"
@@ -428,6 +466,7 @@ var TEXT_SYMBOLS = {
428
466
  left: "<",
429
467
  branch: "",
430
468
  separator: "|",
469
+ model: "*",
431
470
  block_cost: "BLK",
432
471
  weekly_cost: "WK",
433
472
  progress_full: "#",
@@ -436,178 +475,467 @@ var TEXT_SYMBOLS = {
436
475
  var RESET_CODE = "\x1B[0m";
437
476
 
438
477
  // src/themes/index.ts
439
- var color = (n) => `\x1B[38;5;${n}m`;
440
- var bgColor = (n) => `\x1B[48;5;${n}m`;
441
- var themes = {
442
- dark: {
443
- blockBg: bgColor(236),
444
- blockFg: color(252),
445
- weeklyBg: bgColor(236),
446
- weeklyFg: color(252),
447
- warningBg: bgColor(172),
448
- warningFg: color(232),
449
- criticalBg: bgColor(160),
450
- criticalFg: color(255),
451
- progressFull: color(76),
452
- progressEmpty: color(240),
453
- separatorFg: color(244)
454
- },
455
- light: {
456
- blockBg: bgColor(254),
457
- blockFg: color(236),
458
- weeklyBg: bgColor(254),
459
- weeklyFg: color(236),
460
- warningBg: bgColor(214),
461
- warningFg: color(232),
462
- criticalBg: bgColor(196),
463
- criticalFg: color(255),
464
- progressFull: color(34),
465
- progressEmpty: color(250),
466
- separatorFg: color(244)
467
- },
468
- nord: {
469
- blockBg: bgColor(236),
470
- blockFg: color(110),
471
- weeklyBg: bgColor(236),
472
- weeklyFg: color(110),
473
- warningBg: bgColor(179),
474
- warningFg: color(232),
475
- criticalBg: bgColor(131),
476
- criticalFg: color(255),
477
- progressFull: color(108),
478
- progressEmpty: color(239),
479
- separatorFg: color(60)
480
- },
481
- gruvbox: {
482
- blockBg: bgColor(237),
483
- blockFg: color(223),
484
- weeklyBg: bgColor(237),
485
- weeklyFg: color(223),
486
- warningBg: bgColor(214),
487
- warningFg: color(235),
488
- criticalBg: bgColor(167),
489
- criticalFg: color(235),
490
- progressFull: color(142),
491
- progressEmpty: color(239),
492
- separatorFg: color(246)
478
+ function hexToAnsi256(hex) {
479
+ const r = parseInt(hex.slice(1, 3), 16);
480
+ const g = parseInt(hex.slice(3, 5), 16);
481
+ const b = parseInt(hex.slice(5, 7), 16);
482
+ if (r === g && g === b) {
483
+ if (r < 8) return 16;
484
+ if (r > 248) return 231;
485
+ return Math.round((r - 8) / 247 * 24) + 232;
493
486
  }
487
+ const ri = Math.round(r / 255 * 5);
488
+ const gi = Math.round(g / 255 * 5);
489
+ const bi = Math.round(b / 255 * 5);
490
+ return 16 + 36 * ri + 6 * gi + bi;
491
+ }
492
+ var ansi = {
493
+ fg: (hex) => `\x1B[38;5;${hexToAnsi256(hex)}m`,
494
+ bg: (hex) => `\x1B[48;5;${hexToAnsi256(hex)}m`,
495
+ fgRaw: (n) => `\x1B[38;5;${n}m`,
496
+ bgRaw: (n) => `\x1B[48;5;${n}m`,
497
+ reset: "\x1B[0m"
498
+ };
499
+ var darkTheme = {
500
+ directory: { bg: "#8b4513", fg: "#ffffff" },
501
+ git: { bg: "#404040", fg: "#ffffff" },
502
+ model: { bg: "#2d2d2d", fg: "#ffffff" },
503
+ block: { bg: "#2a2a2a", fg: "#87ceeb" },
504
+ weekly: { bg: "#1a1a1a", fg: "#98fb98" },
505
+ warning: { bg: "#d75f00", fg: "#ffffff" },
506
+ critical: { bg: "#af0000", fg: "#ffffff" }
507
+ };
508
+ var lightTheme = {
509
+ directory: { bg: "#ff6b47", fg: "#ffffff" },
510
+ git: { bg: "#4fb3d9", fg: "#ffffff" },
511
+ model: { bg: "#87ceeb", fg: "#000000" },
512
+ block: { bg: "#6366f1", fg: "#ffffff" },
513
+ weekly: { bg: "#10b981", fg: "#ffffff" },
514
+ warning: { bg: "#f59e0b", fg: "#000000" },
515
+ critical: { bg: "#ef4444", fg: "#ffffff" }
516
+ };
517
+ var nordTheme = {
518
+ directory: { bg: "#434c5e", fg: "#d8dee9" },
519
+ git: { bg: "#3b4252", fg: "#a3be8c" },
520
+ model: { bg: "#4c566a", fg: "#81a1c1" },
521
+ block: { bg: "#3b4252", fg: "#81a1c1" },
522
+ weekly: { bg: "#2e3440", fg: "#8fbcbb" },
523
+ warning: { bg: "#d08770", fg: "#2e3440" },
524
+ critical: { bg: "#bf616a", fg: "#eceff4" }
525
+ };
526
+ var gruvboxTheme = {
527
+ directory: { bg: "#504945", fg: "#ebdbb2" },
528
+ git: { bg: "#3c3836", fg: "#b8bb26" },
529
+ model: { bg: "#665c54", fg: "#83a598" },
530
+ block: { bg: "#3c3836", fg: "#83a598" },
531
+ weekly: { bg: "#282828", fg: "#fabd2f" },
532
+ warning: { bg: "#d79921", fg: "#282828" },
533
+ critical: { bg: "#cc241d", fg: "#ebdbb2" }
534
+ };
535
+ var tokyoNightTheme = {
536
+ directory: { bg: "#2f334d", fg: "#82aaff" },
537
+ git: { bg: "#1e2030", fg: "#c3e88d" },
538
+ model: { bg: "#191b29", fg: "#fca7ea" },
539
+ block: { bg: "#2d3748", fg: "#7aa2f7" },
540
+ weekly: { bg: "#1a202c", fg: "#4fd6be" },
541
+ warning: { bg: "#e0af68", fg: "#1a1b26" },
542
+ critical: { bg: "#f7768e", fg: "#1a1b26" }
543
+ };
544
+ var rosePineTheme = {
545
+ directory: { bg: "#26233a", fg: "#c4a7e7" },
546
+ git: { bg: "#1f1d2e", fg: "#9ccfd8" },
547
+ model: { bg: "#191724", fg: "#ebbcba" },
548
+ block: { bg: "#2a273f", fg: "#eb6f92" },
549
+ weekly: { bg: "#232136", fg: "#9ccfd8" },
550
+ warning: { bg: "#f6c177", fg: "#191724" },
551
+ critical: { bg: "#eb6f92", fg: "#191724" }
552
+ };
553
+ var themes = {
554
+ dark: darkTheme,
555
+ light: lightTheme,
556
+ nord: nordTheme,
557
+ gruvbox: gruvboxTheme,
558
+ "tokyo-night": tokyoNightTheme,
559
+ "rose-pine": rosePineTheme
494
560
  };
495
561
  function getTheme(name) {
496
562
  return themes[name] || themes.dark;
497
563
  }
498
564
 
565
+ // src/utils/terminal.ts
566
+ function getTerminalWidth() {
567
+ return process.stdout.columns || 80;
568
+ }
569
+
499
570
  // src/renderer.ts
500
571
  var Renderer = class {
501
572
  config;
502
573
  theme;
503
574
  symbols;
575
+ usePowerline;
504
576
  constructor(config) {
505
577
  this.config = config;
506
578
  this.theme = getTheme(config.theme || "dark");
507
579
  const useNerd = config.display?.useNerdFonts ?? true;
508
580
  const symbolSet = useNerd ? SYMBOLS : TEXT_SYMBOLS;
581
+ this.usePowerline = useNerd;
509
582
  this.symbols = {
510
583
  block: symbolSet.block_cost,
511
584
  weekly: symbolSet.weekly_cost,
585
+ rightArrow: symbolSet.right,
512
586
  separator: symbolSet.separator,
587
+ branch: symbolSet.branch,
588
+ model: symbolSet.model,
513
589
  progressFull: symbolSet.progress_full,
514
- progressEmpty: symbolSet.progress_empty
590
+ progressEmpty: symbolSet.progress_empty,
591
+ trendUp: "\u2191",
592
+ trendDown: "\u2193"
515
593
  };
516
594
  }
517
- formatProgressBar(percent, width, colors) {
595
+ isCompactMode() {
596
+ const mode = this.config.display?.compactMode ?? "auto";
597
+ if (mode === "always") return true;
598
+ if (mode === "never") return false;
599
+ const threshold = this.config.display?.compactWidth ?? 80;
600
+ const termWidth = getTerminalWidth();
601
+ return termWidth < threshold;
602
+ }
603
+ formatProgressBar(percent, width) {
518
604
  const filled = Math.round(percent / 100 * width);
519
605
  const empty = width - filled;
520
- const filledBar = colors.progressFull + this.symbols.progressFull.repeat(filled);
521
- const emptyBar = colors.progressEmpty + this.symbols.progressEmpty.repeat(empty);
522
- return filledBar + emptyBar + RESET_CODE;
606
+ return this.symbols.progressFull.repeat(filled) + this.symbols.progressEmpty.repeat(empty);
523
607
  }
524
- formatTimeRemaining(minutes) {
608
+ formatTimeRemaining(minutes, compact) {
525
609
  if (minutes >= 60) {
526
610
  const hours = Math.floor(minutes / 60);
527
611
  const mins = minutes % 60;
528
- return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
612
+ if (compact) {
613
+ return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
614
+ }
615
+ return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
529
616
  }
530
617
  return `${minutes}m`;
531
618
  }
532
- getColorForPercent(percent, colors) {
619
+ getTrendSymbol(trend) {
620
+ if (!this.config.showTrend) return "";
621
+ if (trend === "up") return this.symbols.trendUp;
622
+ if (trend === "down") return this.symbols.trendDown;
623
+ return "";
624
+ }
625
+ getColorsForPercent(percent, baseColors) {
533
626
  const threshold = this.config.budget?.warningThreshold ?? 80;
534
627
  if (percent >= 100) {
535
- return { bg: colors.criticalBg, fg: colors.criticalFg };
628
+ return this.theme.critical;
536
629
  } else if (percent >= threshold) {
537
- return { bg: colors.warningBg, fg: colors.warningFg };
630
+ return this.theme.warning;
538
631
  }
539
- return { bg: colors.blockBg, fg: colors.blockFg };
632
+ return baseColors;
540
633
  }
541
- renderBlock(blockInfo) {
542
- if (!blockInfo || !this.config.block?.enabled) {
543
- return "";
634
+ renderPowerline(segments) {
635
+ if (segments.length === 0) return "";
636
+ let output = "";
637
+ for (let i = 0; i < segments.length; i++) {
638
+ const seg = segments[i];
639
+ const nextColors = i < segments.length - 1 ? segments[i + 1].colors : null;
640
+ output += ansi.bg(seg.colors.bg) + ansi.fg(seg.colors.fg) + seg.text;
641
+ output += RESET_CODE;
642
+ if (nextColors) {
643
+ output += ansi.fg(seg.colors.bg) + ansi.bg(nextColors.bg) + this.symbols.rightArrow;
644
+ } else {
645
+ output += ansi.fg(seg.colors.bg) + this.symbols.rightArrow;
646
+ }
647
+ }
648
+ output += RESET_CODE;
649
+ return output;
650
+ }
651
+ renderFallback(segments) {
652
+ return segments.map((seg) => ansi.bg(seg.colors.bg) + ansi.fg(seg.colors.fg) + seg.text + RESET_CODE).join(` ${this.symbols.separator} `);
653
+ }
654
+ renderDirectory(ctx) {
655
+ if (!this.config.directory?.enabled || !ctx.envInfo.directory) {
656
+ return null;
657
+ }
658
+ const name = ctx.compact && ctx.envInfo.directory.length > 12 ? ctx.envInfo.directory.slice(0, 10) + "\u2026" : ctx.envInfo.directory;
659
+ return {
660
+ text: ` ${name} `,
661
+ colors: this.theme.directory
662
+ };
663
+ }
664
+ renderGit(ctx) {
665
+ if (!this.config.git?.enabled || !ctx.envInfo.gitBranch) {
666
+ return null;
667
+ }
668
+ const dirtyIndicator = ctx.envInfo.gitDirty ? " \u25CF" : "";
669
+ const icon = this.usePowerline ? this.symbols.branch : "";
670
+ const prefix = icon ? `${icon} ` : "";
671
+ let branch = ctx.envInfo.gitBranch;
672
+ if (ctx.compact && branch.length > 10) {
673
+ branch = branch.slice(0, 8) + "\u2026";
674
+ }
675
+ return {
676
+ text: ` ${prefix}${branch}${dirtyIndicator} `,
677
+ colors: this.theme.git
678
+ };
679
+ }
680
+ renderModel(ctx) {
681
+ if (!this.config.model?.enabled || !ctx.envInfo.model) {
682
+ return null;
544
683
  }
545
- if (blockInfo.percentUsed === null) {
546
- return `${this.symbols.block} --`;
684
+ const icon = this.usePowerline ? this.symbols.model : "";
685
+ const prefix = icon ? `${icon} ` : "";
686
+ return {
687
+ text: ` ${prefix}${ctx.envInfo.model} `,
688
+ colors: this.theme.model
689
+ };
690
+ }
691
+ renderBlock(ctx) {
692
+ if (!ctx.blockInfo || !this.config.block?.enabled) {
693
+ return null;
694
+ }
695
+ const icon = this.usePowerline ? this.symbols.block : "BLK";
696
+ if (ctx.blockInfo.percentUsed === null) {
697
+ return {
698
+ text: ` ${icon} -- `,
699
+ colors: this.theme.block
700
+ };
547
701
  }
548
- const percent = blockInfo.percentUsed;
549
- const displayStyle = this.config.block.displayStyle || "bar";
702
+ const percent = ctx.blockInfo.percentUsed;
703
+ const colors = this.getColorsForPercent(percent, this.theme.block);
704
+ const displayStyle = this.config.block.displayStyle || "text";
550
705
  const barWidth = this.config.block.barWidth || 10;
551
706
  const showTime = this.config.block.showTimeRemaining ?? true;
552
- let display;
553
- if (displayStyle === "bar") {
554
- const bar = this.formatProgressBar(percent, barWidth, this.theme);
555
- display = `${bar} ${Math.round(percent)}%`;
707
+ const trend = this.getTrendSymbol(ctx.trendInfo?.fiveHourTrend ?? null);
708
+ let text;
709
+ if (displayStyle === "bar" && !ctx.compact) {
710
+ const bar = this.formatProgressBar(percent, barWidth);
711
+ text = `${bar} ${Math.round(percent)}%${trend}`;
556
712
  } else {
557
- display = `${Math.round(percent)}%`;
713
+ text = `${Math.round(percent)}%${trend}`;
558
714
  }
559
- if (showTime && blockInfo.timeRemaining !== null) {
560
- const timeStr = this.formatTimeRemaining(blockInfo.timeRemaining);
561
- display += ` (${timeStr} left)`;
715
+ if (showTime && ctx.blockInfo.timeRemaining !== null && !ctx.compact) {
716
+ const timeStr = this.formatTimeRemaining(ctx.blockInfo.timeRemaining, ctx.compact);
717
+ text += ` (${timeStr})`;
562
718
  }
563
- return `${this.symbols.block} ${display}`;
719
+ return {
720
+ text: ` ${icon} ${text} `,
721
+ colors
722
+ };
564
723
  }
565
- renderWeekly(weeklyInfo) {
566
- if (!weeklyInfo || !this.config.weekly?.enabled) {
567
- return "";
724
+ renderWeekly(ctx) {
725
+ if (!ctx.weeklyInfo || !this.config.weekly?.enabled) {
726
+ return null;
568
727
  }
569
- if (weeklyInfo.percentUsed === null) {
570
- return `${this.symbols.weekly} --`;
728
+ const icon = this.usePowerline ? this.symbols.weekly : "WK";
729
+ if (ctx.weeklyInfo.percentUsed === null) {
730
+ return {
731
+ text: ` ${icon} -- `,
732
+ colors: this.theme.weekly
733
+ };
571
734
  }
572
- const percent = weeklyInfo.percentUsed;
573
- const displayStyle = this.config.weekly.displayStyle || "bar";
735
+ const percent = ctx.weeklyInfo.percentUsed;
736
+ const displayStyle = this.config.weekly.displayStyle || "text";
574
737
  const barWidth = this.config.weekly.barWidth || 10;
575
738
  const showWeekProgress = this.config.weekly.showWeekProgress ?? true;
576
- let display;
577
- if (displayStyle === "bar") {
578
- const bar = this.formatProgressBar(percent, barWidth, this.theme);
579
- display = `${bar} ${Math.round(percent)}%`;
739
+ const trend = this.getTrendSymbol(ctx.trendInfo?.sevenDayTrend ?? null);
740
+ let text;
741
+ if (displayStyle === "bar" && !ctx.compact) {
742
+ const bar = this.formatProgressBar(percent, barWidth);
743
+ text = `${bar} ${Math.round(percent)}%${trend}`;
580
744
  } else {
581
- display = `${Math.round(percent)}%`;
745
+ text = `${Math.round(percent)}%${trend}`;
582
746
  }
583
- if (showWeekProgress) {
584
- display += ` (wk ${weeklyInfo.weekProgressPercent}%)`;
747
+ if (showWeekProgress && !ctx.compact) {
748
+ text += ` (wk ${ctx.weeklyInfo.weekProgressPercent}%)`;
585
749
  }
586
- return `${this.symbols.weekly} ${display}`;
750
+ return {
751
+ text: ` ${icon} ${text} `,
752
+ colors: this.theme.weekly
753
+ };
587
754
  }
588
- render(blockInfo, weeklyInfo) {
589
- const parts = [];
590
- const blockSegment = this.renderBlock(blockInfo);
591
- if (blockSegment) {
592
- parts.push(blockSegment);
755
+ getSegment(name, ctx) {
756
+ switch (name) {
757
+ case "directory":
758
+ return this.renderDirectory(ctx);
759
+ case "git":
760
+ return this.renderGit(ctx);
761
+ case "model":
762
+ return this.renderModel(ctx);
763
+ case "block":
764
+ return this.renderBlock(ctx);
765
+ case "weekly":
766
+ return this.renderWeekly(ctx);
767
+ default:
768
+ return null;
593
769
  }
594
- const weeklySegment = this.renderWeekly(weeklyInfo);
595
- if (weeklySegment) {
596
- parts.push(weeklySegment);
770
+ }
771
+ render(blockInfo, weeklyInfo, envInfo, trendInfo = null) {
772
+ const compact = this.isCompactMode();
773
+ const ctx = {
774
+ blockInfo,
775
+ weeklyInfo,
776
+ envInfo,
777
+ trendInfo,
778
+ compact
779
+ };
780
+ const segments = [];
781
+ const order = this.config.segmentOrder ?? ["directory", "git", "model", "block", "weekly"];
782
+ for (const name of order) {
783
+ const segment = this.getSegment(name, ctx);
784
+ if (segment) {
785
+ segments.push(segment);
786
+ }
597
787
  }
598
- if (parts.length === 0) {
788
+ if (segments.length === 0) {
599
789
  return "";
600
790
  }
601
- const separator = ` ${this.theme.separatorFg}${this.symbols.separator}${RESET_CODE} `;
602
- return parts.join(separator);
791
+ if (this.usePowerline) {
792
+ return this.renderPowerline(segments);
793
+ } else {
794
+ return this.renderFallback(segments);
795
+ }
603
796
  }
604
797
  };
605
798
 
799
+ // src/utils/environment.ts
800
+ import { execSync } from "child_process";
801
+ import { basename } from "path";
802
+
803
+ // src/utils/claude-hook.ts
804
+ async function readHookData() {
805
+ if (process.stdin.isTTY) {
806
+ debug("stdin is TTY, no hook data");
807
+ return null;
808
+ }
809
+ try {
810
+ const chunks = [];
811
+ const result = await Promise.race([
812
+ new Promise((resolve, reject) => {
813
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
814
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
815
+ process.stdin.on("error", reject);
816
+ }),
817
+ new Promise((resolve) => setTimeout(() => resolve(null), 100))
818
+ ]);
819
+ if (!result || result.trim() === "") {
820
+ debug("No stdin data received");
821
+ return null;
822
+ }
823
+ const hookData = JSON.parse(result);
824
+ debug("Hook data received:", JSON.stringify(hookData));
825
+ return hookData;
826
+ } catch (error) {
827
+ debug("Error reading hook data:", error);
828
+ return null;
829
+ }
830
+ }
831
+ function formatModelName(modelId, displayName) {
832
+ if (displayName && displayName.length <= 20) {
833
+ const clean = displayName.replace(/^Claude\s*/i, "").trim();
834
+ if (clean) return clean;
835
+ }
836
+ const mappings = {
837
+ "claude-opus-4-5-20251101": "Opus 4.5",
838
+ "claude-opus-4-20250514": "Opus 4",
839
+ "claude-sonnet-4-20250514": "Sonnet 4",
840
+ "claude-3-5-sonnet-20241022": "Sonnet 3.5",
841
+ "claude-3-5-sonnet-latest": "Sonnet 3.5",
842
+ "claude-3-5-sonnet": "Sonnet 3.5",
843
+ "claude-3-opus-20240229": "Opus 3",
844
+ "claude-3-opus": "Opus 3",
845
+ "claude-3-sonnet-20240229": "Sonnet 3",
846
+ "claude-3-haiku-20240307": "Haiku 3",
847
+ "claude-3-haiku": "Haiku 3"
848
+ };
849
+ if (mappings[modelId]) {
850
+ return mappings[modelId];
851
+ }
852
+ const lower = modelId.toLowerCase();
853
+ if (lower.includes("opus")) {
854
+ if (lower.includes("4-5") || lower.includes("4.5")) return "Opus 4.5";
855
+ if (lower.includes("4")) return "Opus 4";
856
+ if (lower.includes("3")) return "Opus 3";
857
+ return "Opus";
858
+ }
859
+ if (lower.includes("sonnet")) {
860
+ if (lower.includes("4")) return "Sonnet 4";
861
+ if (lower.includes("3-5") || lower.includes("3.5")) return "Sonnet 3.5";
862
+ if (lower.includes("3")) return "Sonnet 3";
863
+ return "Sonnet";
864
+ }
865
+ if (lower.includes("haiku")) {
866
+ if (lower.includes("3")) return "Haiku 3";
867
+ return "Haiku";
868
+ }
869
+ return modelId.length > 15 ? modelId.slice(0, 15) : modelId;
870
+ }
871
+
872
+ // src/utils/environment.ts
873
+ function getDirectoryName(hookData) {
874
+ try {
875
+ if (hookData?.workspace?.project_dir) {
876
+ return basename(hookData.workspace.project_dir);
877
+ }
878
+ if (hookData?.cwd) {
879
+ return basename(hookData.cwd);
880
+ }
881
+ return basename(process.cwd());
882
+ } catch (error) {
883
+ debug("Error getting directory name:", error);
884
+ return null;
885
+ }
886
+ }
887
+ function getGitBranch() {
888
+ try {
889
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
890
+ encoding: "utf-8",
891
+ stdio: ["pipe", "pipe", "pipe"]
892
+ }).trim();
893
+ return branch || null;
894
+ } catch (error) {
895
+ debug("Error getting git branch:", error);
896
+ return null;
897
+ }
898
+ }
899
+ function hasGitChanges() {
900
+ try {
901
+ const status = execSync("git status --porcelain", {
902
+ encoding: "utf-8",
903
+ stdio: ["pipe", "pipe", "pipe"]
904
+ }).trim();
905
+ return status.length > 0;
906
+ } catch (error) {
907
+ debug("Error checking git status:", error);
908
+ return false;
909
+ }
910
+ }
911
+ function getClaudeModel(hookData) {
912
+ if (hookData?.model?.id) {
913
+ return formatModelName(hookData.model.id, hookData.model.display_name);
914
+ }
915
+ const model = process.env.CLAUDE_MODEL || process.env.CLAUDE_CODE_MODEL || process.env.ANTHROPIC_MODEL;
916
+ if (model) {
917
+ return formatModelName(model);
918
+ }
919
+ return null;
920
+ }
921
+ function getEnvironmentInfo(hookData) {
922
+ return {
923
+ directory: getDirectoryName(hookData),
924
+ gitBranch: getGitBranch(),
925
+ gitDirty: hasGitChanges(),
926
+ model: getClaudeModel(hookData)
927
+ };
928
+ }
929
+
606
930
  // src/index.ts
607
931
  async function main() {
608
932
  try {
609
933
  const config = loadConfig();
610
934
  debug("Config loaded:", JSON.stringify(config));
935
+ const hookData = await readHookData();
936
+ debug("Hook data:", JSON.stringify(hookData));
937
+ const envInfo = getEnvironmentInfo(hookData);
938
+ debug("Environment info:", JSON.stringify(envInfo));
611
939
  const blockProvider = new BlockProvider();
612
940
  const weeklyProvider = new WeeklyProvider();
613
941
  const pollInterval = config.budget?.pollInterval ?? 15;
@@ -622,8 +950,10 @@ async function main() {
622
950
  ]);
623
951
  debug("Block info:", JSON.stringify(blockInfo));
624
952
  debug("Weekly info:", JSON.stringify(weeklyInfo));
953
+ const trendInfo = config.showTrend ? getUsageTrend() : null;
954
+ debug("Trend info:", JSON.stringify(trendInfo));
625
955
  const renderer = new Renderer(config);
626
- const output = renderer.render(blockInfo, weeklyInfo);
956
+ const output = renderer.render(blockInfo, weeklyInfo, envInfo, trendInfo);
627
957
  if (output) {
628
958
  process.stdout.write(output);
629
959
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-limitline",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "A statusline for Claude Code showing real-time usage limits and weekly tracking",
5
5
  "main": "dist/index.js",
6
6
  "bin": {