ctxline-claude 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/README.md +12 -1
- package/package.json +1 -1
- package/statusline.js +121 -6
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
25
|
<p align="center">
|
|
26
|
-
<img src="preview.svg" alt="Claude Code Statusline">
|
|
26
|
+
<img src="docs/assets/preview.svg" alt="Claude Code Statusline">
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
29
|
<p align="center">
|
|
@@ -77,6 +77,17 @@ chmod +x ~/.claude/hooks/statusline.js
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
**Subagent rows.** The same script also renders per-task rows in the agent panel for running subagents — wire it as a separate `subagentStatusLine` command:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"subagentStatusLine": {
|
|
85
|
+
"type": "command",
|
|
86
|
+
"command": "node ~/.claude/hooks/statusline.js subagent"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
80
91
|
</details>
|
|
81
92
|
|
|
82
93
|
## Update
|
package/package.json
CHANGED
package/statusline.js
CHANGED
|
@@ -54,6 +54,10 @@ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burs
|
|
|
54
54
|
const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
|
|
55
55
|
const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
|
|
56
56
|
|
|
57
|
+
// Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
|
|
58
|
+
// short hard cap of its own instead of the main-mode overallTimeout.
|
|
59
|
+
const SUBAGENT_TIMEOUT_MS = 500;
|
|
60
|
+
|
|
57
61
|
// ANSI color codes
|
|
58
62
|
const colors = {
|
|
59
63
|
reset: '\x1b[0m',
|
|
@@ -96,6 +100,19 @@ function shortenModel(name) {
|
|
|
96
100
|
return name.replace(/\s+context\)/i, ')');
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
// Shorten a resolved model ID (subagent task.model, e.g. "claude-opus-5") for the
|
|
104
|
+
// subagent row: "claude-opus-5" -> "Opus 5", "claude-haiku-4-5-20251001" -> "Haiku 4.5".
|
|
105
|
+
// Distinct from shortenModel, which trims a display name rather than parsing an ID.
|
|
106
|
+
function shortenModelId(id) {
|
|
107
|
+
if (!id) return '';
|
|
108
|
+
const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
|
|
109
|
+
const [family, ...rest] = stripped.split('-');
|
|
110
|
+
if (!family) return stripped;
|
|
111
|
+
const name = family[0].toUpperCase() + family.slice(1);
|
|
112
|
+
const version = rest.join('.');
|
|
113
|
+
return version ? `${name} ${version}` : name;
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
// Tail-truncate an over-long branch name, preserving the leading ticket ID.
|
|
100
117
|
function truncateBranch(name) {
|
|
101
118
|
return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
|
|
@@ -208,10 +225,11 @@ function formatAheadBehind(ab) {
|
|
|
208
225
|
return s;
|
|
209
226
|
}
|
|
210
227
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
// Colored "C<used> <bar>" (e.g. "C45 ███░░░") for an already-clamped 0-100 used
|
|
229
|
+
// percentage. Shared by the main context bar (derived from remaining%) and the
|
|
230
|
+
// subagent row (derived from tokenCount/contextWindowSize) so both use the same
|
|
231
|
+
// thresholds and bar style.
|
|
232
|
+
function renderContextBar(used) {
|
|
215
233
|
const filled = Math.round((used / 100) * BAR_WIDTH);
|
|
216
234
|
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
|
|
217
235
|
|
|
@@ -222,10 +240,15 @@ function getContextBar(remaining) {
|
|
|
222
240
|
else if (used < 80) color = colors.orange;
|
|
223
241
|
else color = colors.blink + colors.red;
|
|
224
242
|
|
|
225
|
-
// Compact label form: "C<used> <bar>" (e.g. "C45 ███░░░"), colored as a whole.
|
|
226
243
|
return `${color}C${used} ${bar}${colors.reset}`;
|
|
227
244
|
}
|
|
228
245
|
|
|
246
|
+
function getContextBar(remaining) {
|
|
247
|
+
const effectiveRemaining = remaining ?? 100;
|
|
248
|
+
const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
|
|
249
|
+
return renderContextBar(used);
|
|
250
|
+
}
|
|
251
|
+
|
|
229
252
|
// Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
|
|
230
253
|
// (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
|
|
231
254
|
// countdown is always recomputed from resetsAt rather than frozen at fetch time.
|
|
@@ -682,11 +705,103 @@ function emit(data) {
|
|
|
682
705
|
});
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
// now - startTime as "45s" / "4m12s" / "2h5m". '' when startTime is missing/unparseable.
|
|
709
|
+
// Format isn't documented by Claude Code, so accept epoch-seconds, epoch-ms, or an ISO
|
|
710
|
+
// string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
|
|
711
|
+
// ~1.7e12 — far enough apart that the threshold is unambiguous for any real timestamp).
|
|
712
|
+
function formatElapsed(startTime) {
|
|
713
|
+
if (startTime == null) return '';
|
|
714
|
+
const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
|
|
715
|
+
const start = new Date(ms).getTime();
|
|
716
|
+
if (Number.isNaN(start)) return '';
|
|
717
|
+
|
|
718
|
+
const diffSec = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
719
|
+
const hours = Math.floor(diffSec / 3600);
|
|
720
|
+
const mins = Math.floor((diffSec % 3600) / 60);
|
|
721
|
+
const secs = diffSec % 60;
|
|
722
|
+
if (hours > 0) return `${hours}h${mins}m`;
|
|
723
|
+
if (mins > 0) return `${mins}m${secs}s`;
|
|
724
|
+
return `${secs}s`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// One subagentStatusLine row: "name │ Model · effort │ C<used> <bar> │ ⏱ <elapsed>".
|
|
728
|
+
// Every segment past name is conditional on its source being present/finite.
|
|
729
|
+
function renderSubagentTask(t) {
|
|
730
|
+
const parts = [t.label || t.name || t.description || 'agent'];
|
|
731
|
+
|
|
732
|
+
const model = shortenModelId(t.model);
|
|
733
|
+
// effort absent = subagent inherits the session effort; show model alone then.
|
|
734
|
+
const effort = t.effort != null ? String(t.effort) : '';
|
|
735
|
+
if (model) {
|
|
736
|
+
parts.push(effort
|
|
737
|
+
? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}`
|
|
738
|
+
: model);
|
|
739
|
+
} else if (effort) {
|
|
740
|
+
parts.push(`${getEffortColor(effort)}${effort}${colors.reset}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (Number.isFinite(t.tokenCount) && Number.isFinite(t.contextWindowSize) && t.contextWindowSize > 0) {
|
|
744
|
+
const used = Math.max(0, Math.min(100, Math.round((t.tokenCount / t.contextWindowSize) * 100)));
|
|
745
|
+
parts.push(renderContextBar(used));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const elapsed = formatElapsed(t.startTime);
|
|
749
|
+
if (elapsed) parts.push(`${colors.dim}⏱ ${elapsed}${colors.reset}`);
|
|
750
|
+
|
|
751
|
+
return parts.join(SEGMENT_SEP);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// subagentStatusLine mode: emit one {id, content} JSON line per task with an id, then
|
|
755
|
+
// exit. No usage/git/todos/cache work — the task objects carry everything needed.
|
|
756
|
+
// Bad payload or a task that fails to render -> emit nothing, keeping default
|
|
757
|
+
// rendering for every task, rather than a partial/broken output.
|
|
758
|
+
function emitSubagent(data) {
|
|
759
|
+
try {
|
|
760
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
761
|
+
const out = tasks
|
|
762
|
+
.filter(t => t && t.id)
|
|
763
|
+
.map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
|
|
764
|
+
.join('\n');
|
|
765
|
+
if (out) {
|
|
766
|
+
// Exit from the write callback: process.exit() would drop output still queued
|
|
767
|
+
// behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
|
|
768
|
+
// callback form reports it instead of throwing, and the answer is the same: exit 0.
|
|
769
|
+
process.stdout.write(out + '\n', () => process.exit(0));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
} catch (e) {}
|
|
773
|
+
process.exit(0);
|
|
774
|
+
}
|
|
775
|
+
|
|
685
776
|
// Entry point, guarded so tests can require this file to exercise payload parsing
|
|
686
777
|
// directly (the /usage response shape is the easiest thing here to get wrong, and it
|
|
687
778
|
// can't be reached through stdin). Running the script normally is unchanged.
|
|
688
779
|
if (require.main === module) {
|
|
689
|
-
if (process.
|
|
780
|
+
if (process.argv[2] === 'subagent') {
|
|
781
|
+
if (process.stdin.isTTY) {
|
|
782
|
+
emitSubagent(null);
|
|
783
|
+
} else {
|
|
784
|
+
let input = '';
|
|
785
|
+
let finished = false;
|
|
786
|
+
|
|
787
|
+
// Single guarded exit shared by all three triggers: timeout, stdin 'end', and
|
|
788
|
+
// stdin 'error' (which can fire before 'end' and would otherwise throw unhandled,
|
|
789
|
+
// breaking the never-throw contract). Whatever accumulated so far gets rendered.
|
|
790
|
+
const finish = () => {
|
|
791
|
+
if (finished) return;
|
|
792
|
+
finished = true;
|
|
793
|
+
clearTimeout(timeout);
|
|
794
|
+
emitSubagent(parseInput(input));
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const timeout = setTimeout(finish, SUBAGENT_TIMEOUT_MS);
|
|
798
|
+
|
|
799
|
+
process.stdin.setEncoding('utf8');
|
|
800
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
801
|
+
process.stdin.on('end', finish);
|
|
802
|
+
process.stdin.on('error', finish);
|
|
803
|
+
}
|
|
804
|
+
} else if (process.stdin.isTTY) {
|
|
690
805
|
emit(null);
|
|
691
806
|
} else {
|
|
692
807
|
let input = '';
|