cc-context-stats 1.7.0 → 1.8.1
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/package.json +9 -1
- package/scripts/context-stats.sh +1 -1
- package/scripts/statusline.js +128 -18
- package/.editorconfig +0 -60
- package/.eslintrc.json +0 -35
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
- package/.github/dependabot.yml +0 -44
- package/.github/workflows/ci.yml +0 -294
- package/.github/workflows/release.yml +0 -151
- package/.pre-commit-config.yaml +0 -74
- package/.prettierrc +0 -33
- package/.shellcheckrc +0 -10
- package/CHANGELOG.md +0 -163
- package/CLAUDE.md +0 -66
- package/CODE_OF_CONDUCT.md +0 -59
- package/CONTRIBUTING.md +0 -240
- package/RELEASE_NOTES.md +0 -19
- package/SECURITY.md +0 -44
- package/TODOS.md +0 -72
- package/assets/logo/favicon.svg +0 -19
- package/assets/logo/logo-black.svg +0 -24
- package/assets/logo/logo-full.svg +0 -40
- package/assets/logo/logo-icon.svg +0 -27
- package/assets/logo/logo-mark.svg +0 -28
- package/assets/logo/logo-white.svg +0 -24
- package/assets/logo/logo-wordmark.svg +0 -6
- package/config/settings-example.json +0 -7
- package/config/settings-node.json +0 -7
- package/config/settings-python.json +0 -7
- package/docs/ARCHITECTURE.md +0 -128
- package/docs/CSV_FORMAT.md +0 -42
- package/docs/DEPLOYMENT.md +0 -71
- package/docs/DEVELOPMENT.md +0 -161
- package/docs/configuration.md +0 -118
- package/docs/context-stats.md +0 -143
- package/docs/installation.md +0 -255
- package/docs/scripts.md +0 -140
- package/docs/troubleshooting.md +0 -278
- package/images/claude-statusline-token-graph.gif +0 -0
- package/images/claude-statusline.png +0 -0
- package/images/context-status-dumbzone.png +0 -0
- package/images/context-status.png +0 -0
- package/images/statusline-detail.png +0 -0
- package/images/token-graph.jpeg +0 -0
- package/images/token-graph.png +0 -0
- package/images/v1.6.1.png +0 -0
- package/install +0 -351
- package/install.sh +0 -298
- package/jest.config.js +0 -11
- package/pyproject.toml +0 -115
- package/requirements-dev.txt +0 -12
- package/scripts/statusline-full.sh +0 -304
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -485
- package/src/claude_statusline/__init__.py +0 -11
- package/src/claude_statusline/__main__.py +0 -6
- package/src/claude_statusline/cli/__init__.py +0 -1
- package/src/claude_statusline/cli/context_stats.py +0 -512
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -169
- package/src/claude_statusline/core/__init__.py +0 -1
- package/src/claude_statusline/core/colors.py +0 -124
- package/src/claude_statusline/core/config.py +0 -148
- package/src/claude_statusline/core/git.py +0 -78
- package/src/claude_statusline/core/state.py +0 -323
- package/src/claude_statusline/formatters/__init__.py +0 -1
- package/src/claude_statusline/formatters/layout.py +0 -67
- package/src/claude_statusline/formatters/time.py +0 -50
- package/src/claude_statusline/formatters/tokens.py +0 -70
- package/src/claude_statusline/graphs/__init__.py +0 -1
- package/src/claude_statusline/graphs/renderer.py +0 -366
- package/src/claude_statusline/graphs/statistics.py +0 -92
- package/src/claude_statusline/ui/__init__.py +0 -1
- package/src/claude_statusline/ui/icons.py +0 -93
- package/src/claude_statusline/ui/waiting.py +0 -62
- package/tests/bash/test_delta_parity.bats +0 -199
- package/tests/bash/test_install.bats +0 -29
- package/tests/bash/test_parity.bats +0 -315
- package/tests/bash/test_statusline_full.bats +0 -139
- package/tests/bash/test_statusline_git.bats +0 -42
- package/tests/bash/test_statusline_minimal.bats +0 -37
- package/tests/fixtures/json/comma_in_path.json +0 -31
- package/tests/fixtures/json/high_usage.json +0 -17
- package/tests/fixtures/json/low_usage.json +0 -17
- package/tests/fixtures/json/medium_usage.json +0 -17
- package/tests/fixtures/json/valid_full.json +0 -30
- package/tests/fixtures/json/valid_minimal.json +0 -9
- package/tests/node/rotation.test.js +0 -89
- package/tests/node/statusline.test.js +0 -240
- package/tests/python/conftest.py +0 -84
- package/tests/python/test_colors.py +0 -105
- package/tests/python/test_config_colors.py +0 -78
- package/tests/python/test_data_pipeline.py +0 -446
- package/tests/python/test_explain.py +0 -177
- package/tests/python/test_icons.py +0 -152
- package/tests/python/test_layout.py +0 -127
- package/tests/python/test_state_rotation_validation.py +0 -232
- package/tests/python/test_statusline.py +0 -215
- package/tests/python/test_waiting.py +0 -127
|
@@ -1,78 +0,0 @@
|
|
|
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, ColorManager
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def get_git_info(
|
|
12
|
-
project_dir: str | Path,
|
|
13
|
-
colors_enabled: bool = True,
|
|
14
|
-
color_manager: ColorManager | None = None,
|
|
15
|
-
) -> str:
|
|
16
|
-
"""Get git branch and change count for a directory.
|
|
17
|
-
|
|
18
|
-
Args:
|
|
19
|
-
project_dir: Path to the project directory
|
|
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).
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
Formatted string with branch and change count, or empty string if not a git repo
|
|
27
|
-
"""
|
|
28
|
-
project_dir = Path(project_dir)
|
|
29
|
-
git_dir = project_dir / ".git"
|
|
30
|
-
|
|
31
|
-
if not git_dir.is_dir():
|
|
32
|
-
return ""
|
|
33
|
-
|
|
34
|
-
try:
|
|
35
|
-
# Get branch name (skip optional locks for performance)
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
["git", "--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
38
|
-
cwd=project_dir,
|
|
39
|
-
capture_output=True,
|
|
40
|
-
text=True,
|
|
41
|
-
timeout=5,
|
|
42
|
-
)
|
|
43
|
-
if result.returncode != 0:
|
|
44
|
-
return ""
|
|
45
|
-
branch = result.stdout.strip()
|
|
46
|
-
|
|
47
|
-
if not branch:
|
|
48
|
-
return ""
|
|
49
|
-
|
|
50
|
-
# Count changes
|
|
51
|
-
result = subprocess.run(
|
|
52
|
-
["git", "--no-optional-locks", "status", "--porcelain"],
|
|
53
|
-
cwd=project_dir,
|
|
54
|
-
capture_output=True,
|
|
55
|
-
text=True,
|
|
56
|
-
timeout=5,
|
|
57
|
-
)
|
|
58
|
-
if result.returncode != 0:
|
|
59
|
-
changes = 0
|
|
60
|
-
else:
|
|
61
|
-
changes = len([line for line in result.stdout.split("\n") if line.strip()])
|
|
62
|
-
|
|
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:
|
|
69
|
-
magenta, cyan, reset = MAGENTA, CYAN, RESET
|
|
70
|
-
else:
|
|
71
|
-
magenta = cyan = reset = ""
|
|
72
|
-
|
|
73
|
-
if changes > 0:
|
|
74
|
-
return f" | {magenta}{branch}{reset} {cyan}[{changes}]{reset}"
|
|
75
|
-
return f" | {magenta}{branch}{reset}"
|
|
76
|
-
|
|
77
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
78
|
-
return ""
|
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
"""State file management for token tracking."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import shutil
|
|
7
|
-
import sys
|
|
8
|
-
import tempfile
|
|
9
|
-
from dataclasses import dataclass
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class StateEntry:
|
|
15
|
-
"""A single state file entry."""
|
|
16
|
-
|
|
17
|
-
timestamp: int
|
|
18
|
-
total_input_tokens: int
|
|
19
|
-
total_output_tokens: int
|
|
20
|
-
current_input_tokens: int
|
|
21
|
-
current_output_tokens: int
|
|
22
|
-
cache_creation: int
|
|
23
|
-
cache_read: int
|
|
24
|
-
cost_usd: float
|
|
25
|
-
lines_added: int
|
|
26
|
-
lines_removed: int
|
|
27
|
-
session_id: str
|
|
28
|
-
model_id: str
|
|
29
|
-
workspace_project_dir: str
|
|
30
|
-
context_window_size: int
|
|
31
|
-
|
|
32
|
-
@classmethod
|
|
33
|
-
def from_csv_line(cls, line: str) -> StateEntry | None:
|
|
34
|
-
"""Parse a CSV line into a StateEntry.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
line: CSV line with comma-separated values
|
|
38
|
-
|
|
39
|
-
Returns:
|
|
40
|
-
StateEntry or None if parsing fails
|
|
41
|
-
"""
|
|
42
|
-
parts = line.strip().split(",")
|
|
43
|
-
|
|
44
|
-
# Handle old format (timestamp,tokens) and new format (14 fields)
|
|
45
|
-
if len(parts) < 2:
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
try:
|
|
49
|
-
timestamp = int(parts[0])
|
|
50
|
-
|
|
51
|
-
# Old format: timestamp,tokens
|
|
52
|
-
if len(parts) == 2:
|
|
53
|
-
tokens = int(parts[1])
|
|
54
|
-
return cls(
|
|
55
|
-
timestamp=timestamp,
|
|
56
|
-
total_input_tokens=tokens,
|
|
57
|
-
total_output_tokens=0,
|
|
58
|
-
current_input_tokens=0,
|
|
59
|
-
current_output_tokens=0,
|
|
60
|
-
cache_creation=0,
|
|
61
|
-
cache_read=0,
|
|
62
|
-
cost_usd=0.0,
|
|
63
|
-
lines_added=0,
|
|
64
|
-
lines_removed=0,
|
|
65
|
-
session_id="",
|
|
66
|
-
model_id="",
|
|
67
|
-
workspace_project_dir="",
|
|
68
|
-
context_window_size=0,
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
# New format with all fields
|
|
72
|
-
def safe_int(val: str, default: int = 0) -> int:
|
|
73
|
-
try:
|
|
74
|
-
return int(val) if val else default
|
|
75
|
-
except ValueError:
|
|
76
|
-
return default
|
|
77
|
-
|
|
78
|
-
def safe_float(val: str, default: float = 0.0) -> float:
|
|
79
|
-
try:
|
|
80
|
-
return float(val) if val else default
|
|
81
|
-
except ValueError:
|
|
82
|
-
return default
|
|
83
|
-
|
|
84
|
-
return cls(
|
|
85
|
-
timestamp=timestamp,
|
|
86
|
-
total_input_tokens=safe_int(parts[1] if len(parts) > 1 else ""),
|
|
87
|
-
total_output_tokens=safe_int(parts[2] if len(parts) > 2 else ""),
|
|
88
|
-
current_input_tokens=safe_int(parts[3] if len(parts) > 3 else ""),
|
|
89
|
-
current_output_tokens=safe_int(parts[4] if len(parts) > 4 else ""),
|
|
90
|
-
cache_creation=safe_int(parts[5] if len(parts) > 5 else ""),
|
|
91
|
-
cache_read=safe_int(parts[6] if len(parts) > 6 else ""),
|
|
92
|
-
cost_usd=safe_float(parts[7] if len(parts) > 7 else ""),
|
|
93
|
-
lines_added=safe_int(parts[8] if len(parts) > 8 else ""),
|
|
94
|
-
lines_removed=safe_int(parts[9] if len(parts) > 9 else ""),
|
|
95
|
-
session_id=parts[10] if len(parts) > 10 else "",
|
|
96
|
-
model_id=parts[11] if len(parts) > 11 else "",
|
|
97
|
-
workspace_project_dir=parts[12] if len(parts) > 12 else "",
|
|
98
|
-
context_window_size=safe_int(parts[13] if len(parts) > 13 else ""),
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
except (ValueError, IndexError):
|
|
102
|
-
return None
|
|
103
|
-
|
|
104
|
-
def to_csv_line(self) -> str:
|
|
105
|
-
"""Convert entry to CSV line."""
|
|
106
|
-
return ",".join(
|
|
107
|
-
str(x)
|
|
108
|
-
for x in [
|
|
109
|
-
self.timestamp,
|
|
110
|
-
self.total_input_tokens,
|
|
111
|
-
self.total_output_tokens,
|
|
112
|
-
self.current_input_tokens,
|
|
113
|
-
self.current_output_tokens,
|
|
114
|
-
self.cache_creation,
|
|
115
|
-
self.cache_read,
|
|
116
|
-
self.cost_usd,
|
|
117
|
-
self.lines_added,
|
|
118
|
-
self.lines_removed,
|
|
119
|
-
self.session_id,
|
|
120
|
-
self.model_id,
|
|
121
|
-
self.workspace_project_dir.replace(",", "_"),
|
|
122
|
-
self.context_window_size,
|
|
123
|
-
]
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
@property
|
|
127
|
-
def total_tokens(self) -> int:
|
|
128
|
-
"""Get combined input + output tokens."""
|
|
129
|
-
return self.total_input_tokens + self.total_output_tokens
|
|
130
|
-
|
|
131
|
-
@property
|
|
132
|
-
def current_used_tokens(self) -> int:
|
|
133
|
-
"""Get current context usage (input + cache)."""
|
|
134
|
-
return self.current_input_tokens + self.cache_creation + self.cache_read
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _validate_session_id(session_id: str) -> None:
|
|
138
|
-
"""Validate that a session ID does not contain dangerous path characters.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
session_id: Session ID to validate
|
|
142
|
-
|
|
143
|
-
Raises:
|
|
144
|
-
ValueError: If session_id contains '/', '\\', '..', or null bytes
|
|
145
|
-
"""
|
|
146
|
-
for bad in ("/", "\\", "..", "\0"):
|
|
147
|
-
if bad in session_id:
|
|
148
|
-
raise ValueError(
|
|
149
|
-
f"Invalid session_id: contains '{bad}'. "
|
|
150
|
-
"Session IDs must not contain '/', '\\', '..', or null bytes."
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
class StateFile:
|
|
155
|
-
"""Manage state files for token tracking."""
|
|
156
|
-
|
|
157
|
-
STATE_DIR = Path.home() / ".claude" / "statusline"
|
|
158
|
-
OLD_STATE_DIR = Path.home() / ".claude"
|
|
159
|
-
ROTATION_THRESHOLD = 10_000
|
|
160
|
-
ROTATION_KEEP = 5_000
|
|
161
|
-
|
|
162
|
-
def __init__(self, session_id: str | None = None) -> None:
|
|
163
|
-
"""Initialize state file manager.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
session_id: Optional session ID. If not provided, uses latest session.
|
|
167
|
-
"""
|
|
168
|
-
if session_id is not None:
|
|
169
|
-
_validate_session_id(session_id)
|
|
170
|
-
self.session_id = session_id
|
|
171
|
-
self._ensure_state_dir()
|
|
172
|
-
self._migrate_old_files()
|
|
173
|
-
|
|
174
|
-
def _ensure_state_dir(self) -> None:
|
|
175
|
-
"""Create state directory if it doesn't exist."""
|
|
176
|
-
self.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
177
|
-
|
|
178
|
-
def _migrate_old_files(self) -> None:
|
|
179
|
-
"""Migrate old state files from ~/.claude/ to ~/.claude/statusline/."""
|
|
180
|
-
for old_file in self.OLD_STATE_DIR.glob("statusline*.state"):
|
|
181
|
-
if old_file.is_file():
|
|
182
|
-
new_file = self.STATE_DIR / old_file.name
|
|
183
|
-
if not new_file.exists():
|
|
184
|
-
try:
|
|
185
|
-
shutil.move(str(old_file), str(new_file))
|
|
186
|
-
except OSError:
|
|
187
|
-
pass
|
|
188
|
-
else:
|
|
189
|
-
try:
|
|
190
|
-
old_file.unlink()
|
|
191
|
-
except OSError:
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
|
-
@property
|
|
195
|
-
def file_path(self) -> Path:
|
|
196
|
-
"""Get the state file path for the current session."""
|
|
197
|
-
if self.session_id:
|
|
198
|
-
return self.STATE_DIR / f"statusline.{self.session_id}.state"
|
|
199
|
-
return self.STATE_DIR / "statusline.state"
|
|
200
|
-
|
|
201
|
-
def find_latest_state_file(self) -> Path | None:
|
|
202
|
-
"""Find the most recently modified state file.
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
Path to the latest state file, or None if no files exist
|
|
206
|
-
"""
|
|
207
|
-
if self.session_id:
|
|
208
|
-
file_path = self.STATE_DIR / f"statusline.{self.session_id}.state"
|
|
209
|
-
return file_path if file_path.exists() else None
|
|
210
|
-
|
|
211
|
-
# Find most recent state file by modification time
|
|
212
|
-
state_files = list(self.STATE_DIR.glob("statusline.*.state"))
|
|
213
|
-
if not state_files:
|
|
214
|
-
# Try default state file
|
|
215
|
-
default = self.STATE_DIR / "statusline.state"
|
|
216
|
-
return default if default.exists() else None
|
|
217
|
-
|
|
218
|
-
return max(state_files, key=lambda f: f.stat().st_mtime)
|
|
219
|
-
|
|
220
|
-
def read_history(self) -> list[StateEntry]:
|
|
221
|
-
"""Read all entries from the state file.
|
|
222
|
-
|
|
223
|
-
Returns:
|
|
224
|
-
List of StateEntry objects
|
|
225
|
-
"""
|
|
226
|
-
file_path = self.find_latest_state_file()
|
|
227
|
-
if not file_path or not file_path.exists():
|
|
228
|
-
return []
|
|
229
|
-
|
|
230
|
-
entries = []
|
|
231
|
-
try:
|
|
232
|
-
content = file_path.read_text()
|
|
233
|
-
for line in content.splitlines():
|
|
234
|
-
if line.strip():
|
|
235
|
-
entry = StateEntry.from_csv_line(line)
|
|
236
|
-
if entry:
|
|
237
|
-
entries.append(entry)
|
|
238
|
-
except OSError as e:
|
|
239
|
-
sys.stderr.write(f"[statusline] warning: failed to read state history {file_path}: {e}\n")
|
|
240
|
-
|
|
241
|
-
return entries
|
|
242
|
-
|
|
243
|
-
def read_last_entry(self) -> StateEntry | None:
|
|
244
|
-
"""Read only the last entry from the state file.
|
|
245
|
-
|
|
246
|
-
Returns:
|
|
247
|
-
The last StateEntry or None if file is empty/missing
|
|
248
|
-
"""
|
|
249
|
-
# Use file_path for specific session, find_latest for unspecified session
|
|
250
|
-
file_path = self.file_path if self.session_id else self.find_latest_state_file()
|
|
251
|
-
if not file_path or not file_path.exists():
|
|
252
|
-
return None
|
|
253
|
-
|
|
254
|
-
try:
|
|
255
|
-
content = file_path.read_text()
|
|
256
|
-
lines = content.splitlines()
|
|
257
|
-
for line in reversed(lines):
|
|
258
|
-
if line.strip():
|
|
259
|
-
return StateEntry.from_csv_line(line)
|
|
260
|
-
except OSError as e:
|
|
261
|
-
sys.stderr.write(f"[statusline] warning: failed to read last entry {file_path}: {e}\n")
|
|
262
|
-
|
|
263
|
-
return None
|
|
264
|
-
|
|
265
|
-
def append_entry(self, entry: StateEntry) -> None:
|
|
266
|
-
"""Append an entry to the state file.
|
|
267
|
-
|
|
268
|
-
Args:
|
|
269
|
-
entry: StateEntry to append
|
|
270
|
-
"""
|
|
271
|
-
try:
|
|
272
|
-
with open(self.file_path, "a") as f:
|
|
273
|
-
f.write(f"{entry.to_csv_line()}\n")
|
|
274
|
-
except OSError as e:
|
|
275
|
-
sys.stderr.write(f"[statusline] warning: failed to write state {self.file_path}: {e}\n")
|
|
276
|
-
return
|
|
277
|
-
self._maybe_rotate()
|
|
278
|
-
|
|
279
|
-
def _maybe_rotate(self) -> None:
|
|
280
|
-
"""Rotate state file if it exceeds the line threshold.
|
|
281
|
-
|
|
282
|
-
If the file has more than ROTATION_THRESHOLD lines, truncate to
|
|
283
|
-
the most recent ROTATION_KEEP lines via atomic temp-file + rename.
|
|
284
|
-
"""
|
|
285
|
-
file_path = self.file_path
|
|
286
|
-
try:
|
|
287
|
-
if not file_path.exists():
|
|
288
|
-
return
|
|
289
|
-
lines = file_path.read_text().splitlines(keepends=True)
|
|
290
|
-
if len(lines) <= self.ROTATION_THRESHOLD:
|
|
291
|
-
return
|
|
292
|
-
keep = lines[-self.ROTATION_KEEP :]
|
|
293
|
-
fd = tempfile.NamedTemporaryFile(
|
|
294
|
-
dir=str(self.STATE_DIR), delete=False, mode="w", suffix=".tmp"
|
|
295
|
-
)
|
|
296
|
-
try:
|
|
297
|
-
fd.writelines(keep)
|
|
298
|
-
fd.close()
|
|
299
|
-
os.replace(fd.name, str(file_path))
|
|
300
|
-
except BaseException:
|
|
301
|
-
fd.close()
|
|
302
|
-
try:
|
|
303
|
-
os.unlink(fd.name)
|
|
304
|
-
except OSError:
|
|
305
|
-
pass
|
|
306
|
-
raise
|
|
307
|
-
except OSError as e:
|
|
308
|
-
sys.stderr.write(f"[statusline] warning: failed to rotate state file {file_path}: {e}\n")
|
|
309
|
-
|
|
310
|
-
def list_sessions(self) -> list[str]:
|
|
311
|
-
"""List all available session IDs.
|
|
312
|
-
|
|
313
|
-
Returns:
|
|
314
|
-
List of session ID strings
|
|
315
|
-
"""
|
|
316
|
-
sessions = []
|
|
317
|
-
for file_path in self.STATE_DIR.glob("statusline.*.state"):
|
|
318
|
-
name = file_path.stem # statusline.{session_id}
|
|
319
|
-
if name.startswith("statusline."):
|
|
320
|
-
session_id = name[11:] # Remove "statusline." prefix
|
|
321
|
-
if session_id:
|
|
322
|
-
sessions.append(session_id)
|
|
323
|
-
return sessions
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Formatting utilities for claude-statusline."""
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""Layout utilities for fitting statusline output to terminal width."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
import shutil
|
|
7
|
-
|
|
8
|
-
# Pattern to strip ANSI escape sequences
|
|
9
|
-
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def visible_width(s: str) -> int:
|
|
13
|
-
"""Return the visible width of a string after stripping ANSI escape sequences."""
|
|
14
|
-
return len(_ANSI_RE.sub("", s))
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def get_terminal_width() -> int:
|
|
18
|
-
"""Return the terminal width in columns.
|
|
19
|
-
|
|
20
|
-
When running inside Claude Code's statusline subprocess, neither $COLUMNS
|
|
21
|
-
nor tput/shutil can detect the real terminal width (they always return 80).
|
|
22
|
-
If COLUMNS is not explicitly set and shutil falls back to 80, we use a
|
|
23
|
-
generous default of 200 so that no parts are unnecessarily dropped;
|
|
24
|
-
Claude Code's own UI handles any overflow/truncation.
|
|
25
|
-
"""
|
|
26
|
-
import os
|
|
27
|
-
|
|
28
|
-
# If COLUMNS is explicitly set, trust it (real terminal or test override)
|
|
29
|
-
if os.environ.get("COLUMNS"):
|
|
30
|
-
return shutil.get_terminal_size().columns
|
|
31
|
-
# No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
|
|
32
|
-
# shutil will fall back to 80, which is too narrow. Use 200 instead.
|
|
33
|
-
cols = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
34
|
-
return 200 if cols == 80 else cols
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def fit_to_width(parts: list[str], max_width: int) -> str:
|
|
38
|
-
"""Assemble parts into a single line that fits within max_width.
|
|
39
|
-
|
|
40
|
-
Parts are added in priority order (first = highest priority).
|
|
41
|
-
The first part (base) is always included. Subsequent parts are
|
|
42
|
-
included only if adding them does not exceed max_width.
|
|
43
|
-
Empty parts are skipped.
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
parts: List of strings in priority order (highest first).
|
|
47
|
-
max_width: Maximum visible width allowed.
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
Assembled string that fits within max_width.
|
|
51
|
-
"""
|
|
52
|
-
if not parts:
|
|
53
|
-
return ""
|
|
54
|
-
|
|
55
|
-
# Base part is always included
|
|
56
|
-
result = parts[0]
|
|
57
|
-
current_width = visible_width(result)
|
|
58
|
-
|
|
59
|
-
for part in parts[1:]:
|
|
60
|
-
if not part:
|
|
61
|
-
continue
|
|
62
|
-
part_width = visible_width(part)
|
|
63
|
-
if current_width + part_width <= max_width:
|
|
64
|
-
result += part
|
|
65
|
-
current_width += part_width
|
|
66
|
-
|
|
67
|
-
return result
|
|
@@ -1,50 +0,0 @@
|
|
|
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())
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Graph rendering utilities for token visualization."""
|