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,228 @@
1
+ """Diagnostic command that dumps the raw JSON context from Claude Code.
2
+
3
+ Usage:
4
+ echo '{"model":...}' | context-stats explain
5
+ echo '{"model":...}' | context-stats explain --no-color
6
+
7
+ Reads the same JSON that Claude Code pipes to the statusline script,
8
+ pretty-prints every field with labels, and shows how cc-context-stats
9
+ interprets them. Useful for debugging blank or missing modules.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+
17
+ from claude_statusline.core.colors import ColorManager
18
+ from claude_statusline.core.config import Config
19
+ from claude_statusline.formatters.tokens import format_tokens
20
+
21
+
22
+ def _pct_color(colors: ColorManager, pct: float) -> str:
23
+ """Return ANSI color based on free-space percentage."""
24
+ if pct > 50:
25
+ return colors.green
26
+ if pct > 25:
27
+ return colors.yellow
28
+ return colors.red
29
+
30
+
31
+ def _fv(colors: ColorManager, value: object) -> str:
32
+ """Format a value for display, handling None gracefully."""
33
+ if value is None:
34
+ return f"{colors.dim}(absent){colors.reset}"
35
+ if isinstance(value, float):
36
+ return f"{value:.4f}"
37
+ return str(value)
38
+
39
+
40
+ def _render_model(data: dict, colors: ColorManager) -> None:
41
+ model = data.get("model", {})
42
+ print(f"{colors.bold}Model{colors.reset}")
43
+ print(f" display_name: {_fv(colors, model.get('display_name'))}")
44
+ print(f" id: {_fv(colors, model.get('id'))}")
45
+ print(f" api_name: {_fv(colors, model.get('api_name'))}")
46
+ print()
47
+
48
+
49
+ def _render_workspace(data: dict, colors: ColorManager) -> None:
50
+ workspace = data.get("workspace", {})
51
+ print(f"{colors.bold}Workspace{colors.reset}")
52
+ print(f" current_dir: {_fv(colors, workspace.get('current_dir'))}")
53
+ print(f" project_dir: {_fv(colors, workspace.get('project_dir'))}")
54
+ print()
55
+
56
+
57
+ def _render_context_window(data: dict, colors: ColorManager, config: Config) -> None:
58
+ cw = data.get("context_window", {})
59
+ print(f"{colors.bold}Context Window{colors.reset}")
60
+ total_size = cw.get("context_window_size", 0)
61
+ print(
62
+ f" window_size: "
63
+ f"{format_tokens(total_size, config.token_detail) if total_size else _fv(colors, None)}"
64
+ )
65
+
66
+ total_in = cw.get("total_input_tokens")
67
+ total_out = cw.get("total_output_tokens")
68
+ print(
69
+ f" total_input: "
70
+ f"{format_tokens(total_in, config.token_detail) if total_in else _fv(colors, total_in)}"
71
+ )
72
+ print(
73
+ f" total_output: "
74
+ f"{format_tokens(total_out, config.token_detail) if total_out else _fv(colors, total_out)}"
75
+ )
76
+
77
+ used_pct = cw.get("used_percentage")
78
+ remaining_pct = cw.get("remaining_percentage")
79
+ print(f" used_pct: {_fv(colors, used_pct)}")
80
+ print(f" remaining_pct: {_fv(colors, remaining_pct)}")
81
+
82
+ cu = cw.get("current_usage")
83
+ if cu:
84
+ _render_current_usage(cu, total_size, colors, config)
85
+ else:
86
+ print(f" current_usage: {colors.dim}(absent — no API call yet this session){colors.reset}")
87
+ print()
88
+
89
+
90
+ def _render_current_usage(cu: dict, total_size: int, colors: ColorManager, config: Config) -> None:
91
+ input_tok = cu.get("input_tokens", 0)
92
+ output_tok = cu.get("output_tokens", 0)
93
+ cache_create = cu.get("cache_creation_input_tokens", 0)
94
+ cache_read = cu.get("cache_read_input_tokens", 0)
95
+ used_tokens = input_tok + cache_create + cache_read
96
+
97
+ print(f"\n {colors.bold}Current Usage{colors.reset}")
98
+ print(f" input_tokens: {format_tokens(input_tok, config.token_detail)}")
99
+ print(f" output_tokens: {format_tokens(output_tok, config.token_detail)}")
100
+ print(f" cache_creation_tokens: {format_tokens(cache_create, config.token_detail)}")
101
+ print(f" cache_read_tokens: {format_tokens(cache_read, config.token_detail)}")
102
+
103
+ print(f"\n {colors.bold}Derived Values{colors.reset}")
104
+ print(
105
+ f" context_used (in+cache): "
106
+ f"{colors.cyan}{format_tokens(used_tokens, config.token_detail)}{colors.reset}"
107
+ )
108
+
109
+ if total_size > 0:
110
+ free = total_size - used_tokens
111
+ free_pct = (free * 100.0) / total_size
112
+ color = _pct_color(colors, free_pct)
113
+ print(
114
+ f" free_tokens: "
115
+ f"{color}{format_tokens(max(0, free), config.token_detail)} ({free_pct:.1f}%){colors.reset}"
116
+ )
117
+
118
+ if config.autocompact:
119
+ ac_buffer = int(total_size * 0.225)
120
+ effective_free = max(0, free - ac_buffer)
121
+ eff_pct = (effective_free * 100.0) / total_size
122
+ eff_color = _pct_color(colors, eff_pct)
123
+ print(
124
+ f" autocompact_buffer: "
125
+ f"{colors.dim}{format_tokens(ac_buffer, config.token_detail)}{colors.reset}"
126
+ )
127
+ print(
128
+ f" effective_free (w/ AC): "
129
+ f"{eff_color}{format_tokens(effective_free, config.token_detail)}"
130
+ f" ({eff_pct:.1f}%){colors.reset}"
131
+ )
132
+ else:
133
+ print(f" autocompact: {colors.dim}disabled{colors.reset}")
134
+
135
+
136
+ def _render_cost(data: dict, colors: ColorManager) -> None:
137
+ cost = data.get("cost", {})
138
+ if not cost:
139
+ return
140
+ print(f"{colors.bold}Cost{colors.reset}")
141
+ cost_usd = cost.get("total_cost_usd")
142
+ print(
143
+ f" total_cost_usd: {f'${cost_usd:.4f}' if cost_usd is not None else _fv(colors, None)}"
144
+ )
145
+ print(f" total_duration_ms: {_fv(colors, cost.get('total_duration_ms'))}")
146
+ print(f" lines_added: {_fv(colors, cost.get('total_lines_added'))}")
147
+ print(f" lines_removed: {_fv(colors, cost.get('total_lines_removed'))}")
148
+ print()
149
+
150
+
151
+ def _render_session(data: dict, colors: ColorManager) -> None:
152
+ print(f"{colors.bold}Session{colors.reset}")
153
+ print(f" session_id: {_fv(colors, data.get('session_id'))}")
154
+ print(f" version: {_fv(colors, data.get('version'))}")
155
+ print(f" transcript_path: {_fv(colors, data.get('transcript_path'))}")
156
+ print(f" exceeds_200k: {_fv(colors, data.get('exceeds_200k_tokens'))}")
157
+ print()
158
+
159
+
160
+ def _render_extensions(data: dict, colors: ColorManager) -> None:
161
+ vim = data.get("vim")
162
+ agent = data.get("agent")
163
+ output_style = data.get("output_style")
164
+ if vim is None and agent is None and output_style is None:
165
+ return
166
+ print(f"{colors.bold}Extensions{colors.reset}")
167
+ if vim is not None:
168
+ vim_mode = vim.get("mode") if isinstance(vim, dict) else vim
169
+ print(f" vim_mode: {_fv(colors, vim_mode)}")
170
+ if agent is not None:
171
+ agent_name = agent.get("name") if isinstance(agent, dict) else agent
172
+ print(f" agent: {_fv(colors, agent_name)}")
173
+ if output_style is not None:
174
+ style_name = output_style.get("name") if isinstance(output_style, dict) else output_style
175
+ print(f" output_style: {_fv(colors, style_name)}")
176
+ print()
177
+
178
+
179
+ def _render_config(config: Config, colors: ColorManager) -> None:
180
+ print(
181
+ f"{colors.bold}Active Config{colors.reset} "
182
+ f"{colors.dim}(~/.claude/statusline.conf){colors.reset}"
183
+ )
184
+ for k, v in config.to_dict().items():
185
+ if k == "color_overrides":
186
+ if v:
187
+ print(f" {k}:")
188
+ for slot, ansi_code in v.items():
189
+ print(f" {slot}: {ansi_code}████{colors.reset}")
190
+ continue
191
+ print(f" {k}: {v}")
192
+ print()
193
+
194
+
195
+ def _render_raw_json(data: dict, colors: ColorManager) -> None:
196
+ print(f"{colors.bold}Raw JSON{colors.reset}")
197
+ print(f"{colors.dim}{json.dumps(data, indent=2)}{colors.reset}")
198
+ print()
199
+
200
+
201
+ def run_explain(data: dict, no_color: bool = False) -> None:
202
+ """Print a diagnostic dump of the Claude Code session JSON.
203
+
204
+ Args:
205
+ data: Parsed JSON dict from stdin.
206
+ no_color: If True, suppress ANSI color codes.
207
+ """
208
+ config = Config.load()
209
+
210
+ # Respect --no-color flag and non-TTY output
211
+ color_enabled = not no_color and sys.stdout.isatty()
212
+ colors = ColorManager(enabled=color_enabled, overrides=config.color_overrides)
213
+
214
+ print(f"\n{colors.bold}cc-context-stats explain{colors.reset}")
215
+ print(f"{colors.dim}{'─' * 60}{colors.reset}")
216
+ print(
217
+ f"{colors.dim}Shows how cc-context-stats interprets Claude Code's JSON context."
218
+ f"{colors.reset}\n"
219
+ )
220
+
221
+ _render_model(data, colors)
222
+ _render_workspace(data, colors)
223
+ _render_context_window(data, colors, config)
224
+ _render_cost(data, colors)
225
+ _render_session(data, colors)
226
+ _render_extensions(data, colors)
227
+ _render_config(config, colors)
228
+ _render_raw_json(data, colors)
@@ -26,14 +26,7 @@ from __future__ import annotations
26
26
  import json
27
27
  import sys
28
28
 
29
- from claude_statusline.core.colors import (
30
- BLUE,
31
- DIM,
32
- GREEN,
33
- RED,
34
- RESET,
35
- YELLOW,
36
- )
29
+ from claude_statusline.core.colors import ColorManager
37
30
  from claude_statusline.core.config import Config
38
31
  from claude_statusline.core.git import get_git_info
39
32
  from claude_statusline.core.state import StateEntry, StateFile
@@ -56,12 +49,15 @@ def main() -> None:
56
49
  model = data.get("model", {}).get("display_name", "Claude")
57
50
  dir_name = cwd.rsplit("/", 1)[-1] if "/" in cwd else cwd or "~"
58
51
 
59
- # Git info
60
- git_info = get_git_info(project_dir)
61
-
62
52
  # Read settings from config file
63
53
  config = Config.load()
64
54
 
55
+ # Build color manager with any user overrides
56
+ colors = ColorManager(enabled=True, overrides=config.color_overrides)
57
+
58
+ # Git info (pass color manager for configurable branch/change colors)
59
+ git_info = get_git_info(project_dir, color_manager=colors)
60
+
65
61
  # Extract session_id once for reuse
66
62
  session_id = data.get("session_id")
67
63
 
@@ -69,6 +65,7 @@ def main() -> None:
69
65
  context_info = ""
70
66
  ac_info = ""
71
67
  delta_info = ""
68
+ mi_info = ""
72
69
  session_info = ""
73
70
 
74
71
  total_size = data.get("context_window", {}).get("context_window_size", 0)
@@ -99,9 +96,9 @@ def main() -> None:
99
96
 
100
97
  if config.autocompact:
101
98
  buffer_k = autocompact_buffer // 1000
102
- ac_info = f" {DIM}[AC:{buffer_k}k]{RESET}"
99
+ ac_info = f" {colors.dim}[AC:{buffer_k}k]{colors.reset}"
103
100
  else:
104
- ac_info = f" {DIM}[AC:off]{RESET}"
101
+ ac_info = f" {colors.dim}[AC:off]{colors.reset}"
105
102
 
106
103
  # Format tokens based on token_detail setting
107
104
  free_display = format_tokens(free_tokens, config.token_detail)
@@ -109,30 +106,22 @@ def main() -> None:
109
106
  # Color based on free percentage
110
107
  free_pct_int = int(free_pct)
111
108
  if free_pct_int > 50:
112
- ctx_color = GREEN
109
+ ctx_color = colors.green
113
110
  elif free_pct_int > 25:
114
- ctx_color = YELLOW
111
+ ctx_color = colors.yellow
115
112
  else:
116
- ctx_color = RED
113
+ ctx_color = colors.red
117
114
 
118
- context_info = f" | {ctx_color}{free_display} free ({free_pct:.1f}%){RESET}"
115
+ context_info = f" | {ctx_color}{free_display} ({free_pct:.1f}%){colors.reset}"
119
116
 
120
- # Calculate and display token delta if enabled
121
- if config.show_delta:
117
+ # Read previous entry if needed for delta OR MI
118
+ if config.show_delta or config.show_mi:
122
119
  state_file = StateFile(session_id)
123
120
  prev_entry = state_file.read_last_entry()
124
121
 
125
122
  prev_tokens = prev_entry.current_used_tokens if prev_entry else 0
126
123
  has_prev = prev_entry is not None
127
124
 
128
- # Calculate delta
129
- delta = used_tokens - prev_tokens
130
-
131
- # Only show positive delta (and skip first run when no previous state)
132
- if has_prev and delta > 0:
133
- delta_display = format_tokens(delta, config.token_detail)
134
- delta_info = f" {DIM}[+{delta_display}]{RESET}"
135
-
136
125
  # Build current entry
137
126
  cur_input_tokens = current_usage.get("input_tokens", 0)
138
127
  cur_output_tokens = current_usage.get("output_tokens", 0)
@@ -154,18 +143,40 @@ def main() -> None:
154
143
  context_window_size=total_size,
155
144
  )
156
145
 
146
+ # Calculate and display token delta if enabled
147
+ if config.show_delta:
148
+ delta = used_tokens - prev_tokens
149
+ if has_prev and delta > 0:
150
+ delta_display = format_tokens(delta, config.token_detail)
151
+ delta_info = f" {colors.dim}[+{delta_display}]{colors.reset}"
152
+
153
+ # Calculate and display MI score if enabled
154
+ if config.show_mi:
155
+ from claude_statusline.graphs.intelligence import (
156
+ calculate_intelligence,
157
+ format_mi_score,
158
+ get_mi_color,
159
+ )
160
+
161
+ mi_score = calculate_intelligence(
162
+ entry, prev_entry, total_size, config.mi_curve_beta
163
+ )
164
+ mi_color_name = get_mi_color(mi_score.mi)
165
+ mi_color = getattr(colors, mi_color_name)
166
+ mi_info = f" {mi_color}MI:{format_mi_score(mi_score.mi)}{colors.reset}"
167
+
157
168
  # Only append if context usage changed (avoid duplicates from multiple refreshes)
158
169
  if not has_prev or used_tokens != prev_tokens:
159
170
  state_file.append_entry(entry)
160
171
 
161
172
  # Display session_id if enabled
162
173
  if config.show_session and session_id:
163
- session_info = f" {DIM}{session_id}{RESET}"
174
+ session_info = f" {colors.dim}{session_id}{colors.reset}"
164
175
 
165
176
  # Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [session_id]
166
- base = f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
177
+ base = f"{colors.dim}[{model}]{colors.reset} {colors.blue}{dir_name}{colors.reset}"
167
178
  max_width = get_terminal_width()
168
- parts = [base, git_info, context_info, delta_info, ac_info, session_info]
179
+ parts = [base, git_info, context_info, delta_info, mi_info, ac_info, session_info]
169
180
  print(fit_to_width(parts, max_width))
170
181
 
171
182
 
@@ -1,6 +1,10 @@
1
1
  """ANSI color constants and utilities."""
2
2
 
3
- # ANSI color codes
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # ANSI color codes (defaults)
4
8
  BLUE = "\033[0;34m"
5
9
  MAGENTA = "\033[0;35m"
6
10
  CYAN = "\033[0;36m"
@@ -11,36 +15,101 @@ BOLD = "\033[1m"
11
15
  DIM = "\033[2m"
12
16
  RESET = "\033[0m"
13
17
 
18
+ # Mapping from color names to ANSI codes
19
+ COLOR_NAMES: dict[str, str] = {
20
+ "black": "\033[0;30m",
21
+ "red": "\033[0;31m",
22
+ "green": "\033[0;32m",
23
+ "yellow": "\033[0;33m",
24
+ "blue": "\033[0;34m",
25
+ "magenta": "\033[0;35m",
26
+ "cyan": "\033[0;36m",
27
+ "white": "\033[0;37m",
28
+ "bright_black": "\033[0;90m",
29
+ "bright_red": "\033[0;91m",
30
+ "bright_green": "\033[0;92m",
31
+ "bright_yellow": "\033[0;93m",
32
+ "bright_blue": "\033[0;94m",
33
+ "bright_magenta": "\033[0;95m",
34
+ "bright_cyan": "\033[0;96m",
35
+ "bright_white": "\033[0;97m",
36
+ }
37
+
38
+ _HEX_RE = re.compile(r"^#([0-9a-fA-F]{6})$")
39
+
40
+
41
+ def parse_color(value: str) -> str | None:
42
+ """Parse a color value into an ANSI escape code.
43
+
44
+ Accepts:
45
+ - Named colors: "red", "green", "bright_cyan", etc.
46
+ - Hex colors: "#ff5733" (converted to 24-bit ANSI)
47
+
48
+ Returns:
49
+ ANSI escape code string, or None if the value is not recognized.
50
+ """
51
+ value = value.strip().lower()
52
+ if not value:
53
+ return None
54
+
55
+ # Named color
56
+ if value in COLOR_NAMES:
57
+ return COLOR_NAMES[value]
58
+
59
+ # Hex color (#rrggbb)
60
+ m = _HEX_RE.match(value)
61
+ if m:
62
+ hex_str = m.group(1)
63
+ r = int(hex_str[0:2], 16)
64
+ g = int(hex_str[2:4], 16)
65
+ b = int(hex_str[4:6], 16)
66
+ return f"\033[38;2;{r};{g};{b}m"
67
+
68
+ return None
69
+
14
70
 
15
71
  class ColorManager:
16
- """Manage color output based on terminal capabilities."""
72
+ """Manage color output based on terminal capabilities.
73
+
74
+ Supports custom color overrides via a dict of {slot_name: ansi_code}.
75
+ """
17
76
 
18
- def __init__(self, enabled: bool = True) -> None:
77
+ def __init__(
78
+ self,
79
+ enabled: bool = True,
80
+ overrides: dict[str, str] | None = None,
81
+ ) -> None:
19
82
  self.enabled = enabled
83
+ self._overrides = overrides or {}
84
+
85
+ def _get(self, slot: str, default: str) -> str:
86
+ if not self.enabled:
87
+ return ""
88
+ return self._overrides.get(slot, default)
20
89
 
21
90
  @property
22
91
  def blue(self) -> str:
23
- return BLUE if self.enabled else ""
92
+ return self._get("blue", BLUE)
24
93
 
25
94
  @property
26
95
  def magenta(self) -> str:
27
- return MAGENTA if self.enabled else ""
96
+ return self._get("magenta", MAGENTA)
28
97
 
29
98
  @property
30
99
  def cyan(self) -> str:
31
- return CYAN if self.enabled else ""
100
+ return self._get("cyan", CYAN)
32
101
 
33
102
  @property
34
103
  def green(self) -> str:
35
- return GREEN if self.enabled else ""
104
+ return self._get("green", GREEN)
36
105
 
37
106
  @property
38
107
  def yellow(self) -> str:
39
- return YELLOW if self.enabled else ""
108
+ return self._get("yellow", YELLOW)
40
109
 
41
110
  @property
42
111
  def red(self) -> str:
43
- return RED if self.enabled else ""
112
+ return self._get("red", RED)
44
113
 
45
114
  @property
46
115
  def bold(self) -> str:
@@ -7,6 +7,18 @@ from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
+ from claude_statusline.core.colors import parse_color
11
+
12
+ # Color config keys and which ColorManager slot they map to
13
+ _COLOR_KEYS: dict[str, str] = {
14
+ "color_green": "green",
15
+ "color_yellow": "yellow",
16
+ "color_red": "red",
17
+ "color_blue": "blue",
18
+ "color_magenta": "magenta",
19
+ "color_cyan": "cyan",
20
+ }
21
+
10
22
 
11
23
  @dataclass
12
24
  class Config:
@@ -18,6 +30,11 @@ class Config:
18
30
  show_session: bool = True
19
31
  show_io_tokens: bool = True
20
32
  reduced_motion: bool = False
33
+ show_mi: bool = True
34
+ mi_curve_beta: float = 1.5
35
+
36
+ # Custom color overrides (slot_name -> ANSI code)
37
+ color_overrides: dict[str, str] = field(default_factory=dict)
21
38
 
22
39
  _config_path: Path = field(default_factory=lambda: Path.home() / ".claude" / "statusline.conf")
23
40
 
@@ -62,10 +79,29 @@ show_session=true
62
79
 
63
80
  # Disable rotating text animations
64
81
  reduced_motion=false
82
+
83
+ # Model Intelligence (MI) score display
84
+ show_mi=true
85
+
86
+ # MI degradation curve shape (higher = steeper initial drop)
87
+ # mi_curve_beta=1.5
88
+
89
+ # Custom colors - use named colors or hex (#rrggbb)
90
+ # Available color slots: color_green, color_yellow, color_red,
91
+ # color_blue, color_magenta, color_cyan
92
+ # Named colors: black, red, green, yellow, blue, magenta, cyan, white,
93
+ # bright_black, bright_red, bright_green, bright_yellow,
94
+ # bright_blue, bright_magenta, bright_cyan, bright_white
95
+ # Examples:
96
+ # color_green=#7dcfff
97
+ # color_yellow=bright_yellow
98
+ # color_red=#f7768e
65
99
  """
