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
@@ -0,0 +1,162 @@
1
+ """Model Intelligence (MI) score computation.
2
+
3
+ Estimates answer quality based on context utilization, cache efficiency,
4
+ and output productivity. Inspired by the Michelangelo paper
5
+ (arXiv:2409.12640, Google DeepMind, Sep 2024).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from claude_statusline.core.state import StateEntry
13
+
14
+ # Hardcoded constants — not configurable, to minimize cross-implementation sync burden
15
+ MI_WEIGHT_CPS = 0.60
16
+ MI_WEIGHT_ES = 0.25
17
+ MI_WEIGHT_PS = 0.15
18
+ MI_GREEN_THRESHOLD = 0.65
19
+ MI_YELLOW_THRESHOLD = 0.35
20
+ MI_PRODUCTIVITY_TARGET = 0.2
21
+
22
+
23
+ @dataclass
24
+ class IntelligenceConfig:
25
+ """Configuration for MI computation."""
26
+
27
+ beta: float = 1.5
28
+
29
+
30
+ @dataclass
31
+ class IntelligenceScore:
32
+ """MI score with sub-components."""
33
+
34
+ cps: float
35
+ es: float
36
+ ps: float
37
+ mi: float
38
+ utilization: float
39
+
40
+
41
+ def calculate_context_pressure(utilization: float, beta: float = 1.5) -> float:
42
+ """Calculate Context Pressure Score (CPS).
43
+
44
+ CPS = max(0, 1 - u^beta) where u is utilization ratio [0, 1+].
45
+
46
+ Args:
47
+ utilization: Context utilization ratio (current_used / context_window_size)
48
+ beta: Curve shape parameter (default 1.5)
49
+
50
+ Returns:
51
+ CPS value in [0, 1]
52
+ """
53
+ if utilization <= 0:
54
+ return 1.0
55
+ return max(0.0, 1.0 - utilization**beta)
56
+
57
+
58
+ def calculate_efficiency(entry: StateEntry) -> float:
59
+ """Calculate Efficiency Score (ES).
60
+
61
+ ES = 0.3 + 0.7 * cache_hit_ratio, where cache_hit_ratio = cache_read / total_context.
62
+
63
+ Args:
64
+ entry: Current state entry
65
+
66
+ Returns:
67
+ ES value in [0.3, 1.0]
68
+ """
69
+ total_context = entry.current_used_tokens
70
+ if total_context == 0:
71
+ return 1.0
72
+ cache_hit_ratio = entry.cache_read / total_context
73
+ return 0.3 + 0.7 * cache_hit_ratio
74
+
75
+
76
+ def calculate_productivity(
77
+ current: StateEntry, previous: StateEntry | None
78
+ ) -> float:
79
+ """Calculate Productivity Score (PS).
80
+
81
+ Uses consecutive entry diffs for delta_lines and delta_output_tokens.
82
+
83
+ Args:
84
+ current: Current state entry
85
+ previous: Previous state entry, or None
86
+
87
+ Returns:
88
+ PS value in [0.2, 1.0], or 0.5 if no previous entry
89
+ """
90
+ if previous is None:
91
+ return 0.5
92
+
93
+ delta_lines_added = current.lines_added - previous.lines_added
94
+ delta_lines_removed = current.lines_removed - previous.lines_removed
95
+ delta_output_tokens = current.total_output_tokens - previous.total_output_tokens
96
+
97
+ if delta_output_tokens <= 0:
98
+ return 0.5
99
+
100
+ delta_lines = delta_lines_added + delta_lines_removed
101
+ ratio = delta_lines / delta_output_tokens
102
+ normalized = min(1.0, ratio / MI_PRODUCTIVITY_TARGET)
103
+ return 0.2 + 0.8 * normalized
104
+
105
+
106
+ def calculate_intelligence(
107
+ current: StateEntry,
108
+ previous: StateEntry | None,
109
+ context_window_size: int,
110
+ beta: float = 1.5,
111
+ ) -> IntelligenceScore:
112
+ """Calculate composite Model Intelligence score.
113
+
114
+ Args:
115
+ current: Current state entry
116
+ previous: Previous state entry (for productivity delta)
117
+ context_window_size: Total context window size in tokens
118
+ beta: CPS curve shape parameter
119
+
120
+ Returns:
121
+ IntelligenceScore with all sub-scores and composite MI
122
+ """
123
+ # Guard clause: unknown context window
124
+ if context_window_size == 0:
125
+ return IntelligenceScore(cps=1.0, es=1.0, ps=0.5, mi=1.0, utilization=0.0)
126
+
127
+ utilization = current.current_used_tokens / context_window_size
128
+ cps = calculate_context_pressure(utilization, beta)
129
+ es = calculate_efficiency(current)
130
+ ps = calculate_productivity(current, previous)
131
+
132
+ mi = MI_WEIGHT_CPS * cps + MI_WEIGHT_ES * es + MI_WEIGHT_PS * ps
133
+
134
+ return IntelligenceScore(cps=cps, es=es, ps=ps, mi=mi, utilization=utilization)
135
+
136
+
137
+ def get_mi_color(mi: float) -> str:
138
+ """Get color name for MI score.
139
+
140
+ Args:
141
+ mi: MI score value
142
+
143
+ Returns:
144
+ Color name: "green", "yellow", or "red"
145
+ """
146
+ if mi > MI_GREEN_THRESHOLD:
147
+ return "green"
148
+ if mi > MI_YELLOW_THRESHOLD:
149
+ return "yellow"
150
+ return "red"
151
+
152
+
153
+ def format_mi_score(mi: float) -> str:
154
+ """Format MI score for display.
155
+
156
+ Args:
157
+ mi: MI score value
158
+
159
+ Returns:
160
+ Formatted string like "0.82"
161
+ """
162
+ return f"{mi:.2f}"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import shutil
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
8
 
8
9
  from claude_statusline.core.colors import ColorManager
@@ -94,6 +95,7 @@ class GraphRenderer:
94
95
  timestamps: list[int],
95
96
  title: str,
96
97
  color: str,
98
+ label_fn: Callable[[int], str] | None = None,
97
99
  ) -> None:
98
100
  """Render a timeseries ASCII graph.
