cc-context-stats 1.5.0 → 1.6.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/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +33 -0
- package/.github/workflows/ci.yml +39 -2
- package/.github/workflows/release.yml +3 -1
- package/CHANGELOG.md +16 -8
- package/CLAUDE.md +54 -0
- package/CODE_OF_CONDUCT.md +59 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/RELEASE_NOTES.md +16 -6
- package/SECURITY.md +44 -0
- package/TODOS.md +72 -0
- package/assets/logo/favicon.svg +17 -14
- package/assets/logo/logo-black.svg +19 -18
- package/assets/logo/logo-full.svg +39 -29
- package/assets/logo/logo-icon.svg +20 -19
- package/assets/logo/logo-mark.svg +21 -19
- package/assets/logo/logo-white.svg +19 -18
- package/assets/logo/logo-wordmark.svg +5 -6
- package/docs/ARCHITECTURE.md +101 -0
- package/docs/CSV_FORMAT.md +40 -0
- package/docs/DEPLOYMENT.md +60 -0
- package/docs/DEVELOPMENT.md +125 -0
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/scripts/statusline-full.sh +11 -3
- package/scripts/statusline-git.sh +8 -1
- package/scripts/statusline-minimal.sh +8 -1
- package/scripts/statusline.js +62 -8
- package/scripts/statusline.py +24 -10
- package/src/claude_statusline/__init__.py +1 -1
- package/src/claude_statusline/cli/context_stats.py +20 -1
- package/src/claude_statusline/core/config.py +5 -4
- package/src/claude_statusline/core/state.py +64 -7
- package/src/claude_statusline/formatters/layout.py +17 -2
- package/tests/bash/test_parity.bats +315 -0
- package/tests/fixtures/json/comma_in_path.json +31 -0
- package/tests/node/rotation.test.js +89 -0
- package/tests/python/test_data_pipeline.py +446 -0
- package/tests/python/test_layout.py +19 -2
- package/tests/python/test_state_rotation_validation.py +232 -0
- package/tests/python/test_statusline.py +2 -0
- package/.claude/commands/context-stats.md +0 -17
- package/.claude/settings.local.json +0 -117
|
@@ -22,12 +22,19 @@ visible_width() {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
get_terminal_width() {
|
|
25
|
+
# When running inside Claude Code's statusline subprocess, $COLUMNS is not set
|
|
26
|
+
# and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
|
|
27
|
+
# so no parts are dropped; Claude Code handles overflow.
|
|
25
28
|
if [[ -n "$COLUMNS" ]]; then
|
|
26
29
|
echo "$COLUMNS"
|
|
27
30
|
else
|
|
28
31
|
local cols
|
|
29
32
|
cols=$(tput cols 2>/dev/null || echo 80)
|
|
30
|
-
|
|
33
|
+
if [[ "$cols" -eq 80 ]]; then
|
|
34
|
+
echo 200
|
|
35
|
+
else
|
|
36
|
+
echo "$cols"
|
|
37
|
+
fi
|
|
31
38
|
fi
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -16,12 +16,19 @@ visible_width() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
get_terminal_width() {
|
|
19
|
+
# When running inside Claude Code's statusline subprocess, $COLUMNS is not set
|
|
20
|
+
# and tput falls back to 80. If COLUMNS is set, trust it. Otherwise use 200
|
|
21
|
+
# so no parts are dropped; Claude Code handles overflow.
|
|
19
22
|
if [[ -n "$COLUMNS" ]]; then
|
|
20
23
|
echo "$COLUMNS"
|
|
21
24
|
else
|
|
22
25
|
local cols
|
|
23
26
|
cols=$(tput cols 2>/dev/null || echo 80)
|
|
24
|
-
|
|
27
|
+
if [[ "$cols" -eq 80 ]]; then
|
|
28
|
+
echo 200
|
|
29
|
+
else
|
|
30
|
+
echo "$cols"
|
|
31
|
+
fi
|
|
25
32
|
fi
|
|
26
33
|
}
|
|
27
34
|
|
package/scripts/statusline.js
CHANGED
|
@@ -28,10 +28,50 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
const { execSync } = require('child_process');
|
|
31
|
+
const crypto = require('crypto');
|
|
31
32
|
const path = require('path');
|
|
32
33
|
const fs = require('fs');
|
|
33
34
|
const os = require('os');
|
|
34
35
|
|
|
36
|
+
const ROTATION_THRESHOLD = 10000;
|
|
37
|
+
const ROTATION_KEEP = 5000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Rotate a state file if it exceeds ROTATION_THRESHOLD lines.
|
|
41
|
+
* Keeps the most recent ROTATION_KEEP lines via atomic temp-file + rename.
|
|
42
|
+
*/
|
|
43
|
+
function maybeRotateStateFile(stateFile) {
|
|
44
|
+
try {
|
|
45
|
+
if (!fs.existsSync(stateFile)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const content = fs.readFileSync(stateFile, 'utf8');
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
// Remove trailing empty element from split if file ends with newline
|
|
51
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
52
|
+
lines.pop();
|
|
53
|
+
}
|
|
54
|
+
if (lines.length <= ROTATION_THRESHOLD) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const keep = lines.slice(-ROTATION_KEEP);
|
|
58
|
+
const tmpFile = stateFile + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(tmpFile, keep.join('\n') + '\n');
|
|
61
|
+
fs.renameSync(tmpFile, stateFile);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(tmpFile);
|
|
65
|
+
} catch {
|
|
66
|
+
/* cleanup best-effort */
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
process.stderr.write(`[statusline] warning: failed to rotate state file: ${e.message}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
// ANSI Colors
|
|
36
76
|
const BLUE = '\x1b[0;34m';
|
|
37
77
|
const MAGENTA = '\x1b[0;35m';
|
|
@@ -46,6 +86,7 @@ const RESET = '\x1b[0m';
|
|
|
46
86
|
* Return the visible width of a string after stripping ANSI escape sequences.
|
|
47
87
|
*/
|
|
48
88
|
function visibleWidth(s) {
|
|
89
|
+
// eslint-disable-next-line no-control-regex
|
|
49
90
|
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
50
91
|
}
|
|
51
92
|
|
|
@@ -96,6 +137,7 @@ function getGitInfo(projectDir) {
|
|
|
96
137
|
cwd: projectDir,
|
|
97
138
|
encoding: 'utf8',
|
|
98
139
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
140
|
+
timeout: 5000,
|
|
99
141
|
}).trim();
|
|
100
142
|
|
|
101
143
|
if (!branch) {
|
|
@@ -107,6 +149,7 @@ function getGitInfo(projectDir) {
|
|
|
107
149
|
cwd: projectDir,
|
|
108
150
|
encoding: 'utf8',
|
|
109
151
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
timeout: 5000,
|
|
110
153
|
});
|
|
111
154
|
const changes = status.split('\n').filter(l => l.trim()).length;
|
|
112
155
|
|
|
@@ -151,8 +194,8 @@ show_delta=true
|
|
|
151
194
|
show_session=true
|
|
152
195
|
`;
|
|
153
196
|
fs.writeFileSync(configPath, defaultConfig);
|
|
154
|
-
} catch {
|
|
155
|
-
|
|
197
|
+
} catch (e) {
|
|
198
|
+
process.stderr.write(`[statusline] warning: failed to create config: ${e.message}\n`);
|
|
156
199
|
}
|
|
157
200
|
return config;
|
|
158
201
|
}
|
|
@@ -181,8 +224,8 @@ show_session=true
|
|
|
181
224
|
config.reducedMotion = valueTrimmed !== 'false';
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
|
-
} catch {
|
|
185
|
-
|
|
227
|
+
} catch (e) {
|
|
228
|
+
process.stderr.write(`[statusline] warning: failed to read config: ${e.message}\n`);
|
|
186
229
|
}
|
|
187
230
|
return config;
|
|
188
231
|
}
|
|
@@ -338,7 +381,10 @@ process.stdin.on('end', () => {
|
|
|
338
381
|
prevTokens = parseInt(lastLine, 10) || 0;
|
|
339
382
|
}
|
|
340
383
|
}
|
|
341
|
-
} catch {
|
|
384
|
+
} catch (e) {
|
|
385
|
+
process.stderr.write(
|
|
386
|
+
`[statusline] warning: failed to read state file: ${e.message}\n`
|
|
387
|
+
);
|
|
342
388
|
prevTokens = 0;
|
|
343
389
|
}
|
|
344
390
|
// Calculate delta (difference in context window usage)
|
|
@@ -372,12 +418,15 @@ process.stdin.on('end', () => {
|
|
|
372
418
|
linesRemoved,
|
|
373
419
|
sessionId || '',
|
|
374
420
|
modelId,
|
|
375
|
-
workspaceProjectDir,
|
|
421
|
+
workspaceProjectDir.replace(/,/g, '_'),
|
|
376
422
|
totalSize,
|
|
377
423
|
].join(',');
|
|
378
424
|
fs.appendFileSync(stateFile, `${stateData}\n`);
|
|
379
|
-
|
|
380
|
-
|
|
425
|
+
maybeRotateStateFile(stateFile);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
process.stderr.write(
|
|
428
|
+
`[statusline] warning: failed to write state file: ${e.message}\n`
|
|
429
|
+
);
|
|
381
430
|
}
|
|
382
431
|
}
|
|
383
432
|
}
|
|
@@ -394,3 +443,8 @@ process.stdin.on('end', () => {
|
|
|
394
443
|
const parts = [base, gitInfo, contextInfo, deltaInfo, acInfo, sessionInfo];
|
|
395
444
|
console.log(fitToWidth(parts, maxWidth));
|
|
396
445
|
});
|
|
446
|
+
|
|
447
|
+
// Export for testing
|
|
448
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
449
|
+
module.exports = { maybeRotateStateFile, ROTATION_THRESHOLD, ROTATION_KEEP };
|
|
450
|
+
}
|
package/scripts/statusline.py
CHANGED
|
@@ -51,8 +51,21 @@ def visible_width(s):
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def get_terminal_width():
|
|
54
|
-
"""Return the terminal width in columns
|
|
55
|
-
|
|
54
|
+
"""Return the terminal width in columns.
|
|
55
|
+
|
|
56
|
+
When running inside Claude Code's statusline subprocess, neither $COLUMNS
|
|
57
|
+
nor tput/shutil can detect the real terminal width (they always return 80).
|
|
58
|
+
If COLUMNS is not explicitly set and shutil falls back to 80, we use a
|
|
59
|
+
generous default of 200 so that no parts are unnecessarily dropped;
|
|
60
|
+
Claude Code's own UI handles any overflow/truncation.
|
|
61
|
+
"""
|
|
62
|
+
# If COLUMNS is explicitly set, trust it (real terminal or test override)
|
|
63
|
+
if os.environ.get("COLUMNS"):
|
|
64
|
+
return shutil.get_terminal_size().columns
|
|
65
|
+
# No COLUMNS env var — likely a Claude Code subprocess with no real TTY.
|
|
66
|
+
# shutil will fall back to 80, which is too narrow. Use 200 instead.
|
|
67
|
+
cols = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
68
|
+
return 200 if cols == 80 else cols
|
|
56
69
|
|
|
57
70
|
|
|
58
71
|
def fit_to_width(parts, max_width):
|
|
@@ -146,8 +159,8 @@ show_delta=true
|
|
|
146
159
|
show_session=true
|
|
147
160
|
"""
|
|
148
161
|
)
|
|
149
|
-
except Exception:
|
|
150
|
-
|
|
162
|
+
except Exception as e:
|
|
163
|
+
sys.stderr.write(f"[statusline] warning: failed to create config: {e}\n")
|
|
151
164
|
return config
|
|
152
165
|
|
|
153
166
|
try:
|
|
@@ -169,8 +182,8 @@ show_session=true
|
|
|
169
182
|
config["show_session"] = value != "false"
|
|
170
183
|
elif key == "show_io_tokens":
|
|
171
184
|
config["show_io_tokens"] = value != "false"
|
|
172
|
-
except
|
|
173
|
-
|
|
185
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
186
|
+
sys.stderr.write(f"[statusline] warning: failed to read config: {e}\n")
|
|
174
187
|
return config
|
|
175
188
|
|
|
176
189
|
|
|
@@ -298,7 +311,8 @@ def main():
|
|
|
298
311
|
prev_tokens = int(last_line.split(",")[1])
|
|
299
312
|
else:
|
|
300
313
|
prev_tokens = int(last_line or 0)
|
|
301
|
-
except Exception:
|
|
314
|
+
except Exception as e:
|
|
315
|
+
sys.stderr.write(f"[statusline] warning: failed to read state file: {e}\n")
|
|
302
316
|
prev_tokens = 0
|
|
303
317
|
# Calculate delta
|
|
304
318
|
delta = used_tokens - prev_tokens
|
|
@@ -329,14 +343,14 @@ def main():
|
|
|
329
343
|
lines_removed,
|
|
330
344
|
session_id or "",
|
|
331
345
|
model_id,
|
|
332
|
-
workspace_project_dir,
|
|
346
|
+
workspace_project_dir.replace(",", "_"),
|
|
333
347
|
total_size,
|
|
334
348
|
]
|
|
335
349
|
)
|
|
336
350
|
with open(state_file, "a") as f:
|
|
337
351
|
f.write(f"{state_data}\n")
|
|
338
|
-
except Exception:
|
|
339
|
-
|
|
352
|
+
except Exception as e:
|
|
353
|
+
sys.stderr.write(f"[statusline] warning: failed to write state file: {e}\n")
|
|
340
354
|
|
|
341
355
|
# Display session_id if enabled
|
|
342
356
|
if show_session and session_id:
|
|
@@ -10,6 +10,7 @@ Options:
|
|
|
10
10
|
--type <cumulative|delta|io|both|all> Graph type to display (default: both)
|
|
11
11
|
--watch, -w [interval] Real-time monitoring mode (default: 2s)
|
|
12
12
|
--no-color Disable color output
|
|
13
|
+
--version, -V Show version and exit
|
|
13
14
|
--help Show this help
|
|
14
15
|
"""
|
|
15
16
|
|
|
@@ -24,7 +25,7 @@ from pathlib import Path
|
|
|
24
25
|
from claude_statusline import __version__
|
|
25
26
|
from claude_statusline.core.colors import ColorManager
|
|
26
27
|
from claude_statusline.core.config import Config
|
|
27
|
-
from claude_statusline.core.state import StateFile
|
|
28
|
+
from claude_statusline.core.state import StateFile, _validate_session_id
|
|
28
29
|
from claude_statusline.graphs.renderer import GraphDimensions, GraphRenderer
|
|
29
30
|
from claude_statusline.graphs.statistics import calculate_deltas
|
|
30
31
|
from claude_statusline.ui.icons import get_activity_tier, get_tier_label
|
|
@@ -59,6 +60,7 @@ OPTIONS:
|
|
|
59
60
|
-w [interval] Set refresh interval in seconds (default: 2)
|
|
60
61
|
--no-watch Show graphs once and exit (disable live monitoring)
|
|
61
62
|
--no-color Disable color output
|
|
63
|
+
--version, -V Show version and exit
|
|
62
64
|
--help Show this help message
|
|
63
65
|
|
|
64
66
|
NOTE:
|
|
@@ -131,13 +133,30 @@ def parse_args() -> argparse.Namespace:
|
|
|
131
133
|
action="store_true",
|
|
132
134
|
help="Show help message",
|
|
133
135
|
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--version",
|
|
138
|
+
"-V",
|
|
139
|
+
action="store_true",
|
|
140
|
+
help="Show version and exit",
|
|
141
|
+
)
|
|
134
142
|
|
|
135
143
|
args = parser.parse_args()
|
|
136
144
|
|
|
145
|
+
if args.version:
|
|
146
|
+
print(f"cc-context-stats {__version__}")
|
|
147
|
+
sys.exit(0)
|
|
148
|
+
|
|
137
149
|
if args.help:
|
|
138
150
|
show_help()
|
|
139
151
|
sys.exit(0)
|
|
140
152
|
|
|
153
|
+
if args.session_id is not None:
|
|
154
|
+
try:
|
|
155
|
+
_validate_session_id(args.session_id)
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
sys.stderr.write(f"Error: {e}\n")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
141
160
|
return args
|
|
142
161
|
|
|
143
162
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
@@ -63,8 +64,8 @@ show_session=true
|
|
|
63
64
|
reduced_motion=false
|
|
64
65
|
"""
|
|
65
66
|
)
|
|
66
|
-
except OSError:
|
|
67
|
-
|
|
67
|
+
except OSError as e:
|
|
68
|
+
sys.stderr.write(f"[statusline] warning: failed to create config {self._config_path}: {e}\n")
|
|
68
69
|
|
|
69
70
|
def _read_config(self) -> None:
|
|
70
71
|
"""Read settings from config file."""
|
|
@@ -90,8 +91,8 @@ reduced_motion=false
|
|
|
90
91
|
self.show_io_tokens = value != "false"
|
|
91
92
|
elif key == "reduced_motion":
|
|
92
93
|
self.reduced_motion = value != "false"
|
|
93
|
-
except OSError:
|
|
94
|
-
|
|
94
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
95
|
+
sys.stderr.write(f"[statusline] warning: failed to read config {self._config_path}: {e}\n")
|
|
95
96
|
|
|
96
97
|
def to_dict(self) -> dict[str, Any]:
|
|
97
98
|
"""Convert config to dictionary."""
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
6
9
|
from dataclasses import dataclass
|
|
7
10
|
from pathlib import Path
|
|
8
11
|
|
|
@@ -115,7 +118,7 @@ class StateEntry:
|
|
|
115
118
|
self.lines_removed,
|
|
116
119
|
self.session_id,
|
|
117
120
|
self.model_id,
|
|
118
|
-
self.workspace_project_dir,
|
|
121
|
+
self.workspace_project_dir.replace(",", "_"),
|
|
119
122
|
self.context_window_size,
|
|
120
123
|
]
|
|
121
124
|
)
|
|
@@ -131,11 +134,30 @@ class StateEntry:
|
|
|
131
134
|
return self.current_input_tokens + self.cache_creation + self.cache_read
|
|
132
135
|
|
|
133
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
|
+
|
|
134
154
|
class StateFile:
|
|
135
155
|
"""Manage state files for token tracking."""
|
|
136
156
|
|
|
137
157
|
STATE_DIR = Path.home() / ".claude" / "statusline"
|
|
138
158
|
OLD_STATE_DIR = Path.home() / ".claude"
|
|
159
|
+
ROTATION_THRESHOLD = 10_000
|
|
160
|
+
ROTATION_KEEP = 5_000
|
|
139
161
|
|
|
140
162
|
def __init__(self, session_id: str | None = None) -> None:
|
|
141
163
|
"""Initialize state file manager.
|
|
@@ -143,6 +165,8 @@ class StateFile:
|
|
|
143
165
|
Args:
|
|
144
166
|
session_id: Optional session ID. If not provided, uses latest session.
|
|
145
167
|
"""
|
|
168
|
+
if session_id is not None:
|
|
169
|
+
_validate_session_id(session_id)
|
|
146
170
|
self.session_id = session_id
|
|
147
171
|
self._ensure_state_dir()
|
|
148
172
|
self._migrate_old_files()
|
|
@@ -211,8 +235,8 @@ class StateFile:
|
|
|
211
235
|
entry = StateEntry.from_csv_line(line)
|
|
212
236
|
if entry:
|
|
213
237
|
entries.append(entry)
|
|
214
|
-
except OSError:
|
|
215
|
-
|
|
238
|
+
except OSError as e:
|
|
239
|
+
sys.stderr.write(f"[statusline] warning: failed to read state history {file_path}: {e}\n")
|
|
216
240
|
|
|
217
241
|
return entries
|
|
218
242
|
|
|
@@ -233,8 +257,8 @@ class StateFile:
|
|
|
233
257
|
for line in reversed(lines):
|
|
234
258
|
if line.strip():
|
|
235
259
|
return StateEntry.from_csv_line(line)
|
|
236
|
-
except OSError:
|
|
237
|
-
|
|
260
|
+
except OSError as e:
|
|
261
|
+
sys.stderr.write(f"[statusline] warning: failed to read last entry {file_path}: {e}\n")
|
|
238
262
|
|
|
239
263
|
return None
|
|
240
264
|
|
|
@@ -247,8 +271,41 @@ class StateFile:
|
|
|
247
271
|
try:
|
|
248
272
|
with open(self.file_path, "a") as f:
|
|
249
273
|
f.write(f"{entry.to_csv_line()}\n")
|
|
250
|
-
except OSError:
|
|
251
|
-
|
|
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")
|
|
252
309
|
|
|
253
310
|
def list_sessions(self) -> list[str]:
|
|
254
311
|
"""List all available session IDs.
|
|
@@ -15,8 +15,23 @@ def visible_width(s: str) -> int:
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def get_terminal_width() -> int:
|
|
18
|
-
"""Return the terminal width in columns
|
|
19
|
-
|
|
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
|
|
20
35
|
|
|
21
36
|
|
|
22
37
|
def fit_to_width(parts: list[str], max_width: int) -> str:
|