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.
- package/.claude/commands/context-stats.md +17 -0
- package/.claude/settings.local.json +85 -0
- package/.editorconfig +60 -0
- package/.eslintrc.json +35 -0
- package/.github/dependabot.yml +44 -0
- package/.github/workflows/ci.yml +255 -0
- package/.github/workflows/release.yml +149 -0
- package/.pre-commit-config.yaml +74 -0
- package/.prettierrc +33 -0
- package/.shellcheckrc +10 -0
- package/CHANGELOG.md +100 -0
- package/CONTRIBUTING.md +240 -0
- package/PUBLISHING_GUIDE.md +69 -0
- package/README.md +179 -0
- package/config/settings-example.json +7 -0
- package/config/settings-node.json +7 -0
- package/config/settings-python.json +7 -0
- package/docs/configuration.md +83 -0
- package/docs/context-stats.md +132 -0
- package/docs/installation.md +195 -0
- package/docs/scripts.md +116 -0
- package/docs/troubleshooting.md +189 -0
- 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/install +344 -0
- package/install.sh +272 -0
- package/jest.config.js +11 -0
- package/npm-publish.sh +33 -0
- package/package.json +36 -0
- package/publish.sh +24 -0
- package/pyproject.toml +113 -0
- package/requirements-dev.txt +12 -0
- package/scripts/context-stats.sh +970 -0
- package/scripts/statusline-full.sh +241 -0
- package/scripts/statusline-git.sh +32 -0
- package/scripts/statusline-minimal.sh +11 -0
- package/scripts/statusline.js +350 -0
- package/scripts/statusline.py +312 -0
- package/show_raw_claude_code_api.js +11 -0
- package/src/claude_statusline/__init__.py +11 -0
- package/src/claude_statusline/__main__.py +6 -0
- package/src/claude_statusline/cli/__init__.py +1 -0
- package/src/claude_statusline/cli/context_stats.py +379 -0
- package/src/claude_statusline/cli/statusline.py +172 -0
- package/src/claude_statusline/core/__init__.py +1 -0
- package/src/claude_statusline/core/colors.py +55 -0
- package/src/claude_statusline/core/config.py +98 -0
- package/src/claude_statusline/core/git.py +67 -0
- package/src/claude_statusline/core/state.py +266 -0
- package/src/claude_statusline/formatters/__init__.py +1 -0
- package/src/claude_statusline/formatters/time.py +50 -0
- package/src/claude_statusline/formatters/tokens.py +70 -0
- package/src/claude_statusline/graphs/__init__.py +1 -0
- package/src/claude_statusline/graphs/renderer.py +346 -0
- package/src/claude_statusline/graphs/statistics.py +58 -0
- package/tests/bash/test_install.bats +29 -0
- package/tests/bash/test_statusline_full.bats +109 -0
- package/tests/bash/test_statusline_git.bats +42 -0
- package/tests/bash/test_statusline_minimal.bats +37 -0
- package/tests/fixtures/json/high_usage.json +17 -0
- package/tests/fixtures/json/low_usage.json +17 -0
- package/tests/fixtures/json/medium_usage.json +17 -0
- package/tests/fixtures/json/valid_full.json +30 -0
- package/tests/fixtures/json/valid_minimal.json +9 -0
- package/tests/node/statusline.test.js +199 -0
- package/tests/python/conftest.py +84 -0
- package/tests/python/test_statusline.py +154 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Context Stats Visualizer for Claude Code.
|
|
3
|
+
|
|
4
|
+
Displays ASCII graphs of token consumption over time.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
context-stats [session_id] [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--type <cumulative|delta|io|both|all> Graph type to display (default: both)
|
|
11
|
+
--watch, -w [interval] Real-time monitoring mode (default: 2s)
|
|
12
|
+
--no-color Disable color output
|
|
13
|
+
--help Show this help
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import signal
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from claude_statusline import __version__
|
|
25
|
+
from claude_statusline.core.colors import ColorManager
|
|
26
|
+
from claude_statusline.core.config import Config
|
|
27
|
+
from claude_statusline.core.state import StateFile
|
|
28
|
+
from claude_statusline.graphs.renderer import GraphDimensions, GraphRenderer
|
|
29
|
+
from claude_statusline.graphs.statistics import calculate_deltas
|
|
30
|
+
|
|
31
|
+
# Cursor control sequences
|
|
32
|
+
CURSOR_HOME = "\033[H"
|
|
33
|
+
CLEAR_SCREEN = "\033[2J"
|
|
34
|
+
HIDE_CURSOR = "\033[?25l"
|
|
35
|
+
SHOW_CURSOR = "\033[?25h"
|
|
36
|
+
CLEAR_TO_END = "\033[J"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def show_help() -> None:
|
|
40
|
+
"""Show help message."""
|
|
41
|
+
print(
|
|
42
|
+
"""Context Stats Visualizer for Claude Code
|
|
43
|
+
|
|
44
|
+
USAGE:
|
|
45
|
+
context-stats [session_id] [options]
|
|
46
|
+
|
|
47
|
+
ARGUMENTS:
|
|
48
|
+
session_id Optional session ID. If not provided, uses the latest session.
|
|
49
|
+
|
|
50
|
+
OPTIONS:
|
|
51
|
+
--type <type> Graph type to display:
|
|
52
|
+
- delta: Context growth per interaction (default)
|
|
53
|
+
- cumulative: Total context usage over time
|
|
54
|
+
- io: Input/output tokens over time
|
|
55
|
+
- both: Show cumulative and delta graphs
|
|
56
|
+
- all: Show all graphs including I/O
|
|
57
|
+
-w [interval] Set refresh interval in seconds (default: 2)
|
|
58
|
+
--no-watch Show graphs once and exit (disable live monitoring)
|
|
59
|
+
--no-color Disable color output
|
|
60
|
+
--help Show this help message
|
|
61
|
+
|
|
62
|
+
NOTE:
|
|
63
|
+
By default, context-stats runs in live monitoring mode, refreshing every 2 seconds.
|
|
64
|
+
Press Ctrl+C to exit. Use --no-watch to display graphs once and exit.
|
|
65
|
+
|
|
66
|
+
EXAMPLES:
|
|
67
|
+
# Live monitoring (default, refreshes every 2s)
|
|
68
|
+
context-stats
|
|
69
|
+
|
|
70
|
+
# Live monitoring with custom interval
|
|
71
|
+
context-stats -w 5
|
|
72
|
+
|
|
73
|
+
# Show graphs once and exit
|
|
74
|
+
context-stats --no-watch
|
|
75
|
+
|
|
76
|
+
# Show graphs for specific session
|
|
77
|
+
context-stats abc123def
|
|
78
|
+
|
|
79
|
+
# Show only cumulative graph
|
|
80
|
+
context-stats --type cumulative
|
|
81
|
+
|
|
82
|
+
# Combine options
|
|
83
|
+
context-stats abc123 --type cumulative -w 3
|
|
84
|
+
|
|
85
|
+
# Output to file (no colors, single run)
|
|
86
|
+
context-stats --no-watch --no-color > output.txt
|
|
87
|
+
|
|
88
|
+
DATA SOURCE:
|
|
89
|
+
Reads token history from ~/.claude/statusline/statusline.<session_id>.state
|
|
90
|
+
"""
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_args() -> argparse.Namespace:
|
|
95
|
+
"""Parse command-line arguments."""
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
description="Context Stats Visualizer for Claude Code",
|
|
98
|
+
add_help=False,
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument("session_id", nargs="?", default=None, help="Session ID")
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--type",
|
|
103
|
+
choices=["cumulative", "delta", "io", "both", "all"],
|
|
104
|
+
default="delta",
|
|
105
|
+
help="Graph type to display (default: delta)",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--watch",
|
|
109
|
+
"-w",
|
|
110
|
+
nargs="?",
|
|
111
|
+
const=2,
|
|
112
|
+
type=int,
|
|
113
|
+
default=2,
|
|
114
|
+
help="Watch mode interval in seconds (default: 2, use --no-watch to disable)",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--no-watch",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="Disable watch mode (show graphs once and exit)",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--no-color",
|
|
123
|
+
action="store_true",
|
|
124
|
+
help="Disable color output",
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--help",
|
|
128
|
+
"-h",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="Show help message",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
args = parser.parse_args()
|
|
134
|
+
|
|
135
|
+
if args.help:
|
|
136
|
+
show_help()
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
|
|
139
|
+
return args
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_once(
|
|
143
|
+
state_file: StateFile,
|
|
144
|
+
graph_type: str,
|
|
145
|
+
renderer: GraphRenderer,
|
|
146
|
+
colors: ColorManager,
|
|
147
|
+
watch_mode: bool = False,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Render graphs once.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
state_file: StateFile instance
|
|
153
|
+
graph_type: Type of graphs to render
|
|
154
|
+
renderer: GraphRenderer instance
|
|
155
|
+
colors: ColorManager instance
|
|
156
|
+
watch_mode: Whether running in watch mode
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
True if rendering was successful, False if not enough data
|
|
160
|
+
"""
|
|
161
|
+
entries = state_file.read_history()
|
|
162
|
+
|
|
163
|
+
if len(entries) < 2:
|
|
164
|
+
print(f"\n{colors.yellow}Need at least 2 data points to generate graphs.{colors.reset}")
|
|
165
|
+
print(
|
|
166
|
+
f"{colors.dim}Found: {len(entries)} entry. Use Claude Code to accumulate more data.{colors.reset}"
|
|
167
|
+
)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
# Extract data for graphs
|
|
171
|
+
timestamps = [e.timestamp for e in entries]
|
|
172
|
+
# Current context window usage (what's actually in the context)
|
|
173
|
+
# This is: cache_read + cache_creation + current_input_tokens
|
|
174
|
+
context_used = [e.current_used_tokens for e in entries]
|
|
175
|
+
# Per-request I/O tokens from current_usage
|
|
176
|
+
current_input = [e.current_input_tokens for e in entries]
|
|
177
|
+
current_output = [e.current_output_tokens for e in entries]
|
|
178
|
+
deltas = calculate_deltas(context_used)
|
|
179
|
+
delta_times = timestamps[1:] # Deltas start from second entry
|
|
180
|
+
|
|
181
|
+
# Get session name and project from entries
|
|
182
|
+
file_path = state_file.find_latest_state_file()
|
|
183
|
+
session_name = file_path.stem.replace("statusline.", "") if file_path else "unknown"
|
|
184
|
+
|
|
185
|
+
# Get project name from the last entry (most recent)
|
|
186
|
+
last_entry = entries[-1]
|
|
187
|
+
project_name = ""
|
|
188
|
+
if last_entry.workspace_project_dir:
|
|
189
|
+
# Extract just the project folder name from the path
|
|
190
|
+
project_name = Path(last_entry.workspace_project_dir).name
|
|
191
|
+
|
|
192
|
+
# Header
|
|
193
|
+
if not watch_mode:
|
|
194
|
+
print()
|
|
195
|
+
if project_name:
|
|
196
|
+
print(
|
|
197
|
+
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
198
|
+
f"{colors.dim}({colors.cyan}{project_name}{colors.dim} • {session_name}){colors.reset}"
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
print(
|
|
202
|
+
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
203
|
+
f"{colors.dim}(Session: {session_name}){colors.reset}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Render requested graphs
|
|
207
|
+
if graph_type in ("cumulative", "both", "all"):
|
|
208
|
+
renderer.render_timeseries(
|
|
209
|
+
context_used, timestamps, "Context Usage Over Time", colors.green
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if graph_type in ("delta", "both", "all"):
|
|
213
|
+
renderer.render_timeseries(
|
|
214
|
+
deltas, delta_times, "Context Growth Per Interaction", colors.cyan
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if graph_type in ("io", "all"):
|
|
218
|
+
renderer.render_timeseries(
|
|
219
|
+
current_input, timestamps, "Input Tokens (per request)", colors.blue
|
|
220
|
+
)
|
|
221
|
+
renderer.render_timeseries(
|
|
222
|
+
current_output, timestamps, "Output Tokens (per request)", colors.magenta
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Summary and footer
|
|
226
|
+
renderer.render_summary(entries, deltas)
|
|
227
|
+
renderer.render_footer(__version__)
|
|
228
|
+
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_watch_mode(
|
|
233
|
+
state_file: StateFile,
|
|
234
|
+
graph_type: str,
|
|
235
|
+
interval: int,
|
|
236
|
+
renderer: GraphRenderer,
|
|
237
|
+
colors: ColorManager,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Run in watch mode with continuous refresh.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
state_file: StateFile instance
|
|
243
|
+
graph_type: Type of graphs to render
|
|
244
|
+
interval: Refresh interval in seconds
|
|
245
|
+
renderer: GraphRenderer instance
|
|
246
|
+
colors: ColorManager instance
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
# Signal handler for clean exit
|
|
250
|
+
def handle_signal(_signum: int, _frame: object) -> None:
|
|
251
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
252
|
+
sys.stdout.flush()
|
|
253
|
+
print(f"\n{colors.dim}Watch mode stopped.{colors.reset}")
|
|
254
|
+
sys.exit(0)
|
|
255
|
+
|
|
256
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
257
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
258
|
+
|
|
259
|
+
# Hide cursor and initial clear in one write
|
|
260
|
+
sys.stdout.write(f"{HIDE_CURSOR}{CLEAR_SCREEN}{CURSOR_HOME}")
|
|
261
|
+
sys.stdout.flush()
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
while True:
|
|
265
|
+
# Move cursor to home
|
|
266
|
+
sys.stdout.write(CURSOR_HOME)
|
|
267
|
+
sys.stdout.flush()
|
|
268
|
+
|
|
269
|
+
# Update dimensions in case of terminal resize
|
|
270
|
+
renderer.dimensions = GraphDimensions.detect()
|
|
271
|
+
|
|
272
|
+
# Watch mode indicator
|
|
273
|
+
current_time = time.strftime("%H:%M:%S")
|
|
274
|
+
print(
|
|
275
|
+
f"{colors.dim}[LIVE {current_time}] Refresh: {interval}s | Ctrl+C to exit{colors.reset}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Check if state file exists now (may have been created since start)
|
|
279
|
+
file_path = state_file.find_latest_state_file()
|
|
280
|
+
if not file_path or not file_path.exists():
|
|
281
|
+
# Show waiting message for new session
|
|
282
|
+
show_waiting_message(
|
|
283
|
+
colors,
|
|
284
|
+
state_file.session_id,
|
|
285
|
+
"Waiting for session data...",
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
# Render graphs
|
|
289
|
+
render_once(state_file, graph_type, renderer, colors, watch_mode=True)
|
|
290
|
+
|
|
291
|
+
# Clear any remaining content
|
|
292
|
+
sys.stdout.write(CLEAR_TO_END)
|
|
293
|
+
sys.stdout.flush()
|
|
294
|
+
|
|
295
|
+
time.sleep(interval)
|
|
296
|
+
finally:
|
|
297
|
+
sys.stdout.write(SHOW_CURSOR)
|
|
298
|
+
sys.stdout.flush()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def show_waiting_message(
|
|
302
|
+
colors: ColorManager,
|
|
303
|
+
session_id: str | None,
|
|
304
|
+
message: str = "Waiting for session data...",
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Show a friendly waiting message for new sessions.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
colors: ColorManager instance
|
|
310
|
+
session_id: Session ID if specified
|
|
311
|
+
message: Message to display
|
|
312
|
+
"""
|
|
313
|
+
print()
|
|
314
|
+
if session_id:
|
|
315
|
+
print(
|
|
316
|
+
f"{colors.bold}{colors.magenta}Context Stats{colors.reset} "
|
|
317
|
+
f"{colors.dim}(Session: {session_id}){colors.reset}"
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
print(f"{colors.bold}{colors.magenta}Context Stats{colors.reset}")
|
|
321
|
+
|
|
322
|
+
print()
|
|
323
|
+
print(f" {colors.cyan}⏳ {message}{colors.reset}")
|
|
324
|
+
print()
|
|
325
|
+
print(
|
|
326
|
+
f" {colors.dim}The session has just started and no data has been recorded yet.{colors.reset}"
|
|
327
|
+
)
|
|
328
|
+
print(f" {colors.dim}Data will appear after the first Claude interaction.{colors.reset}")
|
|
329
|
+
print()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main() -> None:
|
|
333
|
+
"""Main entry point for context-stats CLI."""
|
|
334
|
+
args = parse_args()
|
|
335
|
+
|
|
336
|
+
# Load config for token_detail setting
|
|
337
|
+
config = Config.load()
|
|
338
|
+
|
|
339
|
+
# Setup colors
|
|
340
|
+
color_enabled = not args.no_color and sys.stdout.isatty()
|
|
341
|
+
colors = ColorManager(enabled=color_enabled)
|
|
342
|
+
|
|
343
|
+
# Setup state file
|
|
344
|
+
state_file = StateFile(args.session_id)
|
|
345
|
+
|
|
346
|
+
# Find state file
|
|
347
|
+
file_path = state_file.find_latest_state_file()
|
|
348
|
+
|
|
349
|
+
# Handle case where no state file exists yet
|
|
350
|
+
if not file_path or not file_path.exists():
|
|
351
|
+
if args.no_watch:
|
|
352
|
+
# Single run mode - show friendly message and exit
|
|
353
|
+
if args.session_id:
|
|
354
|
+
show_waiting_message(colors, args.session_id)
|
|
355
|
+
else:
|
|
356
|
+
print(f"{colors.yellow}No session data found.{colors.reset}")
|
|
357
|
+
print(f"{colors.dim}Run Claude Code to generate token usage data.{colors.reset}")
|
|
358
|
+
sys.exit(0)
|
|
359
|
+
else:
|
|
360
|
+
# Watch mode - continue and wait for data
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
# Setup renderer
|
|
364
|
+
renderer = GraphRenderer(
|
|
365
|
+
colors=colors,
|
|
366
|
+
token_detail=config.token_detail,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Run
|
|
370
|
+
if args.no_watch:
|
|
371
|
+
success = render_once(state_file, args.type, renderer, colors)
|
|
372
|
+
if not success:
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
else:
|
|
375
|
+
run_watch_mode(state_file, args.type, args.watch, renderer, colors)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
main()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI entry point for claude-statusline command.
|
|
3
|
+
|
|
4
|
+
Usage: Copy to ~/.claude/statusline.py and make executable, or install via pip.
|
|
5
|
+
|
|
6
|
+
Configuration:
|
|
7
|
+
Create/edit ~/.claude/statusline.conf and set:
|
|
8
|
+
|
|
9
|
+
autocompact=true (when autocompact is enabled in Claude Code - default)
|
|
10
|
+
autocompact=false (when you disable autocompact via /config in Claude Code)
|
|
11
|
+
|
|
12
|
+
token_detail=true (show exact token count like 64,000 - default)
|
|
13
|
+
token_detail=false (show abbreviated tokens like 64.0k)
|
|
14
|
+
|
|
15
|
+
show_delta=true (show token delta since last refresh like [+2,500] - default)
|
|
16
|
+
show_delta=false (disable delta display - saves file I/O on every refresh)
|
|
17
|
+
|
|
18
|
+
show_session=true (show session_id in status line - default)
|
|
19
|
+
show_session=false (hide session_id from status line)
|
|
20
|
+
|
|
21
|
+
When AC is enabled, 22.5% of context window is reserved for autocompact buffer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
from claude_statusline.core.colors import (
|
|
30
|
+
BLUE,
|
|
31
|
+
DIM,
|
|
32
|
+
GREEN,
|
|
33
|
+
RED,
|
|
34
|
+
RESET,
|
|
35
|
+
YELLOW,
|
|
36
|
+
)
|
|
37
|
+
from claude_statusline.core.config import Config
|
|
38
|
+
from claude_statusline.core.git import get_git_info
|
|
39
|
+
from claude_statusline.core.state import StateEntry, StateFile
|
|
40
|
+
from claude_statusline.formatters.time import get_current_timestamp
|
|
41
|
+
from claude_statusline.formatters.tokens import calculate_context_usage, format_tokens
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> None:
|
|
45
|
+
"""Main entry point for claude-statusline CLI."""
|
|
46
|
+
try:
|
|
47
|
+
data = json.load(sys.stdin)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
print("[Claude] ~")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Extract data
|
|
53
|
+
cwd = data.get("workspace", {}).get("current_dir", "~")
|
|
54
|
+
project_dir = data.get("workspace", {}).get("project_dir", cwd)
|
|
55
|
+
model = data.get("model", {}).get("display_name", "Claude")
|
|
56
|
+
dir_name = cwd.rsplit("/", 1)[-1] if "/" in cwd else cwd or "~"
|
|
57
|
+
|
|
58
|
+
# Git info
|
|
59
|
+
git_info = get_git_info(project_dir)
|
|
60
|
+
|
|
61
|
+
# Read settings from config file
|
|
62
|
+
config = Config.load()
|
|
63
|
+
|
|
64
|
+
# Extract session_id once for reuse
|
|
65
|
+
session_id = data.get("session_id")
|
|
66
|
+
|
|
67
|
+
# Context window calculation
|
|
68
|
+
context_info = ""
|
|
69
|
+
ac_info = ""
|
|
70
|
+
delta_info = ""
|
|
71
|
+
session_info = ""
|
|
72
|
+
|
|
73
|
+
total_size = data.get("context_window", {}).get("context_window_size", 0)
|
|
74
|
+
current_usage = data.get("context_window", {}).get("current_usage")
|
|
75
|
+
total_input_tokens = data.get("context_window", {}).get("total_input_tokens", 0)
|
|
76
|
+
total_output_tokens = data.get("context_window", {}).get("total_output_tokens", 0)
|
|
77
|
+
cost_usd = data.get("cost", {}).get("total_cost_usd", 0)
|
|
78
|
+
lines_added = data.get("cost", {}).get("total_lines_added", 0)
|
|
79
|
+
lines_removed = data.get("cost", {}).get("total_lines_removed", 0)
|
|
80
|
+
model_id = data.get("model", {}).get("id", "")
|
|
81
|
+
workspace_project_dir = data.get("workspace", {}).get("project_dir", "")
|
|
82
|
+
|
|
83
|
+
if total_size > 0 and current_usage:
|
|
84
|
+
# Get tokens from current_usage (includes cache)
|
|
85
|
+
input_tokens = current_usage.get("input_tokens", 0)
|
|
86
|
+
cache_creation = current_usage.get("cache_creation_input_tokens", 0)
|
|
87
|
+
cache_read = current_usage.get("cache_read_input_tokens", 0)
|
|
88
|
+
|
|
89
|
+
# Total used from current request
|
|
90
|
+
used_tokens = input_tokens + cache_creation + cache_read
|
|
91
|
+
|
|
92
|
+
# Calculate context usage
|
|
93
|
+
free_tokens, free_pct, autocompact_buffer = calculate_context_usage(
|
|
94
|
+
used_tokens,
|
|
95
|
+
total_size,
|
|
96
|
+
config.autocompact,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if config.autocompact:
|
|
100
|
+
buffer_k = autocompact_buffer // 1000
|
|
101
|
+
ac_info = f" {DIM}[AC:{buffer_k}k]{RESET}"
|
|
102
|
+
else:
|
|
103
|
+
ac_info = f" {DIM}[AC:off]{RESET}"
|
|
104
|
+
|
|
105
|
+
# Format tokens based on token_detail setting
|
|
106
|
+
free_display = format_tokens(free_tokens, config.token_detail)
|
|
107
|
+
|
|
108
|
+
# Color based on free percentage
|
|
109
|
+
free_pct_int = int(free_pct)
|
|
110
|
+
if free_pct_int > 50:
|
|
111
|
+
ctx_color = GREEN
|
|
112
|
+
elif free_pct_int > 25:
|
|
113
|
+
ctx_color = YELLOW
|
|
114
|
+
else:
|
|
115
|
+
ctx_color = RED
|
|
116
|
+
|
|
117
|
+
context_info = f" | {ctx_color}{free_display} free ({free_pct:.1f}%){RESET}"
|
|
118
|
+
|
|
119
|
+
# Calculate and display token delta if enabled
|
|
120
|
+
if config.show_delta:
|
|
121
|
+
state_file = StateFile(session_id)
|
|
122
|
+
prev_entry = state_file.read_last_entry()
|
|
123
|
+
|
|
124
|
+
prev_tokens = prev_entry.current_used_tokens if prev_entry else 0
|
|
125
|
+
has_prev = prev_entry is not None
|
|
126
|
+
|
|
127
|
+
# Calculate delta
|
|
128
|
+
delta = used_tokens - prev_tokens
|
|
129
|
+
|
|
130
|
+
# Only show positive delta (and skip first run when no previous state)
|
|
131
|
+
if has_prev and delta > 0:
|
|
132
|
+
delta_display = format_tokens(delta, config.token_detail)
|
|
133
|
+
delta_info = f" {DIM}[+{delta_display}]{RESET}"
|
|
134
|
+
|
|
135
|
+
# Build current entry
|
|
136
|
+
cur_input_tokens = current_usage.get("input_tokens", 0)
|
|
137
|
+
cur_output_tokens = current_usage.get("output_tokens", 0)
|
|
138
|
+
|
|
139
|
+
entry = StateEntry(
|
|
140
|
+
timestamp=get_current_timestamp(),
|
|
141
|
+
total_input_tokens=total_input_tokens,
|
|
142
|
+
total_output_tokens=total_output_tokens,
|
|
143
|
+
current_input_tokens=cur_input_tokens,
|
|
144
|
+
current_output_tokens=cur_output_tokens,
|
|
145
|
+
cache_creation=cache_creation,
|
|
146
|
+
cache_read=cache_read,
|
|
147
|
+
cost_usd=cost_usd,
|
|
148
|
+
lines_added=lines_added,
|
|
149
|
+
lines_removed=lines_removed,
|
|
150
|
+
session_id=session_id or "",
|
|
151
|
+
model_id=model_id,
|
|
152
|
+
workspace_project_dir=workspace_project_dir,
|
|
153
|
+
context_window_size=total_size,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Only append if context usage changed (avoid duplicates from multiple refreshes)
|
|
157
|
+
if not has_prev or used_tokens != prev_tokens:
|
|
158
|
+
state_file.append_entry(entry)
|
|
159
|
+
|
|
160
|
+
# Display session_id if enabled
|
|
161
|
+
if config.show_session and session_id:
|
|
162
|
+
session_info = f" {DIM}{session_id}{RESET}"
|
|
163
|
+
|
|
164
|
+
# Output: [Model] directory | branch [changes] | XXk free (XX%) [+delta] [AC] [session_id]
|
|
165
|
+
print(
|
|
166
|
+
f"{DIM}[{model}]{RESET} {BLUE}{dir_name}{RESET}"
|
|
167
|
+
f"{git_info}{context_info}{delta_info}{ac_info}{session_info}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core functionality for claude-statusline."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""ANSI color constants and utilities."""
|
|
2
|
+
|
|
3
|
+
# ANSI color codes
|
|
4
|
+
BLUE = "\033[0;34m"
|
|
5
|
+
MAGENTA = "\033[0;35m"
|
|
6
|
+
CYAN = "\033[0;36m"
|
|
7
|
+
GREEN = "\033[0;32m"
|
|
8
|
+
YELLOW = "\033[0;33m"
|
|
9
|
+
RED = "\033[0;31m"
|
|
10
|
+
BOLD = "\033[1m"
|
|
11
|
+
DIM = "\033[2m"
|
|
12
|
+
RESET = "\033[0m"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ColorManager:
|
|
16
|
+
"""Manage color output based on terminal capabilities."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
19
|
+
self.enabled = enabled
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def blue(self) -> str:
|
|
23
|
+
return BLUE if self.enabled else ""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def magenta(self) -> str:
|
|
27
|
+
return MAGENTA if self.enabled else ""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def cyan(self) -> str:
|
|
31
|
+
return CYAN if self.enabled else ""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def green(self) -> str:
|
|
35
|
+
return GREEN if self.enabled else ""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def yellow(self) -> str:
|
|
39
|
+
return YELLOW if self.enabled else ""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def red(self) -> str:
|
|
43
|
+
return RED if self.enabled else ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def bold(self) -> str:
|
|
47
|
+
return BOLD if self.enabled else ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dim(self) -> str:
|
|
51
|
+
return DIM if self.enabled else ""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def reset(self) -> str:
|
|
55
|
+
return RESET if self.enabled else ""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Configuration management for statusline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
"""Configuration settings for the statusline."""
|
|
13
|
+
|
|
14
|
+
autocompact: bool = True
|
|
15
|
+
token_detail: bool = True
|
|
16
|
+
show_delta: bool = True
|
|
17
|
+
show_session: bool = True
|
|
18
|
+
show_io_tokens: bool = True
|
|
19
|
+
|
|
20
|
+
_config_path: Path = field(default_factory=lambda: Path.home() / ".claude" / "statusline.conf")
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def load(cls, config_path: str | Path | None = None) -> Config:
|
|
24
|
+
"""Load configuration from file.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config_path: Path to config file. Defaults to ~/.claude/statusline.conf
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Config instance with loaded settings
|
|
31
|
+
"""
|
|
32
|
+
config = cls()
|
|
33
|
+
if config_path:
|
|
34
|
+
config._config_path = Path(config_path).expanduser()
|
|
35
|
+
|
|
36
|
+
if not config._config_path.exists():
|
|
37
|
+
config._create_default()
|
|
38
|
+
return config
|
|
39
|
+
|
|
40
|
+
config._read_config()
|
|
41
|
+
return config
|
|
42
|
+
|
|
43
|
+
def _create_default(self) -> None:
|
|
44
|
+
"""Create default config file if it doesn't exist."""
|
|
45
|
+
try:
|
|
46
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
self._config_path.write_text(
|
|
48
|
+
"""# Autocompact setting - sync with Claude Code's /config
|
|
49
|
+
autocompact=true
|
|
50
|
+
|
|
51
|
+
# Token display format
|
|
52
|
+
token_detail=true
|
|
53
|
+
|
|
54
|
+
# Show token delta since last refresh (adds file I/O on every refresh)
|
|
55
|
+
# Disable if you don't need it to reduce overhead
|
|
56
|
+
show_delta=true
|
|
57
|
+
|
|
58
|
+
# Show session_id in status line
|
|
59
|
+
show_session=true
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass # Ignore errors creating config
|
|
64
|
+
|
|
65
|
+
def _read_config(self) -> None:
|
|
66
|
+
"""Read settings from config file."""
|
|
67
|
+
try:
|
|
68
|
+
content = self._config_path.read_text()
|
|
69
|
+
for line in content.splitlines():
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if line.startswith("#") or "=" not in line:
|
|
72
|
+
continue
|
|
73
|
+
key, value = line.split("=", 1)
|
|
74
|
+
key = key.strip()
|
|
75
|
+
value = value.strip().lower()
|
|
76
|
+
|
|
77
|
+
if key == "autocompact":
|
|
78
|
+
self.autocompact = value != "false"
|
|
79
|
+
elif key == "token_detail":
|
|
80
|
+
self.token_detail = value != "false"
|
|
81
|
+
elif key == "show_delta":
|
|
82
|
+
self.show_delta = value != "false"
|
|
83
|
+
elif key == "show_session":
|
|
84
|
+
self.show_session = value != "false"
|
|
85
|
+
elif key == "show_io_tokens":
|
|
86
|
+
self.show_io_tokens = value != "false"
|
|
87
|
+
except OSError:
|
|
88
|
+
pass # Use defaults on read error
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
"""Convert config to dictionary."""
|
|
92
|
+
return {
|
|
93
|
+
"autocompact": self.autocompact,
|
|
94
|
+
"token_detail": self.token_detail,
|
|
95
|
+
"show_delta": self.show_delta,
|
|
96
|
+
"show_session": self.show_session,
|
|
97
|
+
"show_io_tokens": self.show_io_tokens,
|
|
98
|
+
}
|