99
101
 
@@ -102,6 +104,7 @@ class GraphRenderer:
102
104
  timestamps: Corresponding timestamps for x-axis labels
103
105
  title: Graph title
104
106
  color: ANSI color code for the graph
107
+ label_fn: Optional function to format Y-axis labels. If None, uses format_tokens().
105
108
  """
106
109
  n = len(data)
107
110
  if n == 0:
@@ -119,12 +122,14 @@ class GraphRenderer:
119
122
  width = self.dimensions.graph_width
120
123
  height = self.dimensions.graph_height
121
124
 
125
+ fmt = label_fn if label_fn else lambda v: format_tokens(v, self.token_detail)
126
+
122
127
  # Print title and stats
123
128
  self._emit()
124
129
  self._emit(f"{self.colors.bold}{title}{self.colors.reset}")
125
130
  self._emit(
126
- f"{self.colors.dim}Max: {format_tokens(max_val, self.token_detail)} "
127
- f"Min: {format_tokens(min_val, self.token_detail)} "
131
+ f"{self.colors.dim}Max: {fmt(max_val)} "
132
+ f"Min: {fmt(min_val)} "
128
133
  f"Points: {n}{self.colors.reset}"
129
134
  )
130
135
  self._emit()
@@ -138,7 +143,7 @@ class GraphRenderer:
138
143
 
139
144
  # Show labels at top, middle, and bottom
140
145
  if r == 0 or r == height // 2 or r == height - 1:
141
- label = format_tokens(val, self.token_detail)
146
+ label = fmt(val)
142
147
  else:
143
148
  label = ""
144
149
 
@@ -259,12 +264,14 @@ class GraphRenderer:
259
264
  self,
260
265
  entries: list, # list[StateEntry]
261
266
  deltas: list[int],
267
+ mi_score: object | None = None, # IntelligenceScore
262
268
  ) -> None:
263
269
  """Render summary statistics.
264
270
 
265
271
  Args:
266
272
  entries: List of StateEntry objects
267
273
  deltas: List of token deltas
