cc-context-stats 1.6.2 → 1.8.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 (37) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/CLAUDE.md +12 -0
  3. package/README.md +34 -24
  4. package/docs/ARCHITECTURE.md +52 -25
  5. package/docs/CSV_FORMAT.md +2 -0
  6. package/docs/DEPLOYMENT.md +19 -8
  7. package/docs/DEVELOPMENT.md +48 -12
  8. package/docs/MODEL_INTELLIGENCE.md +396 -0
  9. package/docs/configuration.md +35 -0
  10. package/docs/context-stats.md +12 -1
  11. package/docs/installation.md +82 -22
  12. package/docs/scripts.md +47 -23
  13. package/docs/troubleshooting.md +93 -4
  14. package/package.json +1 -1
  15. package/pyproject.toml +1 -1
  16. package/scripts/statusline-full.sh +171 -37
  17. package/scripts/statusline.js +214 -32
  18. package/scripts/statusline.py +195 -47
  19. package/src/claude_statusline/__init__.py +1 -1
  20. package/src/claude_statusline/cli/context_stats.py +85 -13
  21. package/src/claude_statusline/cli/explain.py +228 -0
  22. package/src/claude_statusline/cli/statusline.py +41 -30
  23. package/src/claude_statusline/core/colors.py +78 -9
  24. package/src/claude_statusline/core/config.py +68 -9
  25. package/src/claude_statusline/core/git.py +16 -5
  26. package/src/claude_statusline/graphs/intelligence.py +162 -0
  27. package/src/claude_statusline/graphs/renderer.py +38 -3
  28. package/tests/bash/test_statusline_full.bats +5 -5
  29. package/tests/fixtures/mi_test_vectors.json +140 -0
  30. package/tests/node/intelligence.test.js +98 -0
  31. package/tests/node/statusline.test.js +4 -4
  32. package/tests/python/test_colors.py +105 -0
  33. package/tests/python/test_config_colors.py +78 -0
  34. package/tests/python/test_explain.py +177 -0
  35. package/tests/python/test_intelligence.py +314 -0
  36. package/tests/python/test_layout.py +4 -4
  37. package/tests/python/test_statusline.py +4 -4
@@ -36,6 +36,61 @@ const os = require('os');
36
36
  const ROTATION_THRESHOLD = 10000;
37
37
  const ROTATION_KEEP = 5000;
38
38
 
