cc-context-stats 1.8.0 → 1.8.2

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 (106) hide show
  1. package/package.json +8 -1
  2. package/scripts/context-stats.sh +1 -1
  3. package/.editorconfig +0 -60
  4. package/.eslintrc.json +0 -35
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  7. package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
  8. package/.github/dependabot.yml +0 -44
  9. package/.github/workflows/ci.yml +0 -294
  10. package/.github/workflows/release.yml +0 -151
  11. package/.pre-commit-config.yaml +0 -74
  12. package/.prettierrc +0 -33
  13. package/.shellcheckrc +0 -10
  14. package/CHANGELOG.md +0 -187
  15. package/CLAUDE.md +0 -66
  16. package/CODE_OF_CONDUCT.md +0 -59
  17. package/CONTRIBUTING.md +0 -240
  18. package/RELEASE_NOTES.md +0 -19
  19. package/SECURITY.md +0 -44
  20. package/TODOS.md +0 -72
  21. package/assets/logo/favicon.svg +0 -19
  22. package/assets/logo/logo-black.svg +0 -24
  23. package/assets/logo/logo-full.svg +0 -40
  24. package/assets/logo/logo-icon.svg +0 -27
  25. package/assets/logo/logo-mark.svg +0 -28
  26. package/assets/logo/logo-white.svg +0 -24
  27. package/assets/logo/logo-wordmark.svg +0 -6
  28. package/config/settings-example.json +0 -7
  29. package/config/settings-node.json +0 -7
  30. package/config/settings-python.json +0 -7
  31. package/docs/ARCHITECTURE.md +0 -128
  32. package/docs/CSV_FORMAT.md +0 -42
  33. package/docs/DEPLOYMENT.md +0 -71
  34. package/docs/DEVELOPMENT.md +0 -161
  35. package/docs/MODEL_INTELLIGENCE.md +0 -396
  36. package/docs/configuration.md +0 -118
  37. package/docs/context-stats.md +0 -143
  38. package/docs/installation.md +0 -255
  39. package/docs/scripts.md +0 -140
  40. package/docs/troubleshooting.md +0 -278
  41. package/images/claude-statusline-token-graph.gif +0 -0
  42. package/images/claude-statusline.png +0 -0
  43. package/images/context-status-dumbzone.png +0 -0
  44. package/images/context-status.png +0 -0
  45. package/images/statusline-detail.png +0 -0
  46. package/images/token-graph.jpeg +0 -0
  47. package/images/token-graph.png +0 -0
  48. package/images/v1.6.1.png +0 -0
  49. package/install +0 -351
  50. package/install.sh +0 -298
  51. package/jest.config.js +0 -11
  52. package/pyproject.toml +0 -115
  53. package/requirements-dev.txt +0 -12
  54. package/scripts/statusline-full.sh +0 -438
  55. package/scripts/statusline-git.sh +0 -88
  56. package/scripts/statusline-minimal.sh +0 -67
  57. package/scripts/statusline.py +0 -569
  58. package/src/claude_statusline/__init__.py +0 -11
  59. package/src/claude_statusline/__main__.py +0 -6
  60. package/src/claude_statusline/cli/__init__.py +0 -1
  61. package/src/claude_statusline/cli/context_stats.py +0 -542
  62. package/src/claude_statusline/cli/explain.py +0 -228
  63. package/src/claude_statusline/cli/statusline.py +0 -184
  64. package/src/claude_statusline/core/__init__.py +0 -1
  65. package/src/claude_statusline/core/colors.py +0 -124
  66. package/src/claude_statusline/core/config.py +0 -165
  67. package/src/claude_statusline/core/git.py +0 -78
  68. package/src/claude_statusline/core/state.py +0 -323
  69. package/src/claude_statusline/formatters/__init__.py +0 -1
  70. package/src/claude_statusline/formatters/layout.py +0 -67
  71. package/src/claude_statusline/formatters/time.py +0 -50
  72. package/src/claude_statusline/formatters/tokens.py +0 -70
  73. package/src/claude_statusline/graphs/__init__.py +0 -1
  74. package/src/claude_statusline/graphs/intelligence.py +0 -162
  75. package/src/claude_statusline/graphs/renderer.py +0 -401
  76. package/src/claude_statusline/graphs/statistics.py +0 -92
  77. package/src/claude_statusline/ui/__init__.py +0 -1
  78. package/src/claude_statusline/ui/icons.py +0 -93
  79. package/src/claude_statusline/ui/waiting.py +0 -62
  80. package/tests/bash/test_delta_parity.bats +0 -199
  81. package/tests/bash/test_install.bats +0 -29
  82. package/tests/bash/test_parity.bats +0 -315
  83. package/tests/bash/test_statusline_full.bats +0 -139
  84. package/tests/bash/test_statusline_git.bats +0 -42
  85. package/tests/bash/test_statusline_minimal.bats +0 -37
  86. package/tests/fixtures/json/comma_in_path.json +0 -31
  87. package/tests/fixtures/json/high_usage.json +0 -17
  88. package/tests/fixtures/json/low_usage.json +0 -17
  89. package/tests/fixtures/json/medium_usage.json +0 -17
  90. package/tests/fixtures/json/valid_full.json +0 -30
  91. package/tests/fixtures/json/valid_minimal.json +0 -9
  92. package/tests/fixtures/mi_test_vectors.json +0 -140
  93. package/tests/node/intelligence.test.js +0 -98
  94. package/tests/node/rotation.test.js +0 -89
  95. package/tests/node/statusline.test.js +0 -240
  96. package/tests/python/conftest.py +0 -84
  97. package/tests/python/test_colors.py +0 -105
  98. package/tests/python/test_config_colors.py +0 -78
  99. package/tests/python/test_data_pipeline.py +0 -446
  100. package/tests/python/test_explain.py +0 -177
  101. package/tests/python/test_icons.py +0 -152
  102. package/tests/python/test_intelligence.py +0 -314
  103. package/tests/python/test_layout.py +0 -127
  104. package/tests/python/test_state_rotation_validation.py +0 -232
  105. package/tests/python/test_statusline.py +0 -215
  106. package/tests/python/test_waiting.py +0 -127
