cc-context-stats 1.3.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 (72) hide show
  1. package/.claude/commands/context-stats.md +17 -0
  2. package/.claude/settings.local.json +85 -0
  3. package/.editorconfig +60 -0
  4. package/.eslintrc.json +35 -0
  5. package/.github/dependabot.yml +44 -0
  6. package/.github/workflows/ci.yml +255 -0
  7. package/.github/workflows/release.yml +149 -0
  8. package/.pre-commit-config.yaml +74 -0
  9. package/.prettierrc +33 -0
  10. package/.shellcheckrc +10 -0
  11. package/CHANGELOG.md +100 -0
  12. package/CONTRIBUTING.md +240 -0
  13. package/PUBLISHING_GUIDE.md +69 -0
  14. package/README.md +179 -0
  15. package/config/settings-example.json +7 -0
  16. package/config/settings-node.json +7 -0
  17. package/config/settings-python.json +7 -0
  18. package/docs/configuration.md +83 -0
  19. package/docs/context-stats.md +132 -0
  20. package/docs/installation.md +195 -0
  21. package/docs/scripts.md +116 -0
  22. package/docs/troubleshooting.md +189 -0
  23. package/images/claude-statusline-token-graph.gif +0 -0
  24. package/images/claude-statusline.png +0 -0
  25. package/images/context-status-dumbzone.png +0 -0
  26. package/images/context-status.png +0 -0
  27. package/images/statusline-detail.png +0 -0
  28. package/images/token-graph.jpeg +0 -0
  29. package/images/token-graph.png +0 -0
  30. package/install +344 -0
  31. package/install.sh +272 -0
  32. package/jest.config.js +11 -0
  33. package/npm-publish.sh +33 -0
  34. package/package.json +36 -0
  35. package/publish.sh +24 -0
  36. package/pyproject.toml +113 -0
  37. package/requirements-dev.txt +12 -0
  38. package/scripts/context-stats.sh +970 -0
  39. package/scripts/statusline-full.sh +241 -0
  40. package/scripts/statusline-git.sh +32 -0
  41. package/scripts/statusline-minimal.sh +11 -0
  42. package/scripts/statusline.js +350 -0
  43. package/scripts/statusline.py +312 -0
  44. package/show_raw_claude_code_api.js +11 -0
  45. package/src/claude_statusline/__init__.py +11 -0
  46. package/src/claude_statusline/__main__.py +6 -0
  47. package/src/claude_statusline/cli/__init__.py +1 -0
  48. package/src/claude_statusline/cli/context_stats.py +379 -0
  49. package/src/claude_statusline/cli/statusline.py +172 -0
  50. package/src/claude_statusline/core/__init__.py +1 -0
  51. package/src/claude_statusline/core/colors.py +55 -0
  52. package/src/claude_statusline/core/config.py +98 -0
  53. package/src/claude_statusline/core/git.py +67 -0
  54. package/src/claude_statusline/core/state.py +266 -0
  55. package/src/claude_statusline/formatters/__init__.py +1 -0
  56. package/src/claude_statusline/formatters/time.py +50 -0
  57. package/src/claude_statusline/formatters/tokens.py +70 -0
  58. package/src/claude_statusline/graphs/__init__.py +1 -0
  59. package/src/claude_statusline/graphs/renderer.py +346 -0
  60. package/src/claude_statusline/graphs/statistics.py +58 -0
  61. package/tests/bash/test_install.bats +29 -0
  62. package/tests/bash/test_statusline_full.bats +109 -0
  63. package/tests/bash/test_statusline_git.bats +42 -0
  64. package/tests/bash/test_statusline_minimal.bats +37 -0
  65. package/tests/fixtures/json/high_usage.json +17 -0
  66. package/tests/fixtures/json/low_usage.json +17 -0
  67. package/tests/fixtures/json/medium_usage.json +17 -0
  68. package/tests/fixtures/json/valid_full.json +30 -0
  69. package/tests/fixtures/json/valid_minimal.json +9 -0
  70. package/tests/node/statusline.test.js +199 -0
  71. package/tests/python/conftest.py +84 -0
  72. package/tests/python/test_statusline.py +154 -0
