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.
- package/package.json +8 -1
- package/scripts/context-stats.sh +1 -1
- 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 -187
- 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/MODEL_INTELLIGENCE.md +0 -396
- 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 -438
- package/scripts/statusline-git.sh +0 -88
- package/scripts/statusline-minimal.sh +0 -67
- package/scripts/statusline.py +0 -569
- 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 -542
- package/src/claude_statusline/cli/explain.py +0 -228
- package/src/claude_statusline/cli/statusline.py +0 -184
- 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 -165
- 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/intelligence.py +0 -162
- package/src/claude_statusline/graphs/renderer.py +0 -401
- 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/fixtures/mi_test_vectors.json +0 -140
- package/tests/node/intelligence.test.js +0 -98
- 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_intelligence.py +0 -314
- 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,165 +0,0 @@
|
|
|
1
|
-
"""Configuration management for statusline."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass
|
|
24
|
-
class Config:
|
|
25
|
-
"""Configuration settings for the statusline."""
|
|
26
|
-
|
|
27
|
-
autocompact: bool = True
|
|
28
|
-
token_detail: bool = True
|
|
29
|
-
show_delta: bool = True
|
|
30
|
-
show_session: bool = True
|
|
31
|
-
show_io_tokens: bool = True
|
|
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)
|
|
38
|
-
|
|
39
|
-
_config_path: Path = field(default_factory=lambda: Path.home() / ".claude" / "statusline.conf")
|
|
40
|
-
|
|
41
|
-
@classmethod
|
|
42
|
-
def load(cls, config_path: str | Path | None = None) -> Config:
|
|
43
|
-
"""Load configuration from file.
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
config_path: Path to config file. Defaults to ~/.claude/statusline.conf
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Config instance with loaded settings
|
|
50
|
-
"""
|
|
51
|
-
config = cls()
|
|
52
|
-
if config_path:
|
|
53
|
-
config._config_path = Path(config_path).expanduser()
|
|
54
|
-
|
|
55
|
-
if not config._config_path.exists():
|
|
56
|
-
config._create_default()
|
|
57
|
-
return config
|
|
58
|
-
|
|
59
|
-
config._read_config()
|
|
60
|
-
return config
|
|
61
|
-
|
|
62
|
-
def _create_default(self) -> None:
|
|
63
|
-
"""Create default config file if it doesn't exist."""
|
|
64
|
-
try:
|
|
65
|
-
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
-
self._config_path.write_text(
|
|
67
|
-
"""# Autocompact setting - sync with Claude Code's /config
|
|
68
|
-
autocompact=true
|
|
69
|
-
|
|
70
|
-
# Token display format
|
|
71
|
-
token_detail=true
|
|
72
|
-
|
|
73
|
-
# Show token delta since last refresh (adds file I/O on every refresh)
|
|
74
|
-
# Disable if you don't need it to reduce overhead
|
|
75
|
-
show_delta=true
|
|
76
|
-
|
|
77
|
-
# Show session_id in status line
|
|
78
|
-
show_session=true
|
|
79
|
-
|
|
80
|
-
# Disable rotating text animations
|
|
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
|
|
99
|
-
"""
|
|
100
|
-
)
|
|
101
|
-
except OSError as e:
|
|
102
|
-
sys.stderr.write(
|
|
103
|
-
f"[statusline] warning: failed to create config {self._config_path}: {e}\n"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
def _read_config(self) -> None:
|
|
107
|
-
"""Read settings from config file."""
|
|
108
|
-
try:
|
|
109
|
-
content = self._config_path.read_text()
|
|
110
|
-
for line in content.splitlines():
|
|
111
|
-
line = line.strip()
|
|
112
|
-
if line.startswith("#") or "=" not in line:
|
|
113
|
-
continue
|
|
114
|
-
key, value = line.split("=", 1)
|
|
115
|
-
key = key.strip()
|
|
116
|
-
raw_value = value.strip()
|
|
117
|
-
value_lower = raw_value.lower()
|
|
118
|
-
|
|
119
|
-
if key == "autocompact":
|
|
120
|
-
self.autocompact = value_lower != "false"
|
|
121
|
-
elif key == "token_detail":
|
|
122
|
-
self.token_detail = value_lower != "false"
|
|
123
|
-
elif key == "show_delta":
|
|
124
|
-
self.show_delta = value_lower != "false"
|
|
125
|
-
elif key == "show_session":
|
|
126
|
-
self.show_session = value_lower != "false"
|
|
127
|
-
elif key == "show_io_tokens":
|
|
128
|
-
self.show_io_tokens = value_lower != "false"
|
|
129
|
-
elif key == "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
|
-
)
|
|
148
|
-
except (OSError, UnicodeDecodeError) as e:
|
|
149
|
-
sys.stderr.write(
|
|
150
|
-
f"[statusline] warning: failed to read config {self._config_path}: {e}\n"
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
def to_dict(self) -> dict[str, Any]:
|
|
154
|
-
"""Convert config to dictionary."""
|
|
155
|
-
return {
|
|
156
|
-
"autocompact": self.autocompact,
|
|
157
|
-
"token_detail": self.token_detail,
|
|
158
|
-
"show_delta": self.show_delta,
|
|
159
|
-
"show_session": self.show_session,
|
|
160
|
-
"show_io_tokens": self.show_io_tokens,
|
|
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),
|
|
165
|
-
}
|
|
@@ -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())
|