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,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())