@@ -1,228 +0,0 @@
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)
@@ -1,184 +0,0 @@
1
- #!/usr/bin/env python3
2
- """CLI entry point for claude-statusline command.
3
-
4
- Usage: Copy to ~/.claude/statusline.py and make executable, or install via pip.
5
-
6
- Configuration:
7
- Create/edit ~/.claude/statusline.conf and set:
8
-
9
- autocompact=true (when autocompact is enabled in Claude Code - default)
10
- autocompact=false (when you disable autocompact via /config in Claude Code)
11
-
12
- token_detail=true (show exact token count like 64,000 - default)
13
- token_detail=false (show abbreviated tokens like 64.0k)
14
-
15
- show_delta=true (show token delta since last refresh like [+2,500] - default)
16
- show_delta=false (disable delta display - saves file I/O on every refresh)
17
-
18
- show_session=true (show session_id in status line - default)
19
- show_session=false (hide session_id from status line)
20
-
21
- When AC is enabled, 22.5% of context window is reserved for autocompact buffer.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import json
27
- import sys
28
-
29
- from claude_statusline.core.colors import ColorManager
30
- from claude_statusline.core.config import Config
31
- from claude_statusline.core.git import get_git_info
32
- from claude_statusline.core.state import StateEntry, StateFile
33
- from claude_statusline.formatters.layout import fit_to_width, get_terminal_width
34
- from claude_statusline.formatters.time import get_current_timestamp
35
- from claude_statusline.formatters.tokens import calculate_context_usage, format_tokens
36
-
37
-
38
- def main() -> None:
39
- """Main entry point for claude-statusline CLI."""
40
- try:
41
- data = json.load(sys.stdin)
42
- except json.JSONDecodeError:
43
- print("[Claude] ~")
44
- return
45
-
46
- # Extract data
47
- cwd = data.get("workspace", {}).get("current_dir", "~")
48
- project_dir = data.get("workspace", {}).get("project_dir", cwd)
49
- model = data.get("model", {}).get("display_name", "Claude")
50
- dir_name = cwd.rsplit("/", 1)[-1] if "/" in cwd else cwd or "~"
51
-
52
- # Read settings from config file
53
- config = Config.load()
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
-
61
- # Extract session_id once for reuse
62
- session_id = data.get("session_id")
63
-
64
- # Context window calculation
65
- context_info = ""
66
- ac_info = ""
67
- delta_info = ""
68
- mi_info = ""
69
- session_info = ""
70
-
71
- total_size = data.get("context_window", {}).get("context_window_size", 0)
72
- current_usage = data.get("context_window", {}).get("current_usage")
73
- total_input_tokens = data.get("context_window", {}).get("total_input_tokens", 0)
74
- total_output_tokens = data.get("context_window", {}).get("total_output_tokens", 0)
75
- cost_usd = data.get("cost", {}).get("total_cost_usd", 0)
76
- lines_added = data.get("cost", {}).get("total_lines_added", 0)
77
- lines_removed = data.get("cost", {}).get("total_lines_removed", 0)
78
- model_id = data.get("model", {}).get("id", "")
79
- workspace_project_dir = data.get("workspace", {}).get("project_dir", "")
80
-
81
- if total_size > 0 and current_usage:
82
- # Get tokens from current_usage (includes cache)
83
- input_tokens = current_usage.get("input_tokens", 0)
84
- cache_creation = current_usage.get("cache_creation_input_tokens", 0)
85
- cache_read = current_usage.get("cache_read_input_tokens", 0)
86
-
87
- # Total used from current request
88
- used_tokens = input_tokens + cache_creation + cache_read
89
-
90
- # Calculate context usage
91
- free_tokens, free_pct, autocompact_buffer = calculate_context_usage(
92
- used_tokens,
93
- total_size,
94
- config.autocompact,
95
- )
96
-
97
- if config.autocompact:
98
- buffer_k = autocompact_buffer // 1000
99
- ac_info = f" {colors.dim}[AC:{buffer_k}k]{colors.reset}"
100
- else:
101
- ac_info = f" {colors.dim}[AC:off]{colors.reset}"
102
-
103
- # Format tokens based on token_detail setting
104
- free_display = format_tokens(free_tokens, config.token_detail)
105
-
106
- # Color based on free percentage
107
- free_pct_int = int(free_pct)
108
- if free_pct_int > 50:
109
- ctx_color = colors.green
110
- elif free_pct_int > 25:
111
- ctx_color = colors.yellow
112
- else:
113
- ctx_color = colors.red
114
-
115
- context_info = f" | {ctx_color}{free_display} ({free_pct:.1f}%){colors.reset}"
116
-
117
- # Read previous entry if needed for delta OR MI
118
- if config.show_delta or config.show_mi:
119
- state_file = StateFile(session_id)
120
- prev_entry = state_file.read_last_entry()
121
-
122
- prev_tokens = prev_entry.current_used_tokens if prev_entry else 0
123
- has_prev = prev_entry is not None
124
-
125
- # Build current entry
126
- cur_input_tokens = current_usage.get("input_tokens", 0)
127
- cur_output_tokens = current_usage.get("output_tokens", 0)
128
-
129
- entry = StateEntry(
130
- timestamp=get_current_timestamp(),
131
- total_input_tokens=total_input_tokens,
132
- total_output_tokens=total_output_tokens,
133
- current_input_tokens=cur_input_tokens,
134
- current_output_tokens=cur_output_tokens,
135
- cache_creation=cache_creation,
136
- cache_read=cache_read,
137
- cost_usd=cost_usd,
138
- lines_added=lines_added,
139
- lines_removed=lines_removed,
140
- session_id=session_id or "",
141
- model_id=model_id,
142
- workspace_project_dir=workspace_project_dir,
143
- context_window_size=total_size,
144
- )
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
-
168
- # Only append if context usage changed (avoid duplicates from multiple refreshes)
169
- if not has_prev or used_tokens != prev_tokens:
170
- state_file.append_entry(entry)
171
-
172
- # Display session_id if enabled
173
- if config.show_session and session_id:
174
- session_info = f" {colors.dim}{session_id}{colors.reset}"
175
-
176
- # Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [session_id]
177
- base = f"{colors.dim}[{model}]{colors.reset} {colors.blue}{dir_name}{colors.reset}"
178
- max_width = get_terminal_width()
179
- parts = [base, git_info, context_info, delta_info, mi_info, ac_info, session_info]
180
- print(fit_to_width(parts, max_width))
181
-
182
-
183
- if __name__ == "__main__":
184
- main()
@@ -1 +0,0 @@
1
- """Core functionality for claude-statusline."""
@@ -1,124 +0,0 @@
1
- """ANSI color constants and utilities."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
-
7
- # ANSI color codes (defaults)
8
- BLUE = "\033[0;34m"
9
- MAGENTA = "\033[0;35m"
10
- CYAN = "\033[0;36m"
11
- GREEN = "\033[0;32m"
12
- YELLOW = "\033[0;33m"
13
- RED = "\033[0;31m"
14
- BOLD = "\033[1m"
15
- DIM = "\033[2m"
16
- RESET = "\033[0m"
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
-
70
-
71
- class ColorManager:
72
- """Manage color output based on terminal capabilities.
73
-
74
- Supports custom color overrides via a dict of {slot_name: ansi_code}.
75
- """
76
-
77
- def __init__(
78
- self,
79
- enabled: bool = True,
80
- overrides: dict[str, str] | None = None,
81
- ) -> None:
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)
89
-
90
- @property
91
- def blue(self) -> str:
92
- return self._get("blue", BLUE)
93
-
94
- @property
95
- def magenta(self) -> str:
96
- return self._get("magenta", MAGENTA)
97
-
98
- @property
99
- def cyan(self) -> str:
100
- return self._get("cyan", CYAN)
101
-
102
- @property
103
- def green(self) -> str:
104
- return self._get("green", GREEN)
105
-
106
- @property
107
- def yellow(self) -> str:
108
- return self._get("yellow", YELLOW)
109
-
110
- @property
111
- def red(self) -> str:
112
- return self._get("red", RED)
113
-
114
- @property
115
- def bold(self) -> str:
116
- return BOLD if self.enabled else ""
117
-
118
- @property
119
- def dim(self) -> str:
120
- return DIM if self.enabled else ""
121
-
122
- @property
123
- def reset(self) -> str:
124
- return RESET if self.enabled else ""