@ttigger/claude-status 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ttigger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # @ttigger/claude-status
2
+
3
+ A portable Claude Code statusline HUD — model, project, git, context bar, and real usage limits right in your terminal.
4
+
5
+ ---
6
+
7
+ ## Why
8
+
9
+ - **Usage limits in the CLI** — brings the claude.ai Session (5 h rolling) and Weekly (7 d) usage meters and their reset countdowns into every Claude Code session; no browser tab required.
10
+ - **One command, nothing to clone** — `npx @ttigger/claude-status install` fetches and wires everything; works on any machine with Node ≥ 18.
11
+ - **7 styles including Claude brand** — coral `claude` default, `minimal`, `classic`, `tech` (Nerd Font), `data` (braille), `ascii` (most compatible), `emoji`.
12
+ - **Single-line adaptive layout** — `auto` layout packs everything onto one line and gracefully wraps to two or three lines only when the terminal is narrow.
13
+ - **Theme-aware colors** — reads `~/.claude/settings.json` and mirrors Claude Code's light/dark/system theme; no manual palette config required.
14
+ - **`cc` launcher** — a tiny `cc` shim that forwards all args to `claude`, saving keystrokes on every invocation.
15
+ - **Config with live preview** — `claude-status config set` and `claude-status preview` let you tweak and instantly see the result in your actual terminal colors.
16
+ - **Pure Node, cross-OS** — CommonJS, zero runtime dependencies, works on Windows, macOS, and Linux.
17
+
18
+ ---
19
+
20
+ ## Quick Start
21
+
22
+ ```sh
23
+ npx @ttigger/claude-status install
24
+ ```
25
+
26
+ Then open a new Claude Code session. The HUD appears automatically at the top of every prompt.
27
+
28
+ ---
29
+
30
+ ## The HUD
31
+
32
+ ```
33
+ Opus 4.8·1M | claude-status | main
34
+ Ctx ▰▰▱▱▱▱ 23% 47k | compact 60%
35
+ Sess ▰▰▰▰▰▱ 52% 3h12m | Wk ▰▰▱▱▱ 31% 4d6h
36
+ ```
37
+
38
+ | Element | What it shows |
39
+ |---|---|
40
+ | **Model** | Current model name, e.g. `Opus 4.8`. The `·1M` tag appears when the 1 M-token context window is active. |
41
+ | **Project folder** | The base name of the current working directory. |
42
+ | **Git branch** | Active branch name (blank when not in a git repo). |
43
+ | **Context bar + %** | A progress bar showing how full the context window is, plus a percentage and token count in thousands (e.g. `47k`). |
44
+ | **Auto-compact remaining** | The percentage of context headroom left before Claude Code triggers auto-compaction. **This value is approximate** — the threshold is configurable (default ~83.5 %) and may differ from Claude Code's internal trigger. |
45
+ | **Session usage** | Rolling 5-hour usage bar + percentage + countdown to reset (e.g. `3h12m`). |
46
+ | **Weekly usage** | 7-day usage bar + percentage + countdown to reset (e.g. `4d6h`). |
47
+
48
+ ---
49
+
50
+ ## Styles
51
+
52
+ Seven styles are available. Choose with `claude-status config set style <name>`.
53
+
54
+ ### `claude` (default — Claude coral brand)
55
+
56
+ ```
57
+ Opus 4.8·1M | claude-status | main
58
+ Ctx ▰▰▱▱▱▱ 23% 47k | compact 60%
59
+ Sess ▰▰▰▰▰▱ 52% 3h12m | Wk ▰▰▱▱▱ 31% 4d6h
60
+ ```
61
+
62
+ ### `minimal`
63
+
64
+ ```
65
+ Opus 4.8·1M | claude-status | main
66
+ ctx ▪▪░░░░ 23% 47k · compact 60%
67
+ ses ▪▪▪░░░ 52% 3h12m · wk ▪▪░░░ 31% 4d6h
68
+ ```
69
+
70
+ ### `classic` (fallback default)
71
+
72
+ ```
73
+ Opus 4.8·1M | claude-status | ⎇ main
74
+ Ctx ▓▓░░░░░░ 23% · 47k | compact in 60%
75
+ Sess ▓▓▓▓▓░ 52% · 3h12m | Wk ▓▓░░░ 31% · 4d6h
76
+ ```
77
+
78
+ ### `tech` (needs Nerd Font)
79
+
80
+ ```
81
+ Opus 4.8 1M │ claude-status │ main
82
+ 󰍛 ███▱▱▱▱ 23% 47k │ ♻ 60%
83
+ █████▱ 52% 3h12m │ ██▱▱▱ 31% 4d6h
84
+ ```
85
+
86
+ ### `data`
87
+
88
+ ```
89
+ Opus-4.8[1M] | claude-status | git:main
90
+ CTX ⣿⣿⣀⠀⠀ 23.4% 47.0k/200k | AC 60.0%
91
+ 5H ⣿⣿⣿⣀⠀ 52.1% ⟳3h12m | 7D ⣿⣀⠀⠀⠀ 31.0% ⟳4d6h
92
+ ```
93
+
94
+ ### `ascii` (most compatible)
95
+
96
+ ```
97
+ Opus 4.8 1M | claude-status | main
98
+ Ctx [##------] 23% 47k | compact 60%
99
+ Ses [####----] 52% 3h12m | Wk [##------] 31% 4d6h
100
+ ```
101
+
102
+ ### `emoji`
103
+
104
+ ```
105
+ 🤖 Opus 4.8·1M | 📁 claude-status | 🌿 main
106
+ 🧠 ▓▓░░░░ 23% 47k | ♻️ 60%
107
+ ⏱️ ▓▓▓▓▓░ 52% 3h12m | 📅 ▓░░░░ 31% 4d6h
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Configuration
113
+
114
+ Configuration is stored in `~/.claude/claude-status.config.json`. Only the keys you change are written — it is a **deep merge**, so you never need to specify the full config.
115
+
116
+ ### CLI
117
+
118
+ | Command | Description |
119
+ |---|---|
120
+ | `claude-status config set <key> <value>` | Set a single config key |
121
+ | `claude-status config get <key>` | Print the current value of a key |
122
+ | `claude-status config list` | Print all current settings |
123
+ | `claude-status config reset [key]` | Reset one key (or all) to defaults |
124
+
125
+ ### Dotted keys for nested settings
126
+
127
+ ```sh
128
+ claude-status config set elements.weekly false # hide weekly usage
129
+ claude-status config set colorThresholds.green 40 # green up to 40 %
130
+ claude-status config set colorThresholds.yellow 70 # yellow 40–70 %, red above
131
+ claude-status config set autoCompact.thresholdPct 80 # approximate compaction threshold
132
+ claude-status config set barWidth 10 # wider bars
133
+ claude-status config set style minimal # switch style
134
+ claude-status config set layout two # two-line layout
135
+ ```
136
+
137
+ ### Example config file
138
+
139
+ ```json
140
+ {
141
+ "style": "claude",
142
+ "layout": "auto",
143
+ "barWidth": 8,
144
+ "colorThresholds": {
145
+ "green": 50,
146
+ "yellow": 80
147
+ },
148
+ "elements": {
149
+ "model": true,
150
+ "project": true,
151
+ "gitBranch": true,
152
+ "context": true,
153
+ "autoCompact": true,
154
+ "session": true,
155
+ "weekly": true
156
+ },
157
+ "autoCompact": {
158
+ "thresholdPct": 83.5
159
+ }
160
+ }
161
+ ```
162
+
163
+ Only include keys you want to override — unset keys fall back to defaults.
164
+
165
+ ---
166
+
167
+ ## Preview
168
+
169
+ See the HUD rendered in your actual terminal colors before committing to a style:
170
+
171
+ ```sh
172
+ claude-status preview --style tech
173
+ claude-status preview --style emoji --layout three
174
+ ```
175
+
176
+ `preview` is a live WYSIWYG render — it applies the real theme colors from `~/.claude/settings.json` and prints every layout variant side by side.
177
+
178
+ ---
179
+
180
+ ## Commands
181
+
182
+ | Command | Description |
183
+ |---|---|
184
+ | `npx @ttigger/claude-status install` | Install / re-install the HUD into Claude Code |
185
+ | `claude-status uninstall` | Remove the HUD and restore `~/.claude/settings.json` from backup |
186
+ | `claude-status config set <key> <value>` | Set a config value |
187
+ | `claude-status config get <key>` | Get a config value |
188
+ | `claude-status config list` | List all config values |
189
+ | `claude-status config reset [key]` | Reset to default(s) |
190
+ | `claude-status preview [--style s] [--layout l]` | Live WYSIWYG preview |
191
+ | `claude-status help` | Print help |
192
+
193
+ ---
194
+
195
+ ## Updating / Uninstall
196
+
197
+ **Update to the latest version:**
198
+
199
+ ```sh
200
+ npx @ttigger/claude-status@latest install
201
+ ```
202
+
203
+ **Uninstall:**
204
+
205
+ ```sh
206
+ claude-status uninstall
207
+ ```
208
+
209
+ The uninstaller restores `~/.claude/settings.json` from the `.bak` backup created during install, so your previous hooks configuration is preserved.
210
+
211
+ ---
212
+
213
+ ## `cc` Launcher
214
+
215
+ The `cc` bin is a thin wrapper that calls `claude` and passes all arguments through unchanged. It saves a few keystrokes on every invocation.
216
+
217
+ **Name collision on macOS / Linux:** `cc` is the POSIX name for the system C compiler. The installer detects this and warns you on macOS and Linux so you can make an informed choice. Windows has no such collision.
218
+
219
+ If you would rather use a different alias, pass `--alias` during install:
220
+
221
+ ```sh
222
+ npx @ttigger/claude-status install --alias clc
223
+ ```
224
+
225
+ This registers `clc` instead of `cc` so there is no conflict with the system compiler.
226
+
227
+ ---
228
+
229
+ ## Security
230
+
231
+ No data leaves your machine — the package only reads/writes `~/.claude/settings.json` (with a `.bak` backup), reads your config and theme, runs local `git`, and spawns `claude`. See [SECURITY.md](./SECURITY.md) for the full data boundary statement.
232
+
233
+ ---
234
+
235
+ ## License
236
+
237
+ MIT © 2026 ttigger
package/bin/cc.js ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ const { spawn } = require('node:child_process');
3
+
4
+ const target = process.env.CLAUDE_STATUS_CLAUDE_BIN || 'claude';
5
+ const child = spawn(target, process.argv.slice(2), { stdio: 'inherit', shell: false });
6
+
7
+ child.on('error', (err) => {
8
+ if (err.code === 'ENOENT') {
9
+ process.stderr.write(`cc: could not find "${target}" on PATH. Is Claude Code installed?\n`);
10
+ process.exit(127);
11
+ }
12
+ process.stderr.write(`cc: failed to launch claude: ${err.message}\n`);
13
+ process.exit(1);
14
+ });
15
+ child.on('exit', (code, signal) => {
16
+ if (signal) process.kill(process.pid, signal);
17
+ else process.exit(code == null ? 0 : code);
18
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const { renderHud } = require('../src/render');
6
+ const { loadConfig } = require('../src/config');
7
+ const { capabilities } = require('../src/detect');
8
+ const { currentBranch } = require('../src/git');
9
+
10
+ function readStdin() {
11
+ try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
12
+ }
13
+ function readTheme(settingsPath) {
14
+ try {
15
+ const s = JSON.parse(fs.readFileSync(settingsPath, 'utf8').replace(/^/, ''));
16
+ return /^light/.test(s.theme || '') ? 'light' : 'dark';
17
+ } catch { return 'dark'; }
18
+ }
19
+
20
+ function main() {
21
+ let stdin = {};
22
+ try { stdin = JSON.parse(readStdin()) || {}; } catch { stdin = {}; }
23
+ const claudeDir = path.join(os.homedir(), '.claude');
24
+ const config = loadConfig(path.join(claudeDir, 'claude-status.config.json'));
25
+ const theme = readTheme(path.join(claudeDir, 'settings.json'));
26
+ const caps = capabilities(process.env, process.platform);
27
+ const columns = parseInt(process.env.COLUMNS, 10) || 100;
28
+ const cwd = (stdin.workspace && stdin.workspace.current_dir) || process.cwd();
29
+ const branch = config.elements.gitBranch ? currentBranch(cwd) : null;
30
+ const now = Math.floor(Date.now() / 1000);
31
+ try {
32
+ process.stdout.write(renderHud({ stdin, config, theme, caps, columns, now, branch }));
33
+ } catch {
34
+ // never break the user's session; print nothing on unexpected error
35
+ }
36
+ }
37
+ main();
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ const os = require('node:os');
3
+ const { execSync } = require('node:child_process');
4
+ const { configPath } = require('../src/installer/paths');
5
+ const { runInstall, runUninstall } = require('../src/installer/install');
6
+ const { loadConfig, getDotted, setConfig, resetConfig } = require('../src/config');
7
+ const { CONFIG_SCHEMA, STYLES, LAYOUTS } = require('../src/registry');
8
+ const { renderSample, galleryLine } = require('../src/preview');
9
+
10
+ function parseFlags(args) {
11
+ const flags = {}; const positional = [];
12
+ for (let i = 0; i < args.length; i++) {
13
+ if (args[i].startsWith('--')) { flags[args[i].slice(2)] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true; }
14
+ else positional.push(args[i]);
15
+ }
16
+ return { flags, positional };
17
+ }
18
+
19
+ const HELP = `claude-status — Claude Code usage HUD + cc launcher
20
+
21
+ Usage:
22
+ claude-status install [--style <name>] [--alias <name>] [--dry-run]
23
+ claude-status uninstall
24
+ claude-status config set <key> <value>
25
+ claude-status config get <key>
26
+ claude-status config list
27
+ claude-status config reset [<key>]
28
+ claude-status preview [--style <name>] [--layout <name>]
29
+ claude-status help [styles|layout|colors|cc|troubleshooting]
30
+
31
+ Styles: ${STYLES.map(s => s.name).join(', ')}
32
+ Layouts: ${LAYOUTS.map(l => l.name).join(', ')}`;
33
+
34
+ function cmdInstall(flags) {
35
+ const summary = runInstall({
36
+ home: os.homedir(), env: process.env, platform: process.platform,
37
+ style: typeof flags.style === 'string' ? flags.style : null,
38
+ refreshInterval: 30,
39
+ dryRun: !!flags['dry-run'],
40
+ globalInstall: () => {
41
+ try { execSync('npm install -g @ttigger/claude-status', { stdio: 'ignore' }); } catch {}
42
+ },
43
+ resolveCc: () => { try { return execSync('command -v cc', { stdio: ['ignore','pipe','ignore'] }).toString().trim() || null; } catch { return null; } },
44
+ });
45
+ if (summary.dryRun) {
46
+ console.log(`[dry-run] no changes written. Would install style=${summary.chosenStyle} (recommended ${summary.recommendedStyle})`);
47
+ console.log(`[dry-run] would update ${summary.settingsPath} (with .bak) and write ${summary.configPath}`);
48
+ } else {
49
+ console.log(`✓ installed. style=${summary.chosenStyle} (recommended ${summary.recommendedStyle})`);
50
+ }
51
+ if (summary.ccCollision) console.log('⚠ "cc" already exists on PATH (C compiler?). Consider: claude-status install --alias clc');
52
+ console.log(summary.dryRun ? 'Preview:' : 'Open a new Claude Code session to see the HUD. Preview now:');
53
+ console.log(renderSample({ style: summary.chosenStyle, columns: parseInt(process.env.COLUMNS,10) || 100 }));
54
+ }
55
+
56
+ function cmdConfig(positional, flags) {
57
+ const sub = positional[0];
58
+ const cp = configPath(os.homedir());
59
+ if (sub === 'set') {
60
+ const [, key, value] = positional;
61
+ const r = setConfig(cp, key, value);
62
+ if (!r.ok) { console.error(r.error); process.exit(1); }
63
+ console.log(`✓ ${key} → ${r.value}. 目前效果:`);
64
+ const cfg = loadConfig(cp);
65
+ console.log(renderSample({ style: cfg.style, layout: cfg.layout, columns: parseInt(process.env.COLUMNS,10) || 100 }));
66
+ return;
67
+ }
68
+ if (sub === 'get') { console.log(getDotted(loadConfig(cp), positional[1])); return; }
69
+ if (sub === 'reset') { const r = resetConfig(cp, positional[1]); if (!r.ok){console.error(r.error);process.exit(1);} console.log('✓ reset'); return; }
70
+ if (sub === 'list') {
71
+ const cfg = loadConfig(cp);
72
+ for (const [key, spec] of Object.entries(CONFIG_SCHEMA)) {
73
+ const cur = getDotted(cfg, key);
74
+ const choices = spec.choices ? ` choices: ${spec.choices.join('|')}` : (spec.min!=null?` range: ${spec.min}-${spec.max}`:'');
75
+ console.log(`${key} = ${cur}${choices}`);
76
+ }
77
+ console.log('\nStyles preview:');
78
+ for (const s of STYLES) console.log(` ${s.name === cfg.style ? '●' : '○'} ${s.name.padEnd(8)} ${galleryLine(s.name, 80)}`);
79
+ return;
80
+ }
81
+ console.error('Unknown config subcommand. See: claude-status help'); process.exit(1);
82
+ }
83
+
84
+ function cmdPreview(flags) {
85
+ console.log(renderSample({
86
+ style: typeof flags.style === 'string' ? flags.style : undefined,
87
+ layout: typeof flags.layout === 'string' ? flags.layout : undefined,
88
+ columns: parseInt(process.env.COLUMNS, 10) || 100,
89
+ }));
90
+ }
91
+
92
+ function main() {
93
+ const argv = process.argv.slice(2);
94
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
95
+ console.log(HELP); return;
96
+ }
97
+ if (argv[0] === '--version') { console.log(require('../package.json').version); return; }
98
+ const { flags, positional } = parseFlags(argv.slice(1));
99
+ switch (argv[0]) {
100
+ case 'install': return cmdInstall(flags);
101
+ case 'uninstall': runUninstall({ home: os.homedir() }); console.log('✓ uninstalled'); return;
102
+ case 'config': return cmdConfig(positional, flags);
103
+ case 'preview': return cmdPreview(flags);
104
+ default: console.error(`Unknown command: ${argv[0]}`); console.log(HELP); process.exit(1);
105
+ }
106
+ }
107
+ main();
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@ttigger/claude-status",
3
+ "version": "0.1.0",
4
+ "description": "A portable Claude Code statusline HUD (usage, model, git, context) with 7 styles, a cc launcher, and a config CLI with live preview.",
5
+ "license": "MIT",
6
+ "engines": { "node": ">=18" },
7
+ "bin": {
8
+ "claude-status": "bin/claude-status.js",
9
+ "claude-status-render": "bin/claude-status-render.js",
10
+ "cc": "bin/cc.js"
11
+ },
12
+ "files": ["bin/", "src/", "README.md", "LICENSE"],
13
+ "scripts": { "test": "node --test" },
14
+ "keywords": ["claude", "claude-code", "statusline", "cli", "usage", "hud"],
15
+ "repository": { "type": "git", "url": "git+https://github.com/TTigger/claude-status.git" },
16
+ "homepage": "https://github.com/TTigger/claude-status#readme"
17
+ }
package/src/config.js ADDED
@@ -0,0 +1,87 @@
1
+ const fs = require('node:fs');
2
+ const { DEFAULT_CONFIG, setDotted } = require('./defaults');
3
+ const { CONFIG_SCHEMA } = require('./registry');
4
+
5
+ function isObj(v) { return v && typeof v === 'object' && !Array.isArray(v); }
6
+
7
+ function deepMerge(base, over) {
8
+ const out = Array.isArray(base) ? base.slice() : { ...base };
9
+ for (const [k, v] of Object.entries(over || {})) {
10
+ out[k] = isObj(v) && isObj(out[k]) ? deepMerge(out[k], v) : v;
11
+ }
12
+ return out;
13
+ }
14
+
15
+ function loadConfig(configPath) {
16
+ try {
17
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^/, ''));
18
+ return deepMerge(DEFAULT_CONFIG, raw);
19
+ } catch {
20
+ return deepMerge(DEFAULT_CONFIG, {});
21
+ }
22
+ }
23
+
24
+ function coerceValue(key, raw) {
25
+ const spec = CONFIG_SCHEMA[key];
26
+ if (!spec) return { ok: false, error: `Unknown setting: ${key}` };
27
+ switch (spec.type) {
28
+ case 'choice':
29
+ return spec.choices.includes(raw)
30
+ ? { ok: true, value: raw }
31
+ : { ok: false, error: `Invalid value "${raw}". Choices: ${spec.choices.join(', ')}` };
32
+ case 'string':
33
+ return { ok: true, value: String(raw) };
34
+ case 'bool':
35
+ if (/^(true|1|yes|on)$/i.test(raw)) return { ok: true, value: true };
36
+ if (/^(false|0|no|off)$/i.test(raw)) return { ok: true, value: false };
37
+ return { ok: false, error: `Expected boolean, got "${raw}"` };
38
+ case 'int':
39
+ case 'number': {
40
+ const n = spec.type === 'int' ? parseInt(raw, 10) : parseFloat(raw);
41
+ if (Number.isNaN(n)) return { ok: false, error: `Expected number, got "${raw}"` };
42
+ if (spec.min != null && n < spec.min) return { ok: false, error: `Min is ${spec.min}` };
43
+ if (spec.max != null && n > spec.max) return { ok: false, error: `Max is ${spec.max}` };
44
+ return { ok: true, value: n };
45
+ }
46
+ case 'intOrAuto':
47
+ if (raw === 'auto') return { ok: true, value: 'auto' };
48
+ return coerceValue.asInt(key, raw, spec);
49
+ default:
50
+ return { ok: false, error: `Unsupported type for ${key}` };
51
+ }
52
+ }
53
+ coerceValue.asInt = (key, raw, spec) => {
54
+ const n = parseInt(raw, 10);
55
+ if (Number.isNaN(n)) return { ok: false, error: `Expected integer or "auto"` };
56
+ if (spec.min != null && n < spec.min) return { ok: false, error: `Min is ${spec.min}` };
57
+ if (spec.max != null && n > spec.max) return { ok: false, error: `Max is ${spec.max}` };
58
+ return { ok: true, value: n };
59
+ };
60
+
61
+ function getDotted(obj, key) {
62
+ return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
63
+ }
64
+
65
+ function readRaw(configPath) {
66
+ try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; }
67
+ }
68
+
69
+ function setConfig(configPath, key, rawValue) {
70
+ const c = coerceValue(key, rawValue);
71
+ if (!c.ok) return c;
72
+ const raw = readRaw(configPath);
73
+ setDotted(raw, key, c.value);
74
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
75
+ return { ok: true, value: c.value };
76
+ }
77
+
78
+ function resetConfig(configPath, key) {
79
+ const raw = readRaw(configPath);
80
+ if (!key) { try { fs.unlinkSync(configPath); } catch {} return { ok: true }; }
81
+ if (!(key in CONFIG_SCHEMA)) return { ok: false, error: `Unknown setting: ${key}` };
82
+ setDotted(raw, key, CONFIG_SCHEMA[key].default);
83
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n');
84
+ return { ok: true };
85
+ }
86
+
87
+ module.exports = { deepMerge, loadConfig, coerceValue, getDotted, setConfig, resetConfig };
@@ -0,0 +1,22 @@
1
+ const { CONFIG_SCHEMA } = require('./registry');
2
+
3
+ function setDotted(obj, path, value) {
4
+ const parts = path.split('.');
5
+ let cur = obj;
6
+ for (let i = 0; i < parts.length - 1; i++) {
7
+ cur[parts[i]] = cur[parts[i]] || {};
8
+ cur = cur[parts[i]];
9
+ }
10
+ cur[parts[parts.length - 1]] = value;
11
+ }
12
+
13
+ function buildDefaults() {
14
+ const cfg = {};
15
+ for (const [key, spec] of Object.entries(CONFIG_SCHEMA)) {
16
+ setDotted(cfg, key, spec.default);
17
+ }
18
+ return cfg;
19
+ }
20
+
21
+ const DEFAULT_CONFIG = buildDefaults();
22
+ module.exports = { DEFAULT_CONFIG, setDotted };
package/src/detect.js ADDED
@@ -0,0 +1,17 @@
1
+ function capabilities(env, platform) {
2
+ const truecolor = /^(truecolor|24bit)$/i.test(env.COLORTERM || '');
3
+ const color256 = truecolor || /256/.test(env.TERM || '');
4
+ const unicode = platform !== 'win32' || !!env.WT_SESSION;
5
+ // Nerd Font cannot be reliably auto-detected; honor explicit opt-in.
6
+ const nerd = env.CLAUDE_STATUS_NERD === '1';
7
+ return { truecolor, color256, unicode, nerd };
8
+ }
9
+
10
+ function recommendStyle(caps) {
11
+ if (!caps.unicode) return 'ascii';
12
+ if (caps.nerd) return 'tech';
13
+ if (caps.truecolor || caps.color256) return 'claude';
14
+ return 'classic';
15
+ }
16
+
17
+ module.exports = { capabilities, recommendStyle };
@@ -0,0 +1,46 @@
1
+ const { clampPct } = require('./format');
2
+
3
+ function basename(p) {
4
+ if (!p) return null;
5
+ const parts = String(p).split(/[\\/]/).filter(Boolean);
6
+ return parts.length ? parts[parts.length - 1] : null;
7
+ }
8
+
9
+ function sumUsage(u) {
10
+ if (!u || typeof u !== 'object') return 0;
11
+ let total = 0;
12
+ for (const v of Object.values(u)) if (typeof v === 'number') total += v;
13
+ return total;
14
+ }
15
+
16
+ function buildElements(stdin, opts) {
17
+ const cw = stdin.context_window || {};
18
+ const size = cw.context_window_size || 200000;
19
+ const usedPct = typeof cw.used_percentage === 'number' ? cw.used_percentage : 0;
20
+ let usedTokens = sumUsage(cw.current_usage);
21
+ if (!usedTokens) usedTokens = (size * usedPct) / 100;
22
+
23
+ const sizeKnown = Boolean(cw.context_window_size);
24
+ const model = stdin.model
25
+ ? { name: stdin.model.display_name || stdin.model.id || '?',
26
+ context1m: sizeKnown ? size === 1000000 : /\[1m\]/.test(stdin.model.id || '') }
27
+ : null;
28
+
29
+ const rl = stdin.rate_limits || {};
30
+ const mk = (r) => (r ? { pct: clampPct(r.used_percentage), resetsAt: r.resets_at } : null);
31
+
32
+ const acLeft = Math.max(0, Math.round(opts.autoCompactThresholdPct - usedPct));
33
+
34
+ return {
35
+ model,
36
+ project: basename((stdin.workspace || {}).project_dir) ||
37
+ basename((stdin.workspace || {}).current_dir),
38
+ branch: null, // resolved by render caller via git
39
+ context: { pct: clampPct(usedPct), tokensK: Math.round(usedTokens / 1000), sizeK: Math.round(size / 1000) },
40
+ autoCompact: { leftPct: acLeft },
41
+ session: mk(rl.five_hour),
42
+ weekly: mk(rl.seven_day),
43
+ };
44
+ }
45
+
46
+ module.exports = { buildElements, basename };
package/src/engine.js ADDED
@@ -0,0 +1,84 @@
1
+ const { bar, tier, humanizeDuration, tokensK } = require('./format');
2
+ const { colorize } = require('./palette');
3
+
4
+ function renderMetric({ label, pct, suffix, style, palette, thresholds, barWidth }) {
5
+ const tierName = tier(pct, thresholds);
6
+ const glyphs = style.bar;
7
+ const [open, close] = style.barWrap;
8
+ const filledStr = bar(pct, barWidth, { full: glyphs.full, empty: glyphs.empty });
9
+ // color the whole bar interior + percent by tier; brackets/label uncolored
10
+ const coloredBar = open + colorize(filledStr, tierName, palette) + close;
11
+ const coloredPct = colorize(`${pct}%`, tierName, palette);
12
+ const parts = [label, coloredBar, coloredPct];
13
+ if (suffix) parts.push(suffix);
14
+ return parts.filter(Boolean).join(' ');
15
+ }
16
+
17
+ function fmtReset(resetsAt, now, precision) {
18
+ if (!resetsAt) return '';
19
+ const d = humanizeDuration(resetsAt - now);
20
+ if (precision === 'short') return d.replace(/^(\d+[dh])\d+[hm]$/, '$1'); // 3h12m->3h, 4d6h->4d
21
+ return d;
22
+ }
23
+
24
+ function buildParts({ els, style, palette, config, now, opts }) {
25
+ const parts = [];
26
+ const L = style.labels;
27
+ const icons = style.icons || {};
28
+ const lc = (s) => (style.lowercase ? s.toLowerCase() : s);
29
+ const push = (key, text, group) => { if (text) parts.push({ key, text, group }); };
30
+ const thresholds = config.colorThresholds;
31
+ const barWidth = config.barWidth === 'auto' ? 8 : config.barWidth;
32
+
33
+ // env group
34
+ if (config.elements.model && els.model) {
35
+ const name = els.model.name + (els.model.context1m ? '·1M' : '');
36
+ push('model', (icons.model ? icons.model + ' ' : '') + name, 'env');
37
+ }
38
+ if (config.elements.project && els.project) {
39
+ push('project', (icons.project ? icons.project + ' ' : '') + els.project, 'env');
40
+ }
41
+ if (config.elements.gitBranch && els.branch) {
42
+ const lbl = L.branch ? L.branch + (icons.project ? ' ' : '') : '';
43
+ push('branch', (lbl ? lbl + ' ' : '') + els.branch, 'env');
44
+ }
45
+
46
+ // context group
47
+ if (config.elements.context && els.context) {
48
+ const c = els.context;
49
+ let suffix = '';
50
+ if (opts.includeTokens) {
51
+ suffix = style.rawTokens
52
+ ? `${tokensK(c.tokensK * 1000, style.decimals)}/${c.sizeK}k`
53
+ : tokensK(c.tokensK * 1000, style.decimals);
54
+ }
55
+ const text = opts.bars
56
+ ? renderMetric({ label: lc(L.ctx), pct: c.pct, suffix, style, palette, thresholds, barWidth })
57
+ : `${lc(L.ctx)} ${colorizePct(c.pct, thresholds, palette)}${suffix ? ' ' + suffix : ''}`;
58
+ push('context', text, 'context');
59
+ }
60
+ if (config.elements.autoCompact && els.autoCompact && opts.includeAutoCompact) {
61
+ push('autoCompact', `${lc(L.ac)} ${els.autoCompact.leftPct}%`, 'context');
62
+ }
63
+
64
+ // limits group
65
+ const limit = (key, lbl, el) => {
66
+ if (!el) return;
67
+ const reset = fmtReset(el.resetsAt, now, opts.resetPrecision);
68
+ const text = opts.bars
69
+ ? renderMetric({ label: lc(lbl), pct: el.pct, suffix: reset, style, palette, thresholds, barWidth })
70
+ : `${lc(lbl)} ${colorizePct(el.pct, thresholds, palette)}${reset ? ' ' + reset : ''}`;
71
+ push(key, text, 'limits');
72
+ };
73
+ if (config.elements.session) limit('session', L.sess, els.session);
74
+ if (config.elements.weekly) limit('weekly', L.wk, els.weekly);
75
+
76
+ return parts;
77
+ }
78
+
79
+ // helper: colored "NN%" without a bar
80
+ function colorizePct(pct, thresholds, palette) {
81
+ return colorize(`${pct}%`, tier(pct, thresholds), palette);
82
+ }
83
+
84
+ module.exports = { renderMetric, buildParts };
@@ -0,0 +1,18 @@
1
+ // Representative statusline stdin JSON for preview + tests.
2
+ const SAMPLE = {
3
+ model: { id: 'claude-opus-4-8[1m]', display_name: 'Opus 4.8' },
4
+ workspace: { project_dir: '/home/u/claude-status', current_dir: '/home/u/claude-status' },
5
+ worktree: { branch: 'main' },
6
+ context_window: {
7
+ context_window_size: 1000000,
8
+ used_percentage: 23.5,
9
+ current_usage: { input_tokens: 40000, cache_read_input_tokens: 7000 },
10
+ },
11
+ rate_limits: {
12
+ five_hour: { used_percentage: 52, resets_at: 1717400000 },
13
+ seven_day: { used_percentage: 31, resets_at: 1717755680 },
14
+ },
15
+ };
16
+ // "now" so sample resets render as 3h12m / 4d6h deterministically:
17
+ const SAMPLE_NOW = 1717400000 - (3 * 3600 + 12 * 60);
18
+ module.exports = { SAMPLE, SAMPLE_NOW };
package/src/format.js ADDED
@@ -0,0 +1,38 @@
1
+ function clampPct(n) {
2
+ if (typeof n !== 'number' || Number.isNaN(n)) return 0;
3
+ return Math.max(0, Math.min(100, Math.round(n)));
4
+ }
5
+
6
+ function tier(pct, thresholds) {
7
+ const { green, yellow } = thresholds;
8
+ if (pct <= green) return 'low';
9
+ if (pct <= yellow) return 'mid';
10
+ return 'high';
11
+ }
12
+
13
+ function bar(pct, width, glyphs) {
14
+ const p = clampPct(pct);
15
+ const filled = Math.round((p / 100) * width);
16
+ return glyphs.full.repeat(filled) + glyphs.empty.repeat(width - filled);
17
+ }
18
+
19
+ function humanizeDuration(seconds) {
20
+ let s = Math.floor(seconds);
21
+ if (s <= 0) return s < 0 ? '0m' : '<1m';
22
+ if (s < 60) return '<1m';
23
+ const d = Math.floor(s / 86400); s -= d * 86400;
24
+ const h = Math.floor(s / 3600); s -= h * 3600;
25
+ const m = Math.floor(s / 60);
26
+ if (d > 0) return `${d}d${h}h`;
27
+ if (h > 0) return `${h}h${m}m`;
28
+ return `${m}m`;
29
+ }
30
+
31
+ function tokensK(tokens, decimals = false) {
32
+ const k = tokens / 1000;
33
+ return decimals ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
34
+ }
35
+
36
+ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
37
+
38
+ module.exports = { clampPct, tier, bar, humanizeDuration, tokensK, stripAnsi };
package/src/git.js ADDED
@@ -0,0 +1,14 @@
1
+ const { execFileSync } = require('node:child_process');
2
+
3
+ function currentBranch(cwd) {
4
+ try {
5
+ const out = execFileSync('git', ['branch', '--show-current'],
6
+ { cwd, timeout: 500, stdio: ['ignore', 'pipe', 'ignore'] });
7
+ const b = out.toString().trim();
8
+ return b || null;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ module.exports = { currentBranch };
@@ -0,0 +1,12 @@
1
+ function aliasSnippet(shell, name) {
2
+ if (shell === 'powershell') return `Set-Alias ${name} cc`;
3
+ return `alias ${name}='cc'`;
4
+ }
5
+
6
+ // resolver() returns a path string if `cc` exists on PATH, else null.
7
+ function ccCollides(platform, resolver) {
8
+ if (platform === 'win32') return false; // no C-compiler `cc` collision on Windows
9
+ return !!resolver();
10
+ }
11
+
12
+ module.exports = { aliasSnippet, ccCollides };
@@ -0,0 +1,62 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { settingsPath, configPath, backupPath, claudeDir } = require('./paths');
4
+ const { mergeStatusLine, readSettings, writeSettingsWithBackup } = require('./settings');
5
+ const { capabilities, recommendStyle } = require('../detect');
6
+ const { ccCollides } = require('./alias');
7
+ const { CONFIG_SCHEMA } = require('../registry');
8
+
9
+ function runInstall(opts) {
10
+ const { home, env, platform, style, refreshInterval, globalInstall, resolveCc, dryRun } = opts;
11
+
12
+ const caps = capabilities(env, platform);
13
+ const recommended = recommendStyle(caps);
14
+ const chosen = style || recommended;
15
+ const cp = configPath(home);
16
+ const refresh = refreshInterval || CONFIG_SCHEMA.refreshIntervalSec.default;
17
+
18
+ // --dry-run reports what would happen but touches nothing on disk (spec §8).
19
+ if (!dryRun) {
20
+ fs.mkdirSync(claudeDir(home), { recursive: true });
21
+
22
+ if (typeof globalInstall === 'function') globalInstall(); // npm i -g (no-op in tests)
23
+
24
+ // write config only if absent, to preserve user edits
25
+ if (!fs.existsSync(cp)) {
26
+ fs.writeFileSync(cp, JSON.stringify({ style: chosen }, null, 2) + '\n');
27
+ } else if (style) {
28
+ const raw = JSON.parse(fs.readFileSync(cp, 'utf8'));
29
+ raw.style = chosen;
30
+ fs.writeFileSync(cp, JSON.stringify(raw, null, 2) + '\n');
31
+ }
32
+
33
+ const next = mergeStatusLine(readSettings(settingsPath(home)), 'claude-status-render', refresh);
34
+ writeSettingsWithBackup(settingsPath(home), backupPath(home), next);
35
+ }
36
+
37
+ return {
38
+ recommendedStyle: recommended,
39
+ chosenStyle: chosen,
40
+ caps,
41
+ ccCollision: ccCollides(platform, resolveCc || (() => null)),
42
+ settingsPath: settingsPath(home),
43
+ configPath: cp,
44
+ dryRun: !!dryRun,
45
+ };
46
+ }
47
+
48
+ function runUninstall(opts) {
49
+ const { home } = opts;
50
+ const sp = settingsPath(home), bp = backupPath(home);
51
+ if (fs.existsSync(bp)) {
52
+ fs.copyFileSync(bp, sp);
53
+ return { restored: true };
54
+ }
55
+ // no backup: just strip statusLine
56
+ const s = readSettings(sp);
57
+ delete s.statusLine;
58
+ fs.writeFileSync(sp, JSON.stringify(s, null, 2) + '\n');
59
+ return { restored: false };
60
+ }
61
+
62
+ module.exports = { runInstall, runUninstall };
@@ -0,0 +1,10 @@
1
+ const path = require('node:path');
2
+ const os = require('node:os');
3
+
4
+ function home(h) { return h || os.homedir(); }
5
+ function claudeDir(h) { return path.join(home(h), '.claude'); }
6
+ function settingsPath(h) { return path.join(claudeDir(h), 'settings.json'); }
7
+ function configPath(h) { return path.join(claudeDir(h), 'claude-status.config.json'); }
8
+ function backupPath(h) { return path.join(claudeDir(h), 'settings.json.bak'); }
9
+
10
+ module.exports = { claudeDir, settingsPath, configPath, backupPath };
@@ -0,0 +1,21 @@
1
+ const fs = require('node:fs');
2
+
3
+ function mergeStatusLine(settings, command, refreshInterval) {
4
+ return { ...settings, statusLine: { type: 'command', command, refreshInterval } };
5
+ }
6
+
7
+ function readSettings(p) {
8
+ // Strip a leading UTF-8 BOM (some editors add one) so JSON.parse doesn't throw
9
+ // and silently discard the user's existing settings on merge.
10
+ try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, '')); } catch { return {}; }
11
+ }
12
+
13
+ function writeSettingsWithBackup(settingsPath, backupPath, nextObj) {
14
+ if (fs.existsSync(settingsPath)) {
15
+ fs.copyFileSync(settingsPath, backupPath);
16
+ }
17
+ fs.mkdirSync(require('node:path').dirname(settingsPath), { recursive: true });
18
+ fs.writeFileSync(settingsPath, JSON.stringify(nextObj, null, 2) + '\n');
19
+ }
20
+
21
+ module.exports = { mergeStatusLine, readSettings, writeSettingsWithBackup };
package/src/layout.js ADDED
@@ -0,0 +1,51 @@
1
+ function visibleWidth(s) { return s.replace(/\x1b\[[0-9;]*m/g, '').length; }
2
+
3
+ const FULL_OPTS = { includeTokens: true, includeAutoCompact: true, resetPrecision: 'full', bars: true };
4
+
5
+ const SHRINK = [
6
+ (o) => ({ ...o, includeTokens: false }),
7
+ (o) => ({ ...o, includeAutoCompact: false }),
8
+ (o) => ({ ...o, resetPrecision: 'short' }),
9
+ (o) => ({ ...o, bars: false }),
10
+ (o) => ({ ...o, dropBranch: true }),
11
+ ];
12
+
13
+ function joinParts(parts, sep, dropBranch) {
14
+ return parts.filter(p => !(dropBranch && p.key === 'branch'))
15
+ .map(p => p.text).join(sep);
16
+ }
17
+
18
+ function byGroup(parts, group, sep) {
19
+ return parts.filter(p => p.group === group).map(p => p.text).join(sep);
20
+ }
21
+
22
+ function layoutLines(build, layout, columns, sep) {
23
+ if (layout === 'three') {
24
+ const p = build(FULL_OPTS);
25
+ return [byGroup(p, 'env', sep), byGroup(p, 'context', sep), byGroup(p, 'limits', sep)]
26
+ .filter(Boolean).join('\n');
27
+ }
28
+ if (layout === 'two') {
29
+ const p = build(FULL_OPTS);
30
+ const line1 = [byGroup(p, 'env', sep), byGroup(p, 'context', sep)].filter(Boolean).join(sep);
31
+ const line2 = byGroup(p, 'limits', sep);
32
+ return [line1, line2].filter(Boolean).join('\n');
33
+ }
34
+ if (layout === 'oneline') {
35
+ const p = build({ ...FULL_OPTS, includeTokens: false, includeAutoCompact: false });
36
+ return joinParts(p, sep, false);
37
+ }
38
+ let opts = { ...FULL_OPTS };
39
+ let line = joinParts(build(opts), sep, false);
40
+ for (const step of SHRINK) {
41
+ if (visibleWidth(line) <= columns) break;
42
+ opts = step(opts);
43
+ line = joinParts(build(opts), sep, opts.dropBranch);
44
+ }
45
+ if (visibleWidth(line) <= columns) return line;
46
+ const two = layoutLines(build, 'two', columns, sep);
47
+ if (two.split('\n').every(l => visibleWidth(l) <= columns)) return two;
48
+ return layoutLines(build, 'three', columns, sep);
49
+ }
50
+
51
+ module.exports = { layoutLines, visibleWidth };
package/src/palette.js ADDED
@@ -0,0 +1,36 @@
1
+ const C256 = {
2
+ traffic: {
3
+ light: { low: 28, mid: 166, high: 160 },
4
+ dark: { low: 40, mid: 220, high: 196 },
5
+ },
6
+ coral: {
7
+ light: { low: 216, mid: 173, high: 167 },
8
+ dark: { low: 216, mid: 173, high: 167 },
9
+ },
10
+ };
11
+ const C8 = {
12
+ traffic: { low: 32, mid: 33, high: 31 },
13
+ coral: { low: 33, mid: 33, high: 31 },
14
+ };
15
+
16
+ function code256(n) { return `\x1b[38;5;${n}m`; }
17
+ function code8(n) { return `\x1b[${n}m`; }
18
+
19
+ function resolvePalette(mode, theme, caps) {
20
+ const t = theme === 'light' ? 'light' : 'dark';
21
+ let low, mid, high;
22
+ if (caps && caps.color256) {
23
+ const set = C256[mode][t];
24
+ low = code256(set.low); mid = code256(set.mid); high = code256(set.high);
25
+ } else {
26
+ const set = C8[mode];
27
+ low = code8(set.low); mid = code8(set.mid); high = code8(set.high);
28
+ }
29
+ return { low, mid, high, dim: '\x1b[2m', reset: '\x1b[0m' };
30
+ }
31
+
32
+ function colorize(text, tierName, palette) {
33
+ return palette[tierName] + text + palette.reset;
34
+ }
35
+
36
+ module.exports = { resolvePalette, colorize };
package/src/preview.js ADDED
@@ -0,0 +1,21 @@
1
+ const { renderHud } = require('./render');
2
+ const { DEFAULT_CONFIG } = require('./defaults');
3
+ const { deepMerge } = require('./config');
4
+ const { SAMPLE, SAMPLE_NOW } = require('./fixtures');
5
+
6
+ function renderSample({ style, layout, columns, theme = 'dark' }) {
7
+ const config = deepMerge(DEFAULT_CONFIG, {
8
+ ...(style ? { style } : {}), ...(layout ? { layout } : {}),
9
+ });
10
+ return renderHud({
11
+ stdin: SAMPLE, config, theme,
12
+ caps: { unicode: true, color256: true, truecolor: true, nerd: true },
13
+ columns: columns || 100, now: SAMPLE_NOW, branch: 'main',
14
+ });
15
+ }
16
+
17
+ function galleryLine(style, columns) {
18
+ return renderSample({ style, layout: 'oneline', columns: columns || 100 });
19
+ }
20
+
21
+ module.exports = { renderSample, galleryLine };
@@ -0,0 +1,61 @@
1
+ const STYLES = [
2
+ { name: 'claude', label: 'Claude 簡潔風', bar: { full: '▰', empty: '▱' }, barWrap: ['', ''],
3
+ labels: { branch: '⎇', ctx: 'Ctx', sess: 'S', wk: 'W', ac: 'compact' },
4
+ icons: null, colorMode: 'coral', decimals: false, rawTokens: false, lowercase: false,
5
+ requires: 'truecolor' },
6
+ { name: 'minimal', label: 'Minimal 簡潔', bar: { full: '▪', empty: '░' }, barWrap: ['', ''],
7
+ labels: { branch: '', ctx: 'ctx', sess: 'ses', wk: 'wk', ac: 'compact' },
8
+ icons: null, colorMode: 'traffic', decimals: false, rawTokens: false, lowercase: true,
9
+ requires: 'unicode' },
10
+ { name: 'classic', label: 'Classic 區塊', bar: { full: '▓', empty: '░' }, barWrap: ['', ''],
11
+ labels: { branch: '⎇', ctx: 'Ctx', sess: 'Sess', wk: 'Wk', ac: 'compact in' },
12
+ icons: null, colorMode: 'traffic', decimals: false, rawTokens: false, lowercase: false,
13
+ requires: 'unicode' },
14
+ { name: 'tech', label: 'Tech 科技感', bar: { full: '█', empty: '▱' }, barWrap: ['', ''],
15
+ // Nerd Font glyphs as ES6 code-point escapes — plane-15 icons need \u{...}, not \uXXXX.
16
+ labels: { branch: '\u{E0A0}', ctx: '\u{F015B}', sess: '\u{F0CAB}', wk: '\u{F073}', ac: '\u{267B}' },
17
+ icons: { model: '\u{F2DB}', project: '\u{F07B}' }, colorMode: 'traffic', decimals: false,
18
+ rawTokens: false, lowercase: false, requires: 'nerd' },
19
+ { name: 'data', label: 'Data 數據控', bar: { full: '⣿', empty: '⠀' }, barWrap: ['', ''],
20
+ labels: { branch: 'git:', ctx: 'CTX', sess: '5H', wk: '7D', ac: 'AC' },
21
+ icons: null, colorMode: 'traffic', decimals: true, rawTokens: true, lowercase: false,
22
+ requires: 'braille' },
23
+ { name: 'ascii', label: 'ASCII 相容', bar: { full: '#', empty: '-' }, barWrap: ['[', ']'],
24
+ labels: { branch: '', ctx: 'Ctx', sess: 'Ses', wk: 'Wk', ac: 'compact' },
25
+ icons: null, colorMode: 'traffic', decimals: false, rawTokens: false, lowercase: false,
26
+ requires: 'ascii' },
27
+ { name: 'emoji', label: 'Emoji 活潑', bar: { full: '▓', empty: '░' }, barWrap: ['', ''],
28
+ labels: { branch: '🌿', ctx: '🧠', sess: '⏱️', wk: '📅', ac: '♻️' },
29
+ icons: { model: '🤖', project: '📁' }, colorMode: 'traffic', decimals: false,
30
+ rawTokens: false, lowercase: false, requires: 'emoji' },
31
+ ];
32
+
33
+ const LAYOUTS = [
34
+ { name: 'auto', label: '單行自適應' },
35
+ { name: 'oneline', label: '單行精簡' },
36
+ { name: 'two', label: '兩行' },
37
+ { name: 'three', label: '三行分組' },
38
+ ];
39
+
40
+ const CONFIG_SCHEMA = {
41
+ style: { type: 'choice', choices: STYLES.map(s => s.name), default: 'claude' },
42
+ layout: { type: 'choice', choices: LAYOUTS.map(l => l.name), default: 'auto' },
43
+ palette: { type: 'choice', choices: ['auto', 'light', 'dark'], default: 'auto' },
44
+ separator: { type: 'string', default: ' | ' },
45
+ barWidth: { type: 'intOrAuto', min: 1, max: 40, default: 8 },
46
+ 'colorThresholds.green': { type: 'int', min: 0, max: 100, default: 50 },
47
+ 'colorThresholds.yellow': { type: 'int', min: 0, max: 100, default: 80 },
48
+ 'elements.model': { type: 'bool', default: true },
49
+ 'elements.project': { type: 'bool', default: true },
50
+ 'elements.gitBranch': { type: 'bool', default: true },
51
+ 'elements.context': { type: 'bool', default: true },
52
+ 'elements.autoCompact': { type: 'bool', default: true },
53
+ 'elements.session': { type: 'bool', default: true },
54
+ 'elements.weekly': { type: 'bool', default: true },
55
+ 'autoCompact.thresholdPct': { type: 'number', min: 0, max: 100, default: 83.5 },
56
+ refreshIntervalSec: { type: 'int', min: 1, max: 3600, default: 30 },
57
+ };
58
+
59
+ function styleByName(name) { return STYLES.find(s => s.name === name) || null; }
60
+
61
+ module.exports = { STYLES, LAYOUTS, CONFIG_SCHEMA, styleByName };
package/src/render.js ADDED
@@ -0,0 +1,36 @@
1
+ const { buildElements } = require('./elements');
2
+ const { buildParts } = require('./engine');
3
+ const { layoutLines } = require('./layout');
4
+ const { resolvePalette } = require('./palette');
5
+ const { styleByName, STYLES } = require('./registry');
6
+
7
+ function resolveTheme(configPalette, theme) {
8
+ if (configPalette === 'light' || configPalette === 'dark') return configPalette;
9
+ return theme === 'light' ? 'light' : 'dark';
10
+ }
11
+
12
+ function renderHud(ctx) {
13
+ const { stdin, config, theme, caps, columns, now, branch } = ctx;
14
+ const style = styleByName(config.style) || STYLES[0];
15
+ const els = buildElements(stdin, { autoCompactThresholdPct: config.autoCompact.thresholdPct });
16
+ els.branch = branch || null;
17
+
18
+ const effTheme = resolveTheme(config.palette, theme);
19
+ const palette = resolvePalette(style.colorMode, effTheme, caps);
20
+ const barWidth = config.barWidth === 'auto'
21
+ ? Math.max(4, Math.min(12, Math.floor((columns || 100) / 12)))
22
+ : config.barWidth;
23
+
24
+ const noLimits = !els.session && !els.weekly;
25
+ const build = (opts) => {
26
+ const parts = buildParts({ els, style, palette, config: { ...config, barWidth }, now, opts });
27
+ if (noLimits && (config.elements.session || config.elements.weekly)) {
28
+ parts.push({ key: 'limits-note', text: '— waiting for first message', group: 'limits' });
29
+ }
30
+ return parts;
31
+ };
32
+
33
+ return layoutLines(build, config.layout, columns || 100, config.separator);
34
+ }
35
+
36
+ module.exports = { renderHud };