66
100
  )
67
101
  except OSError as e:
68
- sys.stderr.write(f"[statusline] warning: failed to create config {self._config_path}: {e}\n")
102
+ sys.stderr.write(
103
+ f"[statusline] warning: failed to create config {self._config_path}: {e}\n"
104
+ )
69
105
 
70
106
  def _read_config(self) -> None:
71
107
  """Read settings from config file."""
@@ -77,22 +113,42 @@ reduced_motion=false
77
113
  continue
78
114
  key, value = line.split("=", 1)
79
115
  key = key.strip()
80
- value = value.strip().lower()
116
+ raw_value = value.strip()
117
+ value_lower = raw_value.lower()
81
118
 
82
119
  if key == "autocompact":
83
- self.autocompact = value != "false"
120
+ self.autocompact = value_lower != "false"
84
121
  elif key == "token_detail":
85
- self.token_detail = value != "false"
122
+ self.token_detail = value_lower != "false"
86
123
  elif key == "show_delta":
87
- self.show_delta = value != "false"
124
+ self.show_delta = value_lower != "false"
88
125
  elif key == "show_session":
89
- self.show_session = value != "false"
126
+ self.show_session = value_lower != "false"
90
127
  elif key == "show_io_tokens":
91
- self.show_io_tokens = value != "false"
128
+ self.show_io_tokens = value_lower != "false"
92
129
  elif key == "reduced_motion":