39
+ // Model Intelligence constants (hardcoded, not configurable)
40
+ const MI_WEIGHT_CPS = 0.60;
41
+ const MI_WEIGHT_ES = 0.25;
42
+ const MI_WEIGHT_PS = 0.15;
43
+ const MI_GREEN_THRESHOLD = 0.65;
44
+ const MI_YELLOW_THRESHOLD = 0.35;
45
+ const MI_PRODUCTIVITY_TARGET = 0.2;
46
+
47
+ /**
48
+ * Compute Model Intelligence score.
49
+ * Returns { mi, cps, es, ps }.
50
+ */
51
+ function computeMI(usedTokens, contextWindowSize, cacheRead, totalContext,
52
+ deltaLines, deltaOutput, beta = 1.5) {
53
+ // Guard clause
54
+ if (contextWindowSize === 0) {
55
+ return { mi: 1.0, cps: 1.0, es: 1.0, ps: 0.5 };
56
+ }
57
+
58
+ // CPS
59
+ const u = usedTokens / contextWindowSize;
60
+ const cps = u > 0 ? Math.max(0, 1 - Math.pow(u, beta)) : 1.0;
61
+
62
+ // ES
63
+ let es;
64
+ if (totalContext === 0) {
65
+ es = 1.0;
66
+ } else {
67
+ const cacheHitRatio = cacheRead / totalContext;
68
+ es = 0.3 + 0.7 * cacheHitRatio;
69
+ }
70
+
71
+ // PS
72
+ let ps;
73
+ if (deltaOutput === null || deltaOutput === undefined || deltaOutput <= 0) {
74
+ ps = 0.5;
75
+ } else {
76
+ const ratio = deltaLines / deltaOutput;
77
+ const normalized = Math.min(1.0, ratio / MI_PRODUCTIVITY_TARGET);
78
+ ps = 0.2 + 0.8 * normalized;
79
+ }
80
+
81
+ const mi = MI_WEIGHT_CPS * cps + MI_WEIGHT_ES * es + MI_WEIGHT_PS * ps;
82
+ return { mi, cps, es, ps };
83
+ }
84
+
85
+ /**
86
+ * Return ANSI color code for MI score.
87
+ */
88
+ function getMIColor(mi, greenColor, yellowColor, redColor) {
89
+ if (mi > MI_GREEN_THRESHOLD) return greenColor || GREEN;
90
+ if (mi > MI_YELLOW_THRESHOLD) return yellowColor || YELLOW;
91
+ return redColor || RED;
92
+ }
93
+
39
94
  /**
40
95
  * Rotate a state file if it exceeds ROTATION_THRESHOLD lines.
41
96
  * Keeps the most recent ROTATION_KEEP lines via atomic temp-file + rename.
@@ -72,7 +127,7 @@ function maybeRotateStateFile(stateFile) {
72
127
  }
73
128
  }
74
129
 
75
- // ANSI Colors
130
+ // ANSI Colors (defaults, overridable via config)
76
131
  const BLUE = '\x1b[0;34m';
77
132
  const MAGENTA = '\x1b[0;35m';
78
133
  const CYAN = '\x1b[0;36m';
@@ -82,6 +137,54 @@ const RED = '\x1b[0;31m';
82
137
  const DIM = '\x1b[2m';
83
138
  const RESET = '\x1b[0m';
84
139
 
140
+ // Named colors for config parsing
141
+ const COLOR_NAMES = {
142
+ black: '\x1b[0;30m',
143
+ red: '\x1b[0;31m',
144
+ green: '\x1b[0;32m',
145
+ yellow: '\x1b[0;33m',
146
+ blue: '\x1b[0;34m',
147
+ magenta: '\x1b[0;35m',
148
+ cyan: '\x1b[0;36m',
149
+ white: '\x1b[0;37m',
150
+ bright_black: '\x1b[0;90m',
151
+ bright_red: '\x1b[0;91m',
152
+ bright_green: '\x1b[0;92m',
153
+ bright_yellow: '\x1b[0;93m',
154
+ bright_blue: '\x1b[0;94m',
155
+ bright_magenta: '\x1b[0;95m',
156
+ bright_cyan: '\x1b[0;96m',
157
+ bright_white: '\x1b[0;97m',
158
+ };
159
+
160
+ /**
161
+ * Parse a color name or #rrggbb hex into an ANSI escape code.
162
+ * Returns null if unrecognized.
163
+ */
164
+ function parseColor(value) {
165
+ value = value.trim().toLowerCase();
166
+ if (COLOR_NAMES[value]) {
167
+ return COLOR_NAMES[value];
168
+ }
169
+ const m = value.match(/^#([0-9a-f]{6})$/);
170
+ if (m) {
171
+ const r = parseInt(m[1].slice(0, 2), 16);
172
+ const g = parseInt(m[1].slice(2, 4), 16);
173
+ const b = parseInt(m[1].slice(4, 6), 16);
174
+ return `\x1b[38;2;${r};${g};${b}m`;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ const COLOR_CONFIG_KEYS = {
180
+ color_green: 'green',
181
+ color_yellow: 'yellow',
182
+ color_red: 'red',
183
+ color_blue: 'blue',
184
+ color_magenta: 'magenta',
185
+ color_cyan: 'cyan',
186
+ };
187
+
85
188
  /**
86
189
  * Return the visible width of a string after stripping ANSI escape sequences.
87
190
  */
@@ -93,8 +196,24 @@ function visibleWidth(s) {
93
196
  /**
94
197
  * Return the terminal width in columns, defaulting to 80.
95
198
  */
199
+ /**
200
+ * Return the terminal width in columns.
201
+ *
202
+ * When running inside Claude Code's statusline subprocess, neither $COLUMNS
203
+ * nor process.stdout.columns can detect the real terminal width (they return
204
+ * undefined or 80). If COLUMNS is not explicitly set and we'd fall back to 80,
205
+ * use a generous default of 200 so that no parts are unnecessarily dropped;
206
+ * Claude Code's own UI handles any overflow/truncation.
207
+ */
96
208
  function getTerminalWidth() {
97
- return process.stdout.columns || parseInt(process.env.COLUMNS, 10) || 80;
209
+ if (process.env.COLUMNS) {
210
+ return parseInt(process.env.COLUMNS, 10) || 200;
211
+ }
212
+ const cols = process.stdout.columns;
213
+ if (cols && cols !== 80) {
214
+ return cols;
215
+ }
216
+ return 200;
98
217
  }
99
218
 
100
219
  /**
@@ -125,7 +244,9 @@ function fitToWidth(parts, maxWidth) {
125
244
  return result;
126
245
  }
127
246
 
128
- function getGitInfo(projectDir) {
247
+ function getGitInfo(projectDir, magentaColor, cyanColor) {
248
+ const mg = magentaColor || MAGENTA;
249
+ const cy = cyanColor || CYAN;
129
250
  const gitDir = path.join(projectDir, '.git');
130
251
  if (!fs.existsSync(gitDir) || !fs.statSync(gitDir).isDirectory()) {
131
252
  return '';
@@ -154,9 +275,9 @@ function getGitInfo(projectDir) {
154
275
  const changes = status.split('\n').filter(l => l.trim()).length;
155
276
 
156
277
  if (changes > 0) {
157
- return ` | ${MAGENTA}${branch}${RESET} ${CYAN}[${changes}]${RESET}`;
278
+ return ` | ${mg}${branch}${RESET} ${cy}[${changes}]${RESET}`;
158
279
  }
159
- return ` | ${MAGENTA}${branch}${RESET}`;
280
+ return ` | ${mg}${branch}${RESET}`;
160
281
  } catch {
161
282
  return '';
162
283
  }
@@ -170,6 +291,9 @@ function readConfig() {
170
291
  showSession: true,
171
292
  showIoTokens: true,
172
293
  reducedMotion: false,
294
+ showMI: true,
295
+ miCurveBeta: 1.5,
296
+ colors: {},
173
297
  };
174
298
  const configPath = path.join(os.homedir(), '.claude', 'statusline.conf');
175
299
 
@@ -192,6 +316,12 @@ show_delta=true
192
316
 
193
317
  # Show session_id in status line
194
318
  show_session=true
319
+
320
+ # Custom colors - use named colors or hex (#rrggbb)
321
+ # Available: color_green, color_yellow, color_red, color_blue, color_magenta, color_cyan
322
+ # Examples:
323
+ # color_green=#7dcfff
324
+ # color_red=#f7768e
195
325
  `;
196
326
  fs.writeFileSync(configPath, defaultConfig);
197
327
  } catch (e) {
@@ -207,9 +337,10 @@ show_session=true
207
337
  if (trimmed.startsWith('#') || !trimmed.includes('=')) {
208
338
  continue;
209
339
  }
210
- const [key, value] = trimmed.split('=', 2);
211
- const keyTrimmed = key.trim();
212
- const valueTrimmed = value.trim().toLowerCase();
340
+ const eqIdx = trimmed.indexOf('=');
341
+ const keyTrimmed = trimmed.slice(0, eqIdx).trim();
342
+ const rawValue = trimmed.slice(eqIdx + 1).trim();
343
+ const valueTrimmed = rawValue.toLowerCase();
213
344
  if (keyTrimmed === 'autocompact') {
214
345
  config.autocompact = valueTrimmed !== 'false';
215
346
  } else if (keyTrimmed === 'token_detail') {
@@ -222,6 +353,18 @@ show_session=true
222
353
  config.showIoTokens = valueTrimmed !== 'false';
223
354
  } else if (keyTrimmed === 'reduced_motion') {
224
355
  config.reducedMotion = valueTrimmed !== 'false';
356
+ } else if (keyTrimmed === 'show_mi') {
357
+ config.showMI = valueTrimmed !== 'false';
358
+ } else if (keyTrimmed === 'mi_curve_beta') {
359
+ const parsed = parseFloat(rawValue);
360
+ if (!isNaN(parsed)) {
361
+ config.miCurveBeta = parsed;
362
+ }
363
+ } else if (COLOR_CONFIG_KEYS[keyTrimmed]) {
364
+ const ansi = parseColor(rawValue);
365
+ if (ansi) {
366
+ config.colors[COLOR_CONFIG_KEYS[keyTrimmed]] = ansi;
367
+ }
225
368
  }
226
369
  }
227
370
  } catch (e) {
@@ -250,9 +393,6 @@ process.stdin.on('end', () => {
250
393
  const model = data.model?.display_name || 'Claude';
251
394
  const dirName = path.basename(cwd) || '~';
252
395
 
253
- // Git info
254
- const gitInfo = getGitInfo(projectDir);
255
-
256
396
  // Read settings from config file
257
397
  const config = readConfig();
258
398
  const autocompactEnabled = config.autocompact;
@@ -261,6 +401,18 @@ process.stdin.on('end', () => {
261
401
  const showSession = config.showSession;
262
402
  // Note: showIoTokens setting is read but not yet implemented
263
403
 
404
+ // Apply color overrides from config
405
+ const c = config.colors || {};
406
+ const cGreen = c.green || GREEN;
407
+ const cYellow = c.yellow || YELLOW;
408
+ const cRed = c.red || RED;
409
+ const cBlue = c.blue || BLUE;
410
+ const cMagenta = c.magenta || MAGENTA;
411
+ const cCyan = c.cyan || CYAN;
412
+
413
+ // Git info (pass configurable colors)
414
+ const gitInfo = getGitInfo(projectDir, cMagenta, cCyan);
415
+
264
416
  // Extract session_id once for reuse
265
417
  const sessionId = data.session_id;
266
418
 
@@ -268,7 +420,10 @@ process.stdin.on('end', () => {
268
420
  let contextInfo = '';
269
421
  let acInfo = '';
270
422
  let deltaInfo = '';
423
+ let miInfo = '';
271
424
  let sessionInfo = '';
425
+ const showMI = config.showMI;
426
+ const miCurveBeta = config.miCurveBeta;
272
427
  const totalSize = data.context_window?.context_window_size || 0;
273
428
  const currentUsage = data.context_window?.current_usage;
274
429
  const totalInputTokens = data.context_window?.total_input_tokens || 0;
@@ -320,17 +475,17 @@ process.stdin.on('end', () => {
320
475
  // Color based on free percentage
321
476
  let ctxColor;
322
477
  if (freePctInt > 50) {
323
- ctxColor = GREEN;
478
+ ctxColor = cGreen;
324
479
  } else if (freePctInt > 25) {
325
- ctxColor = YELLOW;
480
+ ctxColor = cYellow;
326
481
  } else {
327
- ctxColor = RED;
482
+ ctxColor = cRed;
328
483
  }
329
484
 
330
- contextInfo = ` | ${ctxColor}${freeDisplay} free (${freePct.toFixed(1)}%)${RESET}`;
485
+ contextInfo = ` | ${ctxColor}${freeDisplay} (${freePct.toFixed(1)}%)${RESET}`;
331
486
 
332
- // Calculate and display token delta if enabled
333
- if (showDelta) {
487
+ // Read previous entry if needed for delta OR MI
488
+ if (showDelta || showMI) {
334
489
  const stateDir = path.join(os.homedir(), '.claude', 'statusline');
335
490
  if (!fs.existsSync(stateDir)) {
336
491
  fs.mkdirSync(stateDir, { recursive: true });
@@ -360,10 +515,13 @@ process.stdin.on('end', () => {
360
515
  const stateFile = path.join(stateDir, stateFileName);
361
516
  let hasPrev = false;
362
517
  let prevTokens = 0;
518
+ let prevOutputTokens = 0;
519
+ let prevLinesAdded = 0;
520
+ let prevLinesRemoved = 0;
363
521
  try {
364
522
  if (fs.existsSync(stateFile)) {
365
523
  hasPrev = true;
366
- // Read last line to get previous context usage
524
+ // Read last line to get previous state
367
525
  const content = fs.readFileSync(stateFile, 'utf8').trim();
368
526
  const lines = content.split('\n');
369
527
  const lastLine = lines[lines.length - 1];
@@ -376,6 +534,10 @@ process.stdin.on('end', () => {
376
534
  const prevCacheCreation = parseInt(parts[5], 10) || 0;
377
535
  const prevCacheRead = parseInt(parts[6], 10) || 0;
378
536
  prevTokens = prevCurInput + prevCacheCreation + prevCacheRead;
537
+ // For MI productivity score
538
+ prevOutputTokens = parseInt(parts[2], 10) || 0;
539
+ prevLinesAdded = parseInt(parts[8], 10) || 0;
540
+ prevLinesRemoved = parseInt(parts[9], 10) || 0;
379
541
  } else {
380
542
  // Old format - single value
381
543
  prevTokens = parseInt(lastLine, 10) || 0;
@@ -387,20 +549,40 @@ process.stdin.on('end', () => {
387
549
  );
388
550
  prevTokens = 0;
389
551
  }
390
- // Calculate delta (difference in context window usage)
391
- const delta = usedTokens - prevTokens;
392
- // Only show positive delta (and skip first run when no previous state)
393
- if (hasPrev && delta > 0) {
394
- const deltaDisplay = tokenDetail
395
- ? delta.toLocaleString('en-US')
396
- : `${(delta / 1000).toFixed(1)}k`;
397
- deltaInfo = ` ${DIM}[+${deltaDisplay}]${RESET}`;
552
+
553
+ // Calculate and display token delta if enabled
554
+ if (showDelta) {
555
+ const delta = usedTokens - prevTokens;
556
+ if (hasPrev && delta > 0) {
557
+ const deltaDisplay = tokenDetail
558
+ ? delta.toLocaleString('en-US')
559
+ : `${(delta / 1000).toFixed(1)}k`;
560
+ deltaInfo = ` ${DIM}[+${deltaDisplay}]${RESET}`;
561
+ }
562
+ }
563
+
564
+ // Calculate and display MI score if enabled
565
+ if (showMI) {
566
+ let deltaLines, deltaOutput;
567
+ if (hasPrev) {
568
+ const deltaLA = linesAdded - prevLinesAdded;
569
+ const deltaLR = linesRemoved - prevLinesRemoved;
570
+ deltaLines = deltaLA + deltaLR;
571
+ deltaOutput = totalOutputTokens - prevOutputTokens;
572
+ } else {
573
+ deltaLines = 0;
574
+ deltaOutput = null;
575
+ }
576
+ const miResult = computeMI(
577
+ usedTokens, totalSize, cacheRead, usedTokens,
578
+ deltaLines, deltaOutput, miCurveBeta
579
+ );
580
+ const miColor = getMIColor(miResult.mi, cGreen, cYellow, cRed);
581
+ miInfo = ` ${miColor}MI:${miResult.mi.toFixed(2)}${RESET}`;
398
582
  }
583
+
399
584
  // Only append if context usage changed (avoid duplicates from multiple refreshes)
400
585
  if (!hasPrev || usedTokens !== prevTokens) {
401
- // Append current usage with comprehensive format
402
- // Format: ts,total_in,total_out,cur_in,cur_out,cache_create,cache_read,
403
- // cost_usd,lines_added,lines_removed,session_id,model_id,project_dir
404
586
  try {
405
587
  const timestamp = Math.floor(Date.now() / 1000);
406
588
  const curInputTokens = currentUsage.input_tokens || 0;
@@ -438,13 +620,13 @@ process.stdin.on('end', () => {
438
620
  }
439
621
 
440
622
  // Output: [Model] dir | branch [n] | free (%) [+delta] [AC] session
441
- const base = `${DIM}[${model}]${RESET} ${BLUE}${dirName}${RESET}`;
623
+ const base = `${DIM}[${model}]${RESET} ${cBlue}${dirName}${RESET}`;
442
624
  const maxWidth = getTerminalWidth();
443
- const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
625
+ const parts = [base, gitInfo, contextInfo, deltaInfo, miInfo, acInfo, sessionInfo];
444
626
  console.log(fitToWidth(parts, maxWidth));
445
627
  });
446
628
 
447
629
  // Export for testing
448
630
  if (typeof module !== 'undefined' && module.exports) {
449
- module.exports = { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP };
631
+ module.exports = { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP, computeMI };
450
632
  }