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.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "A customizable statusline for Claude Code that tracks context usage and session limits",
5
5
  "bin": {
6
6
  "ctxline-claude": "bin/install.js"
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
- function getContextBar(remaining) {
212
- const effectiveRemaining = remaining ?? 100;
213
- const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
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.stdin.isTTY) {
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 = '';