93
- self.reduced_motion = value != "false"
130
+ self.reduced_motion = value_lower != "false"
131
+ elif key == "show_mi":
132
+ self.show_mi = value_lower != "false"
133
+ elif key == "mi_curve_beta":
134
+ try:
135
+ self.mi_curve_beta = float(raw_value)
136
+ except ValueError:
137
+ pass
138
+ elif key in _COLOR_KEYS:
139
+ slot = _COLOR_KEYS[key]
140
+ ansi = parse_color(raw_value)
141
+ if ansi:
142
+ self.color_overrides[slot] = ansi
143
+ else:
144
+ sys.stderr.write(
145
+ f"[statusline] warning: unrecognized color value "
146
+ f"'{raw_value}' for {key}\n"
147
+ )
94
148
  except (OSError, UnicodeDecodeError) as e:
95
- sys.stderr.write(f"[statusline] warning: failed to read config {self._config_path}: {e}\n")
149
+ sys.stderr.write(
150
+ f"[statusline] warning: failed to read config {self._config_path}: {e}\n"
151
+ )
96
152
 
97
153
  def to_dict(self) -> dict[str, Any]:
98
154
  """Convert config to dictionary."""
@@ -103,4 +159,7 @@ reduced_motion=false
103
159
  "show_session": self.show_session,