274
+ mi_score: Optional IntelligenceScore for MI display
268
275
  """
269
276
  if not entries:
270
277
  return
@@ -316,6 +323,34 @@ class GraphRenderer:
316
323
  f" {status_color}{self.colors.bold}>>> {status_text.upper()} <<<{self.colors.reset} "
317
324
  f"{self.colors.dim}({status_hint}){self.colors.reset}"
318
325
  )
326
+
327
+ # Model Intelligence score
328
+ if mi_score is not None:
329
+ from claude_statusline.graphs.intelligence import (
330
+ format_mi_score,
331
+ get_mi_color,
332
+ )
333
+
334
+ mi_color_name = get_mi_color(mi_score.mi)
335
+ mi_color = getattr(self.colors, mi_color_name)
336
+ if mi_score.mi > 0.65:
337
+ mi_hint = "Model is operating well"
338
+ elif mi_score.mi > 0.35:
339
+ mi_hint = "Context pressure is degrading answer quality"
340
+ else:
341
+ mi_hint = "Severely degraded, consider new session"
342
+ self._emit(
343
+ f" {mi_color}{'Model Intelligence:':<20}{self.colors.reset} "
344
+ f"{format_mi_score(mi_score.mi)} "
345
+ f"{self.colors.dim}({mi_hint}){self.colors.reset}"
346
+ )
347
+ self._emit(
348
+ f" {self.colors.dim} CPS: {mi_score.cps:.2f} "
349
+ f"ES: {mi_score.es:.2f} "
350
+ f"PS: {mi_score.ps:.2f}{self.colors.reset}"
351
+ )
352
+
353
+ if last.context_window_size > 0:
319
354
  self._emit()
320
355
 
321
356
  # Session details (ordered: Last Growth, I/O, Lines, Cost, Model, Duration)
@@ -38,7 +38,7 @@ teardown() {
38
38
  result=$(cat "$FIXTURES/valid_full.json" | "$SCRIPT")
39
39
  [[ "$result" == *"Opus 4.5"* ]]
40
40
  [[ "$result" == *"my-project"* ]]
41
- [[ "$result" == *"free"* ]]
41
+ [[ "$result" == *"%"* ]]
42
42
  }
43
43
 
44
44
  @test "shows AC indicator when autocompact enabled" {
@@ -58,8 +58,8 @@ teardown() {
58
58
  input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
59
59
  result=$(echo "$input" | "$SCRIPT")
60
60
  # Should NOT show 'k' suffix by default, should show comma-formatted number
61
- [[ "$result" != *"k free"* ]]
62
- [[ "$result" == *"free"* ]]
61
+ [[ "$result" != *"k ("* ]]
62
+ [[ "$result" == *"%"* ]]
63
63
  }
64
64
 
65
65
  @test "shows abbreviated tokens when token_detail=false" {
@@ -67,7 +67,7 @@ teardown() {
67
67
  input='{"model":{"display_name":"Claude"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":10000,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}'
68
68
  result=$(echo "$input" | "$SCRIPT")
69
69
  # Should show 'k' suffix for abbreviated format
70
- [[ "$result" == *"k free"* ]]
70
+ [[ "$result" == *"k ("* ]]
71
71
  }
72
72
 
73
73
  @test "handles missing context window gracefully" {
@@ -79,7 +79,7 @@ teardown() {
79
79
  @test "calculates free tokens percentage correctly" {
80
80
  # Low usage fixture: 30k tokens used out of 200k = 85% free
81
81
  result=$(cat "$FIXTURES/low_usage.json" | "$SCRIPT")
82
- [[ "$result" == *"free"* ]]
82
+ [[ "$result" == *"%"* ]]
83
83
  }
84
84
 
85
85
  @test "uses fixture files correctly" {
@@ -0,0 +1,140 @@
1
+ [
2
+ {
3
+ "description": "Fresh session - low utilization, good cache, productive",
4
+ "input": {
5
+ "current_used": 20000,
6
+ "context_window": 200000,
7
+ "cache_read": 12000,
8
+ "current_input": 5000,
9
+ "cache_creation": 3000,
10
+ "prev_lines_added": 0,
11
+ "prev_lines_removed": 0,
12
+ "cur_lines_added": 150,
13
+ "cur_lines_removed": 10,
14
+ "prev_output": 0,
15
+ "cur_output": 1000,
16
+ "beta": 1.5
17
+ },
18
+ "expected": {
19
+ "cps": 0.968,
20
+ "es": 0.72,
21
+ "ps": 0.84,
22
+ "mi": 0.887
23
+ }
24
+ },
25
+ {
26
+ "description": "Mid-session - moderate utilization, some cache, moderate productivity",
27
+ "input": {
28
+ "current_used": 100000,
29
+ "context_window": 200000,
30
+ "cache_read": 40000,
31
+ "current_input": 35000,
32
+ "cache_creation": 25000,
33
+ "prev_lines_added": 50,
34
+ "prev_lines_removed": 10,
35
+ "cur_lines_added": 150,
36
+ "cur_lines_removed": 20,
37
+ "prev_output": 500,
38
+ "cur_output": 1500,
39
+ "beta": 1.5
40
+ },
41
+ "expected": {
42
+ "cps": 0.646,
43
+ "es": 0.58,
44
+ "ps": 0.64,
45
+ "mi": 0.629
46
+ }
47
+ },
48
+ {
49
+ "description": "Late session - high utilization, low cache, low productivity",
50
+ "input": {
51
+ "current_used": 170000,
52
+ "context_window": 200000,
53
+ "cache_read": 34000,
54
+ "current_input": 85000,
55
+ "cache_creation": 51000,
56
+ "prev_lines_added": 200,
57
+ "prev_lines_removed": 50,
58
+ "cur_lines_added": 250,
59
+ "cur_lines_removed": 55,
60
+ "prev_output": 2000,
61
+ "cur_output": 3000,
62
+ "beta": 1.5
63
+ },
64
+ "expected": {
65
+ "cps": 0.217,
66
+ "es": 0.44,
67
+ "ps": 0.42,
68
+ "mi": 0.303
69
+ }
70
+ },
71
+ {
72
+ "description": "No previous entry - PS defaults to 0.5",
73
+ "input": {
74
+ "current_used": 50000,
75
+ "context_window": 200000,
76
+ "cache_read": 30000,
77
+ "current_input": 10000,
78
+ "cache_creation": 10000,
79
+ "prev_lines_added": null,
80
+ "prev_lines_removed": null,
81
+ "cur_lines_added": 100,
82
+ "cur_lines_removed": 5,
83
+ "prev_output": null,
84
+ "cur_output": 800,
85
+ "beta": 1.5
86
+ },
87
+ "expected": {
88
+ "cps": 0.875,
89
+ "es": 0.72,
90
+ "ps": 0.5,
91
+ "mi": 0.780
92
+ }
93
+ },
94
+ {
95
+ "description": "Context window is zero - guard clause returns defaults",
96
+ "input": {
97
+ "current_used": 50000,
98
+ "context_window": 0,
99
+ "cache_read": 30000,
100
+ "current_input": 10000,
101
+ "cache_creation": 10000,
102
+ "prev_lines_added": 0,
103
+ "prev_lines_removed": 0,
104
+ "cur_lines_added": 100,
105
+ "cur_lines_removed": 5,
106
+ "prev_output": 0,
107
+ "cur_output": 800,
108
+ "beta": 1.5
109
+ },
110
+ "expected": {
111
+ "cps": 1.0,
112
+ "es": 1.0,
113
+ "ps": 0.5,
114
+ "mi": 1.0
115
+ }
116
+ },
117
+ {
118
+ "description": "No cache at all - ES at minimum floor",
119
+ "input": {
120
+ "current_used": 80000,
121
+ "context_window": 200000,
122
+ "cache_read": 0,
123
+ "current_input": 80000,
124
+ "cache_creation": 0,
125
+ "prev_lines_added": 0,
126
+ "prev_lines_removed": 0,
127
+ "cur_lines_added": 50,
128
+ "cur_lines_removed": 10,
129
+ "prev_output": 0,
130
+ "cur_output": 500,
131
+ "beta": 1.5
132
+ },
133
+ "expected": {
134
+ "cps": 0.747,
135
+ "es": 0.3,
136
+ "ps": 0.68,
137
+ "mi": 0.625
138
+ }
139
+ }
140
+ ]
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for Model Intelligence (MI) score computation.
3
+ * Uses shared test vectors for cross-implementation parity.
4
+ */
5
+
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const { computeMI } = require('../../scripts/statusline');
9
+
10
+ const VECTORS_PATH = path.join(__dirname, '..', 'fixtures', 'mi_test_vectors.json');
11
+ const vectors = JSON.parse(fs.readFileSync(VECTORS_PATH, 'utf8'));
12
+
13
+ describe('computeMI', () => {
14
+ test('guard clause: context_window=0 returns defaults', () => {
15
+ const result = computeMI(50000, 0, 30000, 50000, 0, null, 1.5);
16
+ expect(result.mi).toBe(1.0);
17
+ expect(result.cps).toBe(1.0);
18
+ expect(result.es).toBe(1.0);
19
+ expect(result.ps).toBe(0.5);
20
+ });
21
+
22
+ test('empty context returns CPS=1', () => {
23
+ const result = computeMI(0, 200000, 0, 0, 0, null, 1.5);
24
+ expect(result.cps).toBe(1.0);
25
+ });
26
+
27
+ test('full context returns CPS=0', () => {
28
+ const result = computeMI(200000, 200000, 0, 200000, 0, 100, 1.5);
29
+ expect(result.cps).toBe(0);
30
+ });
31
+
32
+ test('no cache returns ES=0.3', () => {
33
+ const result = computeMI(100000, 200000, 0, 100000, 0, null, 1.5);
34
+ expect(result.es).toBeCloseTo(0.3, 1);
35
+ });
36
+
37
+ test('all cache returns ES=1.0', () => {
38
+ const result = computeMI(100000, 200000, 100000, 100000, 0, null, 1.5);
39
+ expect(result.es).toBeCloseTo(1.0, 1);
40
+ });
41
+
42
+ test('no previous returns PS=0.5', () => {
43
+ const result = computeMI(100000, 200000, 50000, 100000, 0, null, 1.5);
44
+ expect(result.ps).toBe(0.5);
45
+ });
46
+
47
+ test('no output returns PS=0.5', () => {
48
+ const result = computeMI(100000, 200000, 50000, 100000, 100, 0, 1.5);
49
+ expect(result.ps).toBe(0.5);
50
+ });
51
+
52
+ test('MI is always between 0 and 1', () => {
53
+ const utilizations = [0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0];
54
+ for (const u of utilizations) {
55
+ const used = Math.floor(u * 200000);
56
+ const result = computeMI(used, 200000, used / 2, used, 50, 500, 1.5);
57
+ expect(result.mi).toBeGreaterThanOrEqual(0);
58
+ expect(result.mi).toBeLessThanOrEqual(1);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe('shared test vectors', () => {
64
+ vectors.forEach((vec) => {
65
+ test(vec.description, () => {
66
+ const inp = vec.input;
67
+ const exp = vec.expected;
68
+
69
+ const hasPrev = inp.prev_output !== null;
70
+ let deltaLines, deltaOutput;
71
+
72
+ if (hasPrev) {
73
+ const deltaLA = inp.cur_lines_added - inp.prev_lines_added;
74
+ const deltaLR = inp.cur_lines_removed - inp.prev_lines_removed;
75
+ deltaLines = deltaLA + deltaLR;
76
+ deltaOutput = inp.cur_output - inp.prev_output;
77
+ } else {
78
+ deltaLines = 0;
79
+ deltaOutput = null;
80
+ }
81
+
82
+ const result = computeMI(
83
+ inp.current_used,
84
+ inp.context_window,
85
+ inp.cache_read,
86
+ inp.current_used,
87
+ deltaLines,
88
+ deltaOutput,
89
+ inp.beta
90
+ );
91
+
92
+ expect(result.cps).toBeCloseTo(exp.cps, 1);
93
+ expect(result.es).toBeCloseTo(exp.es, 1);
94
+ expect(result.ps).toBeCloseTo(exp.ps, 1);
95
+ expect(result.mi).toBeCloseTo(exp.mi, 1);
96
+ });
97
+ });
98
+ });
@@ -97,7 +97,7 @@ describe('statusline.js', () => {
97
97
 
98
98
  test('shows free tokens indicator', async () => {
99
99
  const result = await runScript(sampleInput);
100
- expect(result.stdout).toContain('free');
100
+ expect(result.stdout).toContain('%');
101
101
  });
102
102
 
103
103
  test('shows AC indicator', async () => {
@@ -162,21 +162,21 @@ describe('statusline.js', () => {
162
162
  const input = loadFixture('low_usage');
163
163
  const result = await runScript(input);
164
164
  expect(result.code).toBe(0);
165
- expect(result.stdout).toContain('free');
165
+ expect(result.stdout).toContain('%');
166
166
  });
167
167
 
168
168
  test('handles medium_usage fixture', async () => {
169
169
  const input = loadFixture('medium_usage');
170
170
  const result = await runScript(input);
171
171
  expect(result.code).toBe(0);
172
- expect(result.stdout).toContain('free');
172
+ expect(result.stdout).toContain('%');
173
173
  });
174
174
 
175
175
  test('handles high_usage fixture', async () => {
176
176
  const input = loadFixture('high_usage');
177
177
  const result = await runScript(input);
178
178
  expect(result.code).toBe(0);
179
- expect(result.stdout).toContain('free');
179
+ expect(result.stdout).toContain('%');
180
180
  });
181
181
 
182
182
  test('all JSON fixtures succeed', async () => {
@@ -0,0 +1,105 @@
1
+ """Tests for configurable colors."""
2
+
3
+ from claude_statusline.core.colors import (
4
+ BLUE,
5
+ CYAN,
6
+ GREEN,
7
+ MAGENTA,
8
+ RED,
9
+ YELLOW,
10
+ ColorManager,
11
+ parse_color,
12
+ )
13
+
14
+
15
+ class TestParseColor:
16
+ """Tests for parse_color()."""
17
+
18
+ def test_named_color_red(self):
19
+ assert parse_color("red") == "\033[0;31m"
20
+
21
+ def test_named_color_green(self):
22
+ assert parse_color("green") == "\033[0;32m"
23
+
24
+ def test_named_color_bright_cyan(self):
25
+ assert parse_color("bright_cyan") == "\033[0;96m"
26
+
27
+ def test_named_color_case_insensitive(self):
28
+ assert parse_color("RED") == "\033[0;31m"
29
+ assert parse_color("Green") == "\033[0;32m"
30
+
31
+ def test_hex_color(self):
32
+ result = parse_color("#ff5733")
33
+ assert result == "\033[38;2;255;87;51m"
34
+
35
+ def test_hex_color_uppercase(self):
36
+ result = parse_color("#FF5733")
37
+ assert result == "\033[38;2;255;87;51m"
38
+
39
+ def test_hex_color_black(self):
40
+ result = parse_color("#000000")
41
+ assert result == "\033[38;2;0;0;0m"
42
+
43
+ def test_hex_color_white(self):
44
+ result = parse_color("#ffffff")
45
+ assert result == "\033[38;2;255;255;255m"
46
+
47
+ def test_invalid_color_returns_none(self):
48
+ assert parse_color("nonexistent") is None
49
+
50
+ def test_empty_string_returns_none(self):
51
+ assert parse_color("") is None
52
+
53
+ def test_invalid_hex_returns_none(self):
54
+ assert parse_color("#xyz") is None
55
+ assert parse_color("#12345") is None
56
+ assert parse_color("#1234567") is None
57
+
58
+ def test_strips_whitespace(self):
59
+ assert parse_color(" red ") == "\033[0;31m"
60
+ assert parse_color(" #ff5733 ") == "\033[38;2;255;87;51m"
61
+
62
+
63
+ class TestColorManager:
64
+ """Tests for ColorManager with overrides."""
65
+
66
+ def test_defaults_without_overrides(self):
67
+ cm = ColorManager(enabled=True)
68
+ assert cm.green == GREEN
69
+ assert cm.yellow == YELLOW
70
+ assert cm.red == RED
71
+ assert cm.blue == BLUE
72
+ assert cm.magenta == MAGENTA
73
+ assert cm.cyan == CYAN
74
+
75
+ def test_override_single_color(self):
76
+ custom = "\033[38;2;255;0;0m"
77
+ cm = ColorManager(enabled=True, overrides={"green": custom})
78
+ assert cm.green == custom
79
+ # Others unchanged
80
+ assert cm.yellow == YELLOW
81
+ assert cm.red == RED
82
+
83
+ def test_override_multiple_colors(self):
84
+ overrides = {
85
+ "green": "\033[38;2;0;255;0m",
86
+ "red": "\033[38;2;255;0;0m",
87
+ }
88
+ cm = ColorManager(enabled=True, overrides=overrides)
89
+ assert cm.green == overrides["green"]
90
+ assert cm.red == overrides["red"]
91
+ assert cm.yellow == YELLOW # not overridden
92
+
93
+ def test_disabled_returns_empty(self):
94
+ overrides = {"green": "\033[38;2;0;255;0m"}
95
+ cm = ColorManager(enabled=False, overrides=overrides)
96
+ assert cm.green == ""
97
+ assert cm.yellow == ""
98
+ assert cm.bold == ""
99
+ assert cm.reset == ""
100
+
101
+ def test_bold_dim_reset_not_overridable(self):
102
+ """bold, dim, reset are always the standard ANSI codes."""
103
+ cm = ColorManager(enabled=True, overrides={"bold": "custom"})
104
+ # bold is not in the _get path, it uses the hardcoded value
105
+ assert cm.bold == "\033[1m"