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.
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +12 -0
- package/README.md +34 -24
- package/docs/ARCHITECTURE.md +52 -25
- package/docs/CSV_FORMAT.md +2 -0
- package/docs/DEPLOYMENT.md +19 -8
- package/docs/DEVELOPMENT.md +48 -12
- package/docs/MODEL_INTELLIGENCE.md +396 -0
- package/docs/configuration.md +35 -0
- package/docs/context-stats.md +12 -1
- package/docs/installation.md +82 -22
- package/docs/scripts.md +47 -23
- package/docs/troubleshooting.md +93 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +171 -37
- package/scripts/statusline.js +214 -32
- package/scripts/statusline.py +195 -47
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +85 -13
- package/src/claude_statusline/cli/explain.py +228 -0
- package/src/claude_statusline/cli/statusline.py +41 -30
- package/src/claude_statusline/core/colors.py +78 -9
- package/src/claude_statusline/core/config.py +68 -9
- package/src/claude_statusline/core/git.py +16 -5
- package/src/claude_statusline/graphs/intelligence.py +162 -0
- package/src/claude_statusline/graphs/renderer.py +38 -3
- package/tests/bash/test_statusline_full.bats +5 -5
- package/tests/fixtures/mi_test_vectors.json +140 -0
- package/tests/node/intelligence.test.js +98 -0
- package/tests/node/statusline.test.js +4 -4
- package/tests/python/test_colors.py +105 -0
- package/tests/python/test_config_colors.py +78 -0
- package/tests/python/test_explain.py +177 -0
- package/tests/python/test_intelligence.py +314 -0
- package/tests/python/test_layout.py +4 -4
- 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" {
|
|
99
|
+
ac_info = f" {colors.dim}[AC:{buffer_k}k]{colors.reset}"
|
|
103
100
|
else:
|
|
104
|
-
ac_info = f" {
|
|
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 =
|
|
109
|
+
ctx_color = colors.green
|
|
113
110
|
elif free_pct_int > 25:
|
|
114
|
-
ctx_color =
|
|
111
|
+
ctx_color = colors.yellow
|
|
115
112
|
else:
|
|
116
|
-
ctx_color =
|
|
113
|
+
ctx_color = colors.red
|
|
117
114
|
|
|
118
|
-
context_info = f" | {ctx_color}{free_display}
|
|
115
|
+
context_info = f" | {ctx_color}{free_display} ({free_pct:.1f}%){colors.reset}"
|
|
119
116
|
|
|
120
|
-
#
|
|
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" {
|
|
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"{
|
|
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
|
-
|
|
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__(
|
|
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
|
|
92
|
+
return self._get("blue", BLUE)
|
|
24
93
|
|
|
25
94
|
@property
|
|
26
95
|
def magenta(self) -> str:
|
|
27
|
-
return
|
|
96
|
+
return self._get("magenta", MAGENTA)
|
|
28
97
|
|
|
29
98
|
@property
|
|
30
99
|
def cyan(self) -> str:
|
|
31
|
-
return
|
|
100
|
+
return self._get("cyan", CYAN)
|
|
32
101
|
|
|
33
102
|
@property
|
|
34
103
|
def green(self) -> str:
|
|
35
|
-
return
|
|
104
|
+
return self._get("green", GREEN)
|
|
36
105
|
|
|
37
106
|
@property
|
|
38
107
|
def yellow(self) -> str:
|
|
39
|
-
return
|
|
108
|
+
return self._get("yellow", YELLOW)
|
|
40
109
|
|
|
41
110
|
@property
|
|
42
111
|
def red(self) -> str:
|
|
43
|
-
return
|
|
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(
|
|
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
|
-
|
|
116
|
+
raw_value = value.strip()
|
|
117
|
+
value_lower = raw_value.lower()
|
|
81
118
|
|
|
82
119
|
if key == "autocompact":
|
|
83
|
-
self.autocompact =
|
|
120
|
+
self.autocompact = value_lower != "false"
|
|
84
121
|
elif key == "token_detail":
|
|
85
|
-
self.token_detail =
|
|
122
|
+
self.token_detail = value_lower != "false"
|
|
86
123
|
elif key == "show_delta":
|
|
87
|
-
self.show_delta =
|
|
124
|
+
self.show_delta = value_lower != "false"
|
|
88
125
|
elif key == "show_session":
|
|
89
|
-
self.show_session =
|
|
126
|
+
self.show_session = value_lower != "false"
|
|
90
127
|
elif key == "show_io_tokens":
|
|
91
|
-
self.show_io_tokens =
|
|
128
|
+
self.show_io_tokens = value_lower != "false"
|
|
92
129
|
elif key == "reduced_motion":
|
|
93
|
-
self.reduced_motion =
|
|
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(
|
|
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(
|
|
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
|
|
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 = ""
|