104
160
  "show_io_tokens": self.show_io_tokens,
105
161
  "reduced_motion": self.reduced_motion,
162
+ "show_mi": self.show_mi,
163
+ "mi_curve_beta": self.mi_curve_beta,
164
+ "color_overrides": dict(self.color_overrides),
106
165
  }
@@ -5,15 +5,22 @@ from __future__ import annotations
5
5
  import subprocess
6
6
  from pathlib import Path
7
7
 
8
- from claude_statusline.core.colors import CYAN, MAGENTA, RESET
8
+ from claude_statusline.core.colors import CYAN, MAGENTA, RESET, ColorManager
9
9
 
10
10
 
11
- def get_git_info(project_dir: str | Path, colors_enabled: bool = True) -> str:
11
+ def get_git_info(
12
+ project_dir: str | Path,
13
+ colors_enabled: bool = True,
14
+ color_manager: ColorManager | None = None,
15
+ ) -> str:
12
16
  """Get git branch and change count for a directory.
13
17
 
14
18
  Args:
15
19
  project_dir: Path to the project directory
16
- colors_enabled: Whether to include ANSI color codes
20
+ colors_enabled: Whether to include ANSI color codes. Deprecated —
21
+ prefer passing a ColorManager via color_manager instead.
22
+ color_manager: Optional ColorManager for custom colors. If provided,
23
+ colors_enabled is ignored (the manager handles that).
17
24
 
18
25
  Returns:
19
26
  Formatted string with branch and change count, or empty string if not a git repo
@@ -53,8 +60,12 @@ def get_git_info(project_dir: str | Path, colors_enabled: bool = True) -> str:
53
60
  else:
54
61
  changes = len([line for line in result.stdout.split("\n") if line.strip()])
55
62
 
56
- # Format output
57
- if colors_enabled:
63
+ # Format output — use ColorManager if provided, else fallback to constants
64
+ if color_manager is not None:
65
+ magenta = color_manager.magenta
66
+ cyan = color_manager.cyan
67
+ reset = color_manager.reset
68
+ elif colors_enabled:
58
69
  magenta, cyan, reset = MAGENTA, CYAN, RESET
59
70
  else:
60
71
  magenta = cyan = reset = ""