@@ -0,0 +1,67 @@
1
+ """Git integration utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from claude_statusline.core.colors import CYAN, MAGENTA, RESET
9
+
10
+
11
+ def get_git_info(project_dir: str | Path, colors_enabled: bool = True) -> str:
12
+ """Get git branch and change count for a directory.
13
+
14
+ Args:
15
+ project_dir: Path to the project directory
16
+ colors_enabled: Whether to include ANSI color codes
17
+
18
+ Returns:
19
+ Formatted string with branch and change count, or empty string if not a git repo
20
+ """
21
+ project_dir = Path(project_dir)
22
+ git_dir = project_dir / ".git"
23
+
24
+ if not git_dir.is_dir():
25
+ return ""
26
+
27
+ try:
28
+ # Get branch name (skip optional locks for performance)
29
+ result = subprocess.run(
30
+ ["git", "--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"],
31
+ cwd=project_dir,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=5,
35
+ )
36
+ if result.returncode != 0:
37
+ return ""
38
+ branch = result.stdout.strip()
39
+
40
+ if not branch:
41
+ return ""
42
+
43
+ # Count changes
44
+ result = subprocess.run(
45
+ ["git", "--no-optional-locks", "status", "--porcelain"],
46
+ cwd=project_dir,
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=5,
50
+ )
51
+ if result.returncode != 0:
52
+ changes = 0
53
+ else:
54
+ changes = len([line for line in result.stdout.split("\n") if line.strip()])
55
+
56
+ # Format output
57
+ if colors_enabled:
58
+ magenta, cyan, reset = MAGENTA, CYAN, RESET
59
+ else:
60
+ magenta = cyan = reset = ""
61
+
62
+ if changes > 0:
63
+ return f" | {magenta}{branch}{reset} {cyan}[{changes}]{reset}"
64
+ return f" | {magenta}{branch}{reset}"
65
+
66
+ except (subprocess.TimeoutExpired, OSError):
67
+ return ""
@@ -0,0 +1,266 @@
1
+ """State file management for token tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class StateEntry:
12
+ """A single state file entry."""
13
+
14
+ timestamp: int
15
+ total_input_tokens: int
16
+ total_output_tokens: int
17
+ current_input_tokens: int
18
+ current_output_tokens: int
19
+ cache_creation: int
20
+ cache_read: int
21
+ cost_usd: float
22
+ lines_added: int
23
+ lines_removed: int
24
+ session_id: str
25
+ model_id: str
26
+ workspace_project_dir: str
27
+ context_window_size: int
28
+
29
+ @classmethod
30
+ def from_csv_line(cls, line: str) -> StateEntry | None:
31
+ """Parse a CSV line into a StateEntry.
32
+
33
+ Args:
34
+ line: CSV line with comma-separated values
35
+
36
+ Returns:
37
+ StateEntry or None if parsing fails
38
+ """
39
+ parts = line.strip().split(",")
40
+
41
+ # Handle old format (timestamp,tokens) and new format (14 fields)
42
+ if len(parts) < 2:
43
+ return None
44
+
45
+ try:
46
+ timestamp = int(parts[0])
47
+
48
+ # Old format: timestamp,tokens
49
+ if len(parts) == 2:
50
+ tokens = int(parts[1])
51
+ return cls(
52
+ timestamp=timestamp,
53
+ total_input_tokens=tokens,
54
+ total_output_tokens=0,
55
+ current_input_tokens=0,
56
+ current_output_tokens=0,
57
+ cache_creation=0,
58
+ cache_read=0,
59
+ cost_usd=0.0,
60
+ lines_added=0,
61
+ lines_removed=0,
62
+ session_id="",
63
+ model_id="",
64
+ workspace_project_dir="",
65
+ context_window_size=0,
66
+ )
67
+
68
+ # New format with all fields
69
+ def safe_int(val: str, default: int = 0) -> int:
70
+ try:
71
+ return int(val) if val else default
72
+ except ValueError:
73
+ return default
74
+
75
+ def safe_float(val: str, default: float = 0.0) -> float:
76
+ try:
77
+ return float(val) if val else default
78
+ except ValueError:
79
+ return default
80
+
81
+ return cls(
82
+ timestamp=timestamp,
83
+ total_input_tokens=safe_int(parts[1] if len(parts) > 1 else ""),
84
+ total_output_tokens=safe_int(parts[2] if len(parts) > 2 else ""),
85
+ current_input_tokens=safe_int(parts[3] if len(parts) > 3 else ""),
86
+ current_output_tokens=safe_int(parts[4] if len(parts) > 4 else ""),
87
+ cache_creation=safe_int(parts[5] if len(parts) > 5 else ""),
88
+ cache_read=safe_int(parts[6] if len(parts) > 6 else ""),
89
+ cost_usd=safe_float(parts[7] if len(parts) > 7 else ""),
90
+ lines_added=safe_int(parts[8] if len(parts) > 8 else ""),
91
+ lines_removed=safe_int(parts[9] if len(parts) > 9 else ""),
92
+ session_id=parts[10] if len(parts) > 10 else "",
93
+ model_id=parts[11] if len(parts) > 11 else "",
94
+ workspace_project_dir=parts[12] if len(parts) > 12 else "",
95
+ context_window_size=safe_int(parts[13] if len(parts) > 13 else ""),
96
+ )
97
+
98
+ except (ValueError, IndexError):
99
+ return None
100
+
101
+ def to_csv_line(self) -> str:
102
+ """Convert entry to CSV line."""
103
+ return ",".join(
104
+ str(x)
105
+ for x in [
106
+ self.timestamp,
107
+ self.total_input_tokens,
108
+ self.total_output_tokens,
109
+ self.current_input_tokens,
110
+ self.current_output_tokens,
111
+ self.cache_creation,
112
+ self.cache_read,
113
+ self.cost_usd,
114
+ self.lines_added,
115
+ self.lines_removed,
116
+ self.session_id,
117
+ self.model_id,
118
+ self.workspace_project_dir,
119
+ self.context_window_size,
120
+ ]
121
+ )
122
+
123
+ @property
124
+ def total_tokens(self) -> int:
125
+ """Get combined input + output tokens."""
126
+ return self.total_input_tokens + self.total_output_tokens
127
+
128
+ @property
129
+ def current_used_tokens(self) -> int:
130
+ """Get current context usage (input + cache)."""
131
+ return self.current_input_tokens + self.cache_creation + self.cache_read
132
+
133
+
134
+ class StateFile:
135
+ """Manage state files for token tracking."""
136
+
137
+ STATE_DIR = Path.home() / ".claude" / "statusline"
138
+ OLD_STATE_DIR = Path.home() / ".claude"
139
+
140
+ def __init__(self, session_id: str | None = None) -> None:
141
+ """Initialize state file manager.
142
+
143
+ Args:
144
+ session_id: Optional session ID. If not provided, uses latest session.
145
+ """
146
+ self.session_id = session_id
147
+ self._ensure_state_dir()
148
+ self._migrate_old_files()
149
+
150
+ def _ensure_state_dir(self) -> None:
151
+ """Create state directory if it doesn't exist."""
152
+ self.STATE_DIR.mkdir(parents=True, exist_ok=True)
153
+
154
+ def _migrate_old_files(self) -> None:
155
+ """Migrate old state files from ~/.claude/ to ~/.claude/statusline/."""
156
+ for old_file in self.OLD_STATE_DIR.glob("statusline*.state"):
157
+ if old_file.is_file():
158
+ new_file = self.STATE_DIR / old_file.name
159
+ if not new_file.exists():
160
+ try:
161
+ shutil.move(str(old_file), str(new_file))
162
+ except OSError:
163
+ pass
164
+ else:
165
+ try:
166
+ old_file.unlink()
167
+ except OSError:
168
+ pass
169
+
170
+ @property
171
+ def file_path(self) -> Path:
172
+ """Get the state file path for the current session."""
173
+ if self.session_id:
174
+ return self.STATE_DIR / f"statusline.{self.session_id}.state"
175
+ return self.STATE_DIR / "statusline.state"
176
+
177
+ def find_latest_state_file(self) -> Path | None:
178
+ """Find the most recently modified state file.
179
+
180
+ Returns:
181
+ Path to the latest state file, or None if no files exist
182
+ """
183
+ if self.session_id:
184
+ file_path = self.STATE_DIR / f"statusline.{self.session_id}.state"
185
+ return file_path if file_path.exists() else None
186
+
187
+ # Find most recent state file by modification time
188
+ state_files = list(self.STATE_DIR.glob("statusline.*.state"))
189
+ if not state_files:
190
+ # Try default state file
191
+ default = self.STATE_DIR / "statusline.state"
192
+ return default if default.exists() else None
193
+
194
+ return max(state_files, key=lambda f: f.stat().st_mtime)
195
+
196
+ def read_history(self) -> list[StateEntry]:
197
+ """Read all entries from the state file.
198
+
199
+ Returns:
200
+ List of StateEntry objects
201
+ """
202
+ file_path = self.find_latest_state_file()
203
+ if not file_path or not file_path.exists():
204
+ return []
205
+
206
+ entries = []
207
+ try:
208
+ content = file_path.read_text()
209
+ for line in content.splitlines():
210
+ if line.strip():
211
+ entry = StateEntry.from_csv_line(line)
212
+ if entry:
213
+ entries.append(entry)
214
+ except OSError:
215
+ pass
216
+
217
+ return entries
218
+
219
+ def read_last_entry(self) -> StateEntry | None:
220
+ """Read only the last entry from the state file.
221
+
222
+ Returns:
223
+ The last StateEntry or None if file is empty/missing
224
+ """
225
+ # Use file_path for specific session, find_latest for unspecified session
226
+ file_path = self.file_path if self.session_id else self.find_latest_state_file()
227
+ if not file_path or not file_path.exists():
228
+ return None
229
+
230
+ try:
231
+ content = file_path.read_text()
232
+ lines = content.splitlines()
233
+ for line in reversed(lines):
234
+ if line.strip():
235
+ return StateEntry.from_csv_line(line)
236
+ except OSError:
237
+ pass
238
+
239
+ return None
240
+
241
+ def append_entry(self, entry: StateEntry) -> None:
242
+ """Append an entry to the state file.
243
+
244
+ Args:
245
+ entry: StateEntry to append
246
+ """
247
+ try:
248
+ with open(self.file_path, "a") as f:
249
+ f.write(f"{entry.to_csv_line()}\n")
250
+ except OSError:
251
+ pass
252
+
253
+ def list_sessions(self) -> list[str]:
254
+ """List all available session IDs.
255
+
256
+ Returns:
257
+ List of session ID strings
258
+ """
259
+ sessions = []
260
+ for file_path in self.STATE_DIR.glob("statusline.*.state"):
261
+ name = file_path.stem # statusline.{session_id}
262
+ if name.startswith("statusline."):
263
+ session_id = name[11:] # Remove "statusline." prefix
264
+ if session_id:
265
+ sessions.append(session_id)
266
+ return sessions
@@ -0,0 +1 @@
1
+ """Formatting utilities for claude-statusline."""
@@ -0,0 +1,50 @@
1
+ """Time and duration formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime
7
+
8
+
9
+ def format_timestamp(ts: int) -> str:
10
+ """Format Unix timestamp as time string.
11
+
12
+ Args:
13
+ ts: Unix timestamp (seconds since epoch)
14
+
15
+ Returns:
16
+ Formatted time string like "14:30"
17
+ """
18
+ try:
19
+ return datetime.fromtimestamp(ts).strftime("%H:%M")
20
+ except (ValueError, OSError):
21
+ return str(ts)
22
+
23
+
24
+ def format_duration(seconds: int) -> str:
25
+ """Format duration in seconds as human-readable string.
26
+
27
+ Args:
28
+ seconds: Duration in seconds
29
+
30
+ Returns:
31
+ Formatted string like "2h 30m" or "45m" or "30s"
32
+ """
33
+ hours = seconds // 3600
34
+ minutes = (seconds % 3600) // 60
35
+
36
+ if hours > 0:
37
+ return f"{hours}h {minutes}m"
38
+ elif minutes > 0:
39
+ return f"{minutes}m"
40
+ else:
41
+ return f"{seconds}s"
42
+
43
+
44
+ def get_current_timestamp() -> int:
45
+ """Get current Unix timestamp.
46
+
47
+ Returns:
48
+ Current time as Unix timestamp
49
+ """
50
+ return int(time.time())
@@ -0,0 +1,70 @@
1
+ """Token formatting utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def format_tokens(count: int, detail: bool = True) -> str:
7
+ """Format token count for display.
8
+
9
+ Args:
10
+ count: Number of tokens
11
+ detail: If True, show exact count with commas. If False, use abbreviated format.
12
+
13
+ Returns:
14
+ Formatted string like "64,000" or "64.0k"
15
+ """
16
+ if detail:
17
+ return f"{count:,}"
18
+ else:
19
+ if count >= 1_000_000:
20
+ return f"{count / 1_000_000:.1f}M"
21
+ elif count >= 1_000:
22
+ return f"{count / 1_000:.1f}k"
23
+ else:
24
+ return str(count)
25
+
26
+
27
+ def format_percentage(value: float, decimals: int = 1) -> str:
28
+ """Format a percentage value.
29
+
30
+ Args:
31
+ value: Percentage value (0-100)
32
+ decimals: Number of decimal places
33
+
34
+ Returns:
35
+ Formatted percentage string like "75.5%"
36
+ """
37
+ return f"{value:.{decimals}f}%"
38
+
39
+
40
+ def calculate_context_usage(
41
+ used_tokens: int,
42
+ total_size: int,
43
+ autocompact_enabled: bool = True,
44
+ autocompact_ratio: float = 0.225,
45
+ ) -> tuple[int, float, int]:
46
+ """Calculate context window usage statistics.
47
+
48
+ Args:
49
+ used_tokens: Number of tokens currently used
50
+ total_size: Total context window size
51
+ autocompact_enabled: Whether autocompact is enabled
52
+ autocompact_ratio: Ratio of context window reserved for autocompact (default 22.5%)
53
+
54
+ Returns:
55
+ Tuple of (free_tokens, free_percentage, autocompact_buffer)
56
+ """
57
+ if total_size <= 0:
58
+ return 0, 0.0, 0
59
+
60
+ autocompact_buffer = int(total_size * autocompact_ratio)
61
+
62
+ if autocompact_enabled:
63
+ free_tokens = total_size - used_tokens - autocompact_buffer
64
+ else:
65
+ free_tokens = total_size - used_tokens
66
+
67
+ free_tokens = max(0, free_tokens)
68
+ free_pct = (free_tokens * 100.0) / total_size
69
+
70
+ return free_tokens, free_pct, autocompact_buffer
@@ -0,0 +1 @@
1
+ """Graph rendering utilities for token visualization."""