cc-hud 0.1.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-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +158 -0
- package/commands/setup.md +20 -0
- package/dist/index.js +35 -0
- package/dist/render.js +77 -0
- package/dist/stdin.js +18 -0
- package/dist/transcript.js +75 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
- package/src/index.ts +42 -0
- package/src/render.ts +85 -0
- package/src/stdin.ts +21 -0
- package/src/transcript.ts +94 -0
- package/src/types.ts +36 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-hud",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Water"
|
|
5
|
+
},
|
|
6
|
+
"metadata": {
|
|
7
|
+
"description": "Compact statusline HUD for Claude Code",
|
|
8
|
+
"version": "0.1.0"
|
|
9
|
+
},
|
|
10
|
+
"plugins": [
|
|
11
|
+
{
|
|
12
|
+
"name": "cc-hud",
|
|
13
|
+
"source": "./",
|
|
14
|
+
"description": "Compact single-line statusline showing context usage, active agents, and rate limits.",
|
|
15
|
+
"category": "monitoring",
|
|
16
|
+
"tags": ["hud", "statusline", "status-bar", "monitoring", "context", "agents", "rate-limit", "windows", "crash-free", "node", "zero-dependency", "catppuccin", "progress-bar", "usage"]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-hud",
|
|
3
|
+
"description": "Compact statusline HUD for Claude Code — context, agents, rate limits",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Water"
|
|
7
|
+
},
|
|
8
|
+
"commands": [
|
|
9
|
+
"./commands/setup.md"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/WaterTian/cc-hud",
|
|
12
|
+
"repository": "https://github.com/WaterTian/cc-hud",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"keywords": ["hud", "statusline", "status-bar", "monitoring", "context", "agents", "rate-limit", "windows", "crash-free", "node", "zero-dependency", "catppuccin", "progress-bar", "usage"]
|
|
15
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
cc-hud — Claude Code statusline plugin
|
|
3
|
+
Shows: model name, context window usage (progress bar), active subagents, rate limits (5h/7d)
|
|
4
|
+
Pure Node.js, zero dependencies, crash-free on Windows (no Bun)
|
|
5
|
+
Install: /plugin marketplace add WaterTian/cc-hud && /plugin install cc-hud && /cc-hud:setup
|
|
6
|
+
Keywords: claude-code plugin statusline status-bar hud monitoring context-window rate-limit agents windows crash-free zero-dependency catppuccin
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="cc-hud-preview.png" alt="cc-hud preview" width="720" />
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<h1 align="center">CC-HUD</h1>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<strong>A compact, single-line statusline plugin for <a href="https://claude.ai/claude-code">Claude Code</a></strong><br/>
|
|
17
|
+
<sub>Crash-free, zero-dependency status bar — model · context · agents · rate limits</sub>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<code>Model</code> → <code>Context</code> → <code>Agents</code> → <code>Rate Limits</code>
|
|
22
|
+
<br/>
|
|
23
|
+
<sub>everything you need, nothing you don't.</sub>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<a href="#install"><img src="https://img.shields.io/badge/install-3_commands-blueviolet?style=flat-square" alt="install" /></a>
|
|
28
|
+
|
|
29
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen?style=flat-square" alt="zero deps" />
|
|
30
|
+
|
|
31
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-blue?style=flat-square" alt="node >= 18" />
|
|
32
|
+
|
|
33
|
+
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT" />
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<br/>
|
|
37
|
+
|
|
38
|
+
## Why CC-HUD?
|
|
39
|
+
|
|
40
|
+
<table>
|
|
41
|
+
<tr><td>
|
|
42
|
+
|
|
43
|
+
### The Problem
|
|
44
|
+
|
|
45
|
+
Claude Code's native installer bundles [Bun](https://bun.sh), which has a known memory allocator bug on **Windows** ([oven-sh/bun#25082](https://github.com/oven-sh/bun/issues/25082)), causing frequent `pas panic` crashes. Statusline plugins like [jarrodwatts/claude-hud](https://github.com/jarrodwatts/claude-hud) run **on every tick**, amplifying memory pressure and making crashes far more likely.
|
|
46
|
+
|
|
47
|
+
### The Solution
|
|
48
|
+
|
|
49
|
+
CC-HUD is a **crash-free alternative** — pure Node.js, zero dependencies, stateless per-call, ~60ms execution, 2s hard timeout. Designed to keep your status bar running without taking Claude Code down.
|
|
50
|
+
|
|
51
|
+
</td></tr>
|
|
52
|
+
</table>
|
|
53
|
+
|
|
54
|
+
## 为什么做 CC-HUD?
|
|
55
|
+
|
|
56
|
+
<table>
|
|
57
|
+
<tr><td>
|
|
58
|
+
|
|
59
|
+
### 问题
|
|
60
|
+
|
|
61
|
+
Claude Code 原生安装器内嵌 [Bun](https://bun.sh),在 **Windows** 上存在已知内存分配器 bug([oven-sh/bun#25082](https://github.com/oven-sh/bun/issues/25082)),频繁触发 `pas panic` 崩溃。而 [jarrodwatts/claude-hud](https://github.com/jarrodwatts/claude-hud) 等状态栏插件**每次 tick 都会执行**,加剧内存压力,使崩溃更加频繁。
|
|
62
|
+
|
|
63
|
+
### 解决方案
|
|
64
|
+
|
|
65
|
+
CC-HUD 是**不会崩溃的替代方案** — 纯 Node.js、零依赖、无状态调用、~60ms 执行、2s 硬超时。让状态栏稳定运行,不拖垮 Claude Code。
|
|
66
|
+
|
|
67
|
+
</td></tr>
|
|
68
|
+
</table>
|
|
69
|
+
|
|
70
|
+
> [!TIP]
|
|
71
|
+
> **Windows users:** Use `npm i -g @anthropic-ai/claude-code` instead of the native installer to avoid Bun crashes entirely.
|
|
72
|
+
>
|
|
73
|
+
> **Windows 用户:** 建议用 `npm i -g @anthropic-ai/claude-code` 代替原生安装器,彻底规避 Bun 崩溃。
|
|
74
|
+
|
|
75
|
+
<br/>
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
<table>
|
|
80
|
+
<tr>
|
|
81
|
+
<td align="center" width="20%"><h3>█▌</h3><b>Context Bar</b><br/><sub>1/8-precision blocks<br/>80-level granularity</sub></td>
|
|
82
|
+
<td align="center" width="20%"><h3>🎨</h3><b>Color</b><br/><sub><a href="https://github.com/catppuccin/catppuccin">Catppuccin Mocha</a><br/>4-stop gradient</sub></td>
|
|
83
|
+
<td align="center" width="20%"><h3>◐</h3><b>Agents</b><br/><sub>Running subagents<br/>with type & model</sub></td>
|
|
84
|
+
<td align="center" width="20%"><h3>%</h3><b>Rate Limits</b><br/><sub>5h / 7d usage<br/>Pro / Max</sub></td>
|
|
85
|
+
<td align="center" width="20%"><h3>0</h3><b>Dependencies</b><br/><sub>Zero. Node.js<br/>built-ins only</sub></td>
|
|
86
|
+
</tr>
|
|
87
|
+
</table>
|
|
88
|
+
|
|
89
|
+
<br/>
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
Inside Claude Code, run 3 commands:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
/plugin marketplace add WaterTian/cc-hud
|
|
97
|
+
/plugin install cc-hud
|
|
98
|
+
/cc-hud:setup
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Restart Claude Code. **Done.**
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary><b>Manual install</b></summary>
|
|
105
|
+
<br/>
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
git clone https://github.com/WaterTian/cc-hud.git
|
|
109
|
+
cd cc-hud && npm install && npm run build
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Add to `~/.claude/settings.json`:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"statusLine": {
|
|
117
|
+
"type": "command",
|
|
118
|
+
"command": "node /path/to/cc-hud/dist/index.js",
|
|
119
|
+
"padding": 2
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
</details>
|
|
125
|
+
|
|
126
|
+
<br/>
|
|
127
|
+
|
|
128
|
+
## How It Works
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
Claude Code ─── stdin JSON ──→ cc-hud ──→ stdout ──→ status bar
|
|
132
|
+
↘ transcript JSONL (tail 64KB → active agents)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
<table>
|
|
136
|
+
<tr>
|
|
137
|
+
<td align="center"><b>Stateless</b><br/><sub>Fresh process per call<br/>zero memory leaks</sub></td>
|
|
138
|
+
<td align="center"><b>Fast</b><br/><sub>~60ms execution<br/>within 300ms debounce</sub></td>
|
|
139
|
+
<td align="center"><b>Safe</b><br/><sub>2s hard timeout<br/>all IO try-catch</sub></td>
|
|
140
|
+
</tr>
|
|
141
|
+
</table>
|
|
142
|
+
|
|
143
|
+
<br/>
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm run build # compile
|
|
149
|
+
npm test # 13 tests
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
<br/>
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
<p align="center">
|
|
157
|
+
<sub>MIT License © <a href="https://github.com/WaterTian">Water</a></sub>
|
|
158
|
+
</p>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup
|
|
3
|
+
description: Configure cc-hud statusline in Claude Code settings
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Set up the cc-hud statusline. Write the following `statusLine` config into the user's `~/.claude/settings.json` (merge with existing settings, do not overwrite other fields):
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"statusLine": {
|
|
11
|
+
"type": "command",
|
|
12
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/dist/index.js",
|
|
13
|
+
"padding": 2
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Use `${CLAUDE_PLUGIN_ROOT}` which resolves to the plugin's install directory.
|
|
19
|
+
|
|
20
|
+
After writing the config, tell the user to restart Claude Code to see the HUD.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readStdin } from './stdin.js';
|
|
2
|
+
import { parseAgents } from './transcript.js';
|
|
3
|
+
import { render } from './render.js';
|
|
4
|
+
// Hard timeout — never block Claude Code
|
|
5
|
+
const TIMEOUT_MS = 2000;
|
|
6
|
+
setTimeout(() => process.exit(0), TIMEOUT_MS).unref();
|
|
7
|
+
function shortModelName(displayName, id) {
|
|
8
|
+
if (displayName) {
|
|
9
|
+
const stripped = displayName.replace(/\s*\(.*?\)\s*/g, '').trim();
|
|
10
|
+
if (stripped)
|
|
11
|
+
return stripped;
|
|
12
|
+
}
|
|
13
|
+
if (id) {
|
|
14
|
+
const m = id.match(/claude-(\w+)-(\d+)-(\d+)/);
|
|
15
|
+
if (m)
|
|
16
|
+
return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`;
|
|
17
|
+
}
|
|
18
|
+
return 'Claude';
|
|
19
|
+
}
|
|
20
|
+
async function main() {
|
|
21
|
+
const data = await readStdin();
|
|
22
|
+
// Parse transcript in parallel with render prep — no dependency
|
|
23
|
+
const agentsPromise = parseAgents(data.transcript_path);
|
|
24
|
+
const contextPercent = data.context_window?.used_percentage ?? 0;
|
|
25
|
+
const agents = await agentsPromise;
|
|
26
|
+
const renderData = {
|
|
27
|
+
model: shortModelName(data.model?.display_name, data.model?.id),
|
|
28
|
+
contextPercent: Math.round(contextPercent),
|
|
29
|
+
agents,
|
|
30
|
+
fiveHourPercent: data.rate_limits?.five_hour?.used_percentage ?? null,
|
|
31
|
+
sevenDayPercent: data.rate_limits?.seven_day?.used_percentage ?? null,
|
|
32
|
+
};
|
|
33
|
+
console.log(render(renderData));
|
|
34
|
+
}
|
|
35
|
+
main().catch(() => process.exit(0));
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// — Catppuccin Mocha palette (ANSI 256) —
|
|
2
|
+
const RESET = '\x1b[0m';
|
|
3
|
+
const fg = (n) => `\x1b[38;5;${n}m`;
|
|
4
|
+
const GREEN = fg(151); // #a6e3a1 — ok
|
|
5
|
+
const YELLOW = fg(223); // #f9e2af — caution
|
|
6
|
+
const PEACH = fg(216); // #fab387 — warning
|
|
7
|
+
const RED = fg(211); // #f38ba8 — critical
|
|
8
|
+
const TEAL = fg(115); // #94e2d5 — agent accent
|
|
9
|
+
const BLUE = fg(111); // #89b4fa — info accent
|
|
10
|
+
const OVERLAY = fg(243); // #6c7086 — dim/separator
|
|
11
|
+
const SURFACE = fg(238); // #313244 — bar track
|
|
12
|
+
const TEXT = fg(189); // #cdd6f4 — primary text
|
|
13
|
+
// — Bar config —
|
|
14
|
+
const BAR_WIDTH = 10;
|
|
15
|
+
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
16
|
+
const TRACK_CHAR = '░';
|
|
17
|
+
function color(percent) {
|
|
18
|
+
if (percent <= 50)
|
|
19
|
+
return GREEN;
|
|
20
|
+
if (percent <= 70)
|
|
21
|
+
return YELLOW;
|
|
22
|
+
if (percent <= 85)
|
|
23
|
+
return PEACH;
|
|
24
|
+
return RED;
|
|
25
|
+
}
|
|
26
|
+
function progressBar(percent) {
|
|
27
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
28
|
+
const total = (clamped / 100) * BAR_WIDTH;
|
|
29
|
+
const full = Math.floor(total);
|
|
30
|
+
const frac = Math.round((total - full) * 8);
|
|
31
|
+
const empty = BAR_WIDTH - full - (frac > 0 ? 1 : 0);
|
|
32
|
+
const c = color(clamped);
|
|
33
|
+
const bar = c + '█'.repeat(full) +
|
|
34
|
+
(frac > 0 ? BLOCKS[frac] : '') +
|
|
35
|
+
RESET + SURFACE +
|
|
36
|
+
TRACK_CHAR.repeat(Math.max(0, empty)) +
|
|
37
|
+
RESET;
|
|
38
|
+
return `${bar} ${c}${clamped}%${RESET}`;
|
|
39
|
+
}
|
|
40
|
+
function rateSegment(label, percent) {
|
|
41
|
+
if (percent == null)
|
|
42
|
+
return null;
|
|
43
|
+
const clamped = Math.round(Math.max(0, Math.min(100, percent)));
|
|
44
|
+
const c = color(clamped);
|
|
45
|
+
return `${OVERLAY}${label}:${RESET} ${c}${clamped}%${RESET}`;
|
|
46
|
+
}
|
|
47
|
+
function agentSegment(agents) {
|
|
48
|
+
if (agents.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const parts = agents.slice(0, 3).map(a => {
|
|
51
|
+
const model = a.model ? ` ${OVERLAY}[${a.model}]${RESET}` : '';
|
|
52
|
+
return `${TEAL}◐${RESET} ${TEXT}${a.type}${RESET}${model}`;
|
|
53
|
+
});
|
|
54
|
+
return parts.join(' ');
|
|
55
|
+
}
|
|
56
|
+
export function render(data) {
|
|
57
|
+
const segments = [];
|
|
58
|
+
// Model + context bar
|
|
59
|
+
segments.push(`${OVERLAY}[${RESET}${BLUE}${data.model}${RESET}${OVERLAY}]${RESET} ${progressBar(data.contextPercent)}`);
|
|
60
|
+
// Agents (if any)
|
|
61
|
+
const agentStr = agentSegment(data.agents);
|
|
62
|
+
if (agentStr)
|
|
63
|
+
segments.push(agentStr);
|
|
64
|
+
// Rate limits
|
|
65
|
+
const r5 = rateSegment('5h', data.fiveHourPercent);
|
|
66
|
+
const r7 = rateSegment('7d', data.sevenDayPercent);
|
|
67
|
+
if (r5 && r7) {
|
|
68
|
+
segments.push(`${r5} ${r7}`);
|
|
69
|
+
}
|
|
70
|
+
else if (r5) {
|
|
71
|
+
segments.push(r5);
|
|
72
|
+
}
|
|
73
|
+
else if (r7) {
|
|
74
|
+
segments.push(r7);
|
|
75
|
+
}
|
|
76
|
+
return segments.join(` ${OVERLAY}│${RESET} `);
|
|
77
|
+
}
|
package/dist/stdin.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export async function readStdin() {
|
|
2
|
+
if (process.stdin.isTTY)
|
|
3
|
+
return {};
|
|
4
|
+
const chunks = [];
|
|
5
|
+
process.stdin.setEncoding('utf8');
|
|
6
|
+
for await (const chunk of process.stdin) {
|
|
7
|
+
chunks.push(chunk);
|
|
8
|
+
}
|
|
9
|
+
const raw = chunks.join('');
|
|
10
|
+
if (!raw.trim())
|
|
11
|
+
return {};
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { open, stat } from 'node:fs/promises';
|
|
2
|
+
const TAIL_BYTES = 64 * 1024; // 64 KB — agent entries are near the end
|
|
3
|
+
async function readTail(filePath) {
|
|
4
|
+
const info = await stat(filePath);
|
|
5
|
+
if (!info.isFile() || info.size === 0)
|
|
6
|
+
return '';
|
|
7
|
+
const fd = await open(filePath, 'r');
|
|
8
|
+
try {
|
|
9
|
+
const start = Math.max(0, info.size - TAIL_BYTES);
|
|
10
|
+
const len = info.size - start;
|
|
11
|
+
const buf = Buffer.alloc(len);
|
|
12
|
+
await fd.read(buf, 0, len, start);
|
|
13
|
+
const text = buf.toString('utf8');
|
|
14
|
+
if (start > 0) {
|
|
15
|
+
const nl = text.indexOf('\n');
|
|
16
|
+
return nl >= 0 ? text.slice(nl + 1) : '';
|
|
17
|
+
}
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await fd.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function parseAgents(transcriptPath) {
|
|
25
|
+
if (!transcriptPath)
|
|
26
|
+
return [];
|
|
27
|
+
let text;
|
|
28
|
+
try {
|
|
29
|
+
text = await readTail(transcriptPath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!text)
|
|
35
|
+
return [];
|
|
36
|
+
const agents = new Map();
|
|
37
|
+
const completed = new Set();
|
|
38
|
+
const lines = text.split('\n');
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
// Fast pre-filter: skip lines that can't contain agent data
|
|
41
|
+
if (!line.includes('"Agent"') && !line.includes('"tool_result"'))
|
|
42
|
+
continue;
|
|
43
|
+
let entry;
|
|
44
|
+
try {
|
|
45
|
+
entry = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const blocks = entry.message?.content;
|
|
51
|
+
if (!Array.isArray(blocks))
|
|
52
|
+
continue;
|
|
53
|
+
for (const block of blocks) {
|
|
54
|
+
if (block.type === 'tool_use' && block.name === 'Agent' && block.id) {
|
|
55
|
+
const input = block.input ?? {};
|
|
56
|
+
agents.set(block.id, {
|
|
57
|
+
id: block.id,
|
|
58
|
+
type: input.subagent_type ?? 'general-purpose',
|
|
59
|
+
model: input.model,
|
|
60
|
+
description: input.description,
|
|
61
|
+
status: 'running',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
65
|
+
completed.add(block.tool_use_id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const id of completed) {
|
|
70
|
+
const agent = agents.get(id);
|
|
71
|
+
if (agent)
|
|
72
|
+
agent.status = 'completed';
|
|
73
|
+
}
|
|
74
|
+
return [...agents.values()].filter(a => a.status === 'running');
|
|
75
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-hud",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Compact statusline HUD for Claude Code — context, agents, rate limits",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"test": "npm run build && node --test",
|
|
14
|
+
"test:stdin": "npm run build && echo {\"model\":{\"display_name\":\"Opus\"},\"context_window\":{\"used_percentage\":45,\"context_window_size\":200000},\"rate_limits\":{\"five_hour\":{\"used_percentage\":25},\"seven_day\":{\"used_percentage\":10}}} | node dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/",
|
|
18
|
+
"src/",
|
|
19
|
+
"commands/",
|
|
20
|
+
".claude-plugin/"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude-code",
|
|
24
|
+
"claude-code-plugin",
|
|
25
|
+
"statusline",
|
|
26
|
+
"status-bar",
|
|
27
|
+
"hud",
|
|
28
|
+
"monitoring",
|
|
29
|
+
"context-window",
|
|
30
|
+
"rate-limit",
|
|
31
|
+
"agents",
|
|
32
|
+
"windows",
|
|
33
|
+
"crash-free",
|
|
34
|
+
"zero-dependency",
|
|
35
|
+
"catppuccin"
|
|
36
|
+
],
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readStdin } from './stdin.js';
|
|
2
|
+
import { parseAgents } from './transcript.js';
|
|
3
|
+
import { render } from './render.js';
|
|
4
|
+
import type { RenderData } from './types.js';
|
|
5
|
+
|
|
6
|
+
// Hard timeout — never block Claude Code
|
|
7
|
+
const TIMEOUT_MS = 2000;
|
|
8
|
+
setTimeout(() => process.exit(0), TIMEOUT_MS).unref();
|
|
9
|
+
|
|
10
|
+
function shortModelName(displayName?: string, id?: string): string {
|
|
11
|
+
if (displayName) {
|
|
12
|
+
const stripped = displayName.replace(/\s*\(.*?\)\s*/g, '').trim();
|
|
13
|
+
if (stripped) return stripped;
|
|
14
|
+
}
|
|
15
|
+
if (id) {
|
|
16
|
+
const m = id.match(/claude-(\w+)-(\d+)-(\d+)/);
|
|
17
|
+
if (m) return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`;
|
|
18
|
+
}
|
|
19
|
+
return 'Claude';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main(): Promise<void> {
|
|
23
|
+
const data = await readStdin();
|
|
24
|
+
|
|
25
|
+
// Parse transcript in parallel with render prep — no dependency
|
|
26
|
+
const agentsPromise = parseAgents(data.transcript_path);
|
|
27
|
+
|
|
28
|
+
const contextPercent = data.context_window?.used_percentage ?? 0;
|
|
29
|
+
const agents = await agentsPromise;
|
|
30
|
+
|
|
31
|
+
const renderData: RenderData = {
|
|
32
|
+
model: shortModelName(data.model?.display_name, data.model?.id),
|
|
33
|
+
contextPercent: Math.round(contextPercent),
|
|
34
|
+
agents,
|
|
35
|
+
fiveHourPercent: data.rate_limits?.five_hour?.used_percentage ?? null,
|
|
36
|
+
sevenDayPercent: data.rate_limits?.seven_day?.used_percentage ?? null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
console.log(render(renderData));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main().catch(() => process.exit(0));
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RenderData } from './types.js';
|
|
2
|
+
|
|
3
|
+
// — Catppuccin Mocha palette (ANSI 256) —
|
|
4
|
+
const RESET = '\x1b[0m';
|
|
5
|
+
const fg = (n: number) => `\x1b[38;5;${n}m`;
|
|
6
|
+
|
|
7
|
+
const GREEN = fg(151); // #a6e3a1 — ok
|
|
8
|
+
const YELLOW = fg(223); // #f9e2af — caution
|
|
9
|
+
const PEACH = fg(216); // #fab387 — warning
|
|
10
|
+
const RED = fg(211); // #f38ba8 — critical
|
|
11
|
+
const TEAL = fg(115); // #94e2d5 — agent accent
|
|
12
|
+
const BLUE = fg(111); // #89b4fa — info accent
|
|
13
|
+
const OVERLAY = fg(243); // #6c7086 — dim/separator
|
|
14
|
+
const SURFACE = fg(238); // #313244 — bar track
|
|
15
|
+
const TEXT = fg(189); // #cdd6f4 — primary text
|
|
16
|
+
|
|
17
|
+
// — Bar config —
|
|
18
|
+
const BAR_WIDTH = 10;
|
|
19
|
+
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
20
|
+
const TRACK_CHAR = '░';
|
|
21
|
+
|
|
22
|
+
function color(percent: number): string {
|
|
23
|
+
if (percent <= 50) return GREEN;
|
|
24
|
+
if (percent <= 70) return YELLOW;
|
|
25
|
+
if (percent <= 85) return PEACH;
|
|
26
|
+
return RED;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function progressBar(percent: number): string {
|
|
30
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
31
|
+
const total = (clamped / 100) * BAR_WIDTH;
|
|
32
|
+
const full = Math.floor(total);
|
|
33
|
+
const frac = Math.round((total - full) * 8);
|
|
34
|
+
const empty = BAR_WIDTH - full - (frac > 0 ? 1 : 0);
|
|
35
|
+
|
|
36
|
+
const c = color(clamped);
|
|
37
|
+
const bar =
|
|
38
|
+
c + '█'.repeat(full) +
|
|
39
|
+
(frac > 0 ? BLOCKS[frac] : '') +
|
|
40
|
+
RESET + SURFACE +
|
|
41
|
+
TRACK_CHAR.repeat(Math.max(0, empty)) +
|
|
42
|
+
RESET;
|
|
43
|
+
|
|
44
|
+
return `${bar} ${c}${clamped}%${RESET}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rateSegment(label: string, percent: number | null): string | null {
|
|
48
|
+
if (percent == null) return null;
|
|
49
|
+
const clamped = Math.round(Math.max(0, Math.min(100, percent)));
|
|
50
|
+
const c = color(clamped);
|
|
51
|
+
return `${OVERLAY}${label}:${RESET} ${c}${clamped}%${RESET}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentSegment(agents: RenderData['agents']): string | null {
|
|
55
|
+
if (agents.length === 0) return null;
|
|
56
|
+
const parts = agents.slice(0, 3).map(a => {
|
|
57
|
+
const model = a.model ? ` ${OVERLAY}[${a.model}]${RESET}` : '';
|
|
58
|
+
return `${TEAL}◐${RESET} ${TEXT}${a.type}${RESET}${model}`;
|
|
59
|
+
});
|
|
60
|
+
return parts.join(' ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function render(data: RenderData): string {
|
|
64
|
+
const segments: string[] = [];
|
|
65
|
+
|
|
66
|
+
// Model + context bar
|
|
67
|
+
segments.push(`${OVERLAY}[${RESET}${BLUE}${data.model}${RESET}${OVERLAY}]${RESET} ${progressBar(data.contextPercent)}`);
|
|
68
|
+
|
|
69
|
+
// Agents (if any)
|
|
70
|
+
const agentStr = agentSegment(data.agents);
|
|
71
|
+
if (agentStr) segments.push(agentStr);
|
|
72
|
+
|
|
73
|
+
// Rate limits
|
|
74
|
+
const r5 = rateSegment('5h', data.fiveHourPercent);
|
|
75
|
+
const r7 = rateSegment('7d', data.sevenDayPercent);
|
|
76
|
+
if (r5 && r7) {
|
|
77
|
+
segments.push(`${r5} ${r7}`);
|
|
78
|
+
} else if (r5) {
|
|
79
|
+
segments.push(r5);
|
|
80
|
+
} else if (r7) {
|
|
81
|
+
segments.push(r7);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return segments.join(` ${OVERLAY}│${RESET} `);
|
|
85
|
+
}
|
package/src/stdin.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StdinData } from './types.js';
|
|
2
|
+
|
|
3
|
+
export async function readStdin(): Promise<StdinData> {
|
|
4
|
+
if (process.stdin.isTTY) return {};
|
|
5
|
+
|
|
6
|
+
const chunks: string[] = [];
|
|
7
|
+
process.stdin.setEncoding('utf8');
|
|
8
|
+
|
|
9
|
+
for await (const chunk of process.stdin) {
|
|
10
|
+
chunks.push(chunk as string);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const raw = chunks.join('');
|
|
14
|
+
if (!raw.trim()) return {};
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(raw) as StdinData;
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { open, stat } from 'node:fs/promises';
|
|
2
|
+
import type { AgentEntry } from './types.js';
|
|
3
|
+
|
|
4
|
+
interface ContentBlock {
|
|
5
|
+
type: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
input?: Record<string, unknown>;
|
|
9
|
+
tool_use_id?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TranscriptLine {
|
|
13
|
+
message?: { content?: ContentBlock[] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TAIL_BYTES = 64 * 1024; // 64 KB — agent entries are near the end
|
|
17
|
+
|
|
18
|
+
async function readTail(filePath: string): Promise<string> {
|
|
19
|
+
const info = await stat(filePath);
|
|
20
|
+
if (!info.isFile() || info.size === 0) return '';
|
|
21
|
+
|
|
22
|
+
const fd = await open(filePath, 'r');
|
|
23
|
+
try {
|
|
24
|
+
const start = Math.max(0, info.size - TAIL_BYTES);
|
|
25
|
+
const len = info.size - start;
|
|
26
|
+
const buf = Buffer.alloc(len);
|
|
27
|
+
await fd.read(buf, 0, len, start);
|
|
28
|
+
const text = buf.toString('utf8');
|
|
29
|
+
|
|
30
|
+
if (start > 0) {
|
|
31
|
+
const nl = text.indexOf('\n');
|
|
32
|
+
return nl >= 0 ? text.slice(nl + 1) : '';
|
|
33
|
+
}
|
|
34
|
+
return text;
|
|
35
|
+
} finally {
|
|
36
|
+
await fd.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function parseAgents(transcriptPath: string | undefined): Promise<AgentEntry[]> {
|
|
41
|
+
if (!transcriptPath) return [];
|
|
42
|
+
|
|
43
|
+
let text: string;
|
|
44
|
+
try {
|
|
45
|
+
text = await readTail(transcriptPath);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!text) return [];
|
|
51
|
+
|
|
52
|
+
const agents = new Map<string, AgentEntry>();
|
|
53
|
+
const completed = new Set<string>();
|
|
54
|
+
|
|
55
|
+
const lines = text.split('\n');
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
// Fast pre-filter: skip lines that can't contain agent data
|
|
58
|
+
if (!line.includes('"Agent"') && !line.includes('"tool_result"')) continue;
|
|
59
|
+
|
|
60
|
+
let entry: TranscriptLine;
|
|
61
|
+
try {
|
|
62
|
+
entry = JSON.parse(line);
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const blocks = entry.message?.content;
|
|
68
|
+
if (!Array.isArray(blocks)) continue;
|
|
69
|
+
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
if (block.type === 'tool_use' && block.name === 'Agent' && block.id) {
|
|
72
|
+
const input = block.input ?? {};
|
|
73
|
+
agents.set(block.id, {
|
|
74
|
+
id: block.id,
|
|
75
|
+
type: (input.subagent_type as string) ?? 'general-purpose',
|
|
76
|
+
model: input.model as string | undefined,
|
|
77
|
+
description: input.description as string | undefined,
|
|
78
|
+
status: 'running',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
83
|
+
completed.add(block.tool_use_id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const id of completed) {
|
|
89
|
+
const agent = agents.get(id);
|
|
90
|
+
if (agent) agent.status = 'completed';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...agents.values()].filter(a => a.status === 'running');
|
|
94
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface StdinData {
|
|
2
|
+
model?: { id?: string; display_name?: string };
|
|
3
|
+
context_window?: {
|
|
4
|
+
context_window_size?: number;
|
|
5
|
+
used_percentage?: number | null;
|
|
6
|
+
remaining_percentage?: number | null;
|
|
7
|
+
current_usage?: {
|
|
8
|
+
input_tokens?: number;
|
|
9
|
+
output_tokens?: number;
|
|
10
|
+
cache_creation_input_tokens?: number;
|
|
11
|
+
cache_read_input_tokens?: number;
|
|
12
|
+
} | null;
|
|
13
|
+
};
|
|
14
|
+
rate_limits?: {
|
|
15
|
+
five_hour?: { used_percentage?: number | null; resets_at?: number | null } | null;
|
|
16
|
+
seven_day?: { used_percentage?: number | null; resets_at?: number | null } | null;
|
|
17
|
+
} | null;
|
|
18
|
+
transcript_path?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AgentEntry {
|
|
23
|
+
id: string;
|
|
24
|
+
type: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
status: 'running' | 'completed';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RenderData {
|
|
31
|
+
model: string;
|
|
32
|
+
contextPercent: number;
|
|
33
|
+
agents: AgentEntry[];
|
|
34
|
+
fiveHourPercent: number | null;
|
|
35
|
+
sevenDayPercent: number | null;
|
|
36
|
+
}
|