agent-fuel 0.4.2 → 0.5.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 +54 -13
- package/dist/adapters/index.d.ts +2 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.js +177 -0
- package/dist/index.js +82 -6
- package/dist/render.d.ts +1 -0
- package/dist/render.js +9 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,10 +45,11 @@ AI coding assistants are now integral to developer workflows. Tools like **Claud
|
|
|
45
45
|
`agent-fuel` is a tiny modern CLI built with TypeScript that:
|
|
46
46
|
|
|
47
47
|
1. **Dispatches Adapters concurrently** — all adapters run in parallel and each row is printed the moment its adapter resolves; you never wait for the slowest tool.
|
|
48
|
-
2. **
|
|
49
|
-
3. **
|
|
50
|
-
4. **
|
|
51
|
-
5. **
|
|
48
|
+
2. **Streams Consolidated Quota Live** — renders a weighted **Total** bar on top which calculates and updates in real-time as each provider finishes loading, showing the live calculated portion rather than waiting for all adapters to finish.
|
|
49
|
+
3. **Normalises Quota Models** — standardises diverse limits into a uniform `0–100%` score.
|
|
50
|
+
4. **Scrapes TUI output directly** — Codex and AGY quotas are read by spawning the real CLIs via `expect` and parsing terminal output, so the numbers match what the tools themselves show.
|
|
51
|
+
5. **Caches AGY results** — AGY quota is cached for 5 minutes so repeated runs are instant (~1s).
|
|
52
|
+
6. **Renders a clean dashboard** — colour-coded bars with reset times directly in your terminal.
|
|
52
53
|
|
|
53
54
|
### Project Architecture
|
|
54
55
|
|
|
@@ -57,6 +58,7 @@ agent-fuel/
|
|
|
57
58
|
├── src/
|
|
58
59
|
│ ├── index.ts # CLI entry point — runs all adapters concurrently
|
|
59
60
|
│ ├── render.ts # Colour-coded bar dashboard renderer
|
|
61
|
+
│ ├── config.ts # Config file manager & config command handler
|
|
60
62
|
│ └── adapters/
|
|
61
63
|
│ ├── index.ts # Shared UsageSnapshot type & QuotaAdapter interface
|
|
62
64
|
│ ├── claude.ts # Claude Code (via ccusage blocks)
|
|
@@ -86,28 +88,67 @@ type UsageSnapshot = {
|
|
|
86
88
|
```
|
|
87
89
|
⚡️ Agent Fuel - CLI Quota Monitor
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
Total [████████████████████████░░░░░░] 81% remaining (tune weights: agent-fuel config)
|
|
92
|
+
|
|
93
|
+
Claude Code [██████████████████████░░░░░░░░] 74% remaining (resets 13:19 (Europe/Copenhagen))
|
|
94
|
+
Codex [██████████████████████████████] 99% remaining (resets 13:56)
|
|
91
95
|
AGY Gemini [██████████████████████████████] 100% remaining ✓ quota available [Gemini 3.5 Flash (Medium)]
|
|
92
|
-
AGY Other [████████████░░░░░░░░░░░░░░░░░░] 40% remaining (resets in
|
|
96
|
+
AGY Other [████████████░░░░░░░░░░░░░░░░░░] 40% remaining (resets in 109h 12m) [Claude Sonnet 4.6 (Thinking)]
|
|
93
97
|
|
|
94
|
-
agent-fuel v0.
|
|
98
|
+
agent-fuel v0.5.0
|
|
95
99
|
```
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
- **Total** bar prints on top (in TTY interactive mode) showing the weighted consolidated remaining quota. As adapters load, the bar fills up in real-time. When fully loaded, a helpful CLI reminder is displayed alongside the Total percentage.
|
|
102
|
+
- Rows appear as each adapter resolves — Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
|
|
103
|
+
- **AGY Gemini** shows the worst-case remaining across all `Gemini *` model tiers.
|
|
104
|
+
- **AGY Other** shows the worst-case across Claude and other non-Gemini models.
|
|
105
|
+
- **Codex** row tagged `[~est]` when quota has not been reached and the percentage is estimated from local session cost data (see fallback note below).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## ⚙️ Configuration & Custom Weights
|
|
110
|
+
|
|
111
|
+
Different developers operate under different quota sizes. By default, `agent-fuel` weights each provider bucket as standard proxies for monthly dollar subscription amounts:
|
|
112
|
+
- Claude Code (`claude-code`): `20`
|
|
113
|
+
- Codex CLI (`codex`): `20`
|
|
114
|
+
- AGY Gemini (`agy-gemini`): `10`
|
|
115
|
+
- AGY Other (`agy-other`): `10`
|
|
116
|
+
|
|
117
|
+
If a provider is completely unused or fails to return a quota percentage, its weight is **dynamically excluded** from the calculation, ensuring that missing/unused services don't break the consolidated bar.
|
|
98
118
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
### Managing Settings via the CLI
|
|
120
|
+
|
|
121
|
+
You can view or update your weights and settings directly using the CLI:
|
|
122
|
+
|
|
123
|
+
* **View Active Configuration**:
|
|
124
|
+
```bash
|
|
125
|
+
agent-fuel config
|
|
126
|
+
```
|
|
127
|
+
* **Change Provider Weight**:
|
|
128
|
+
```bash
|
|
129
|
+
agent-fuel config set claude-code 50
|
|
130
|
+
```
|
|
131
|
+
* **Disable/Enable Total Bar**:
|
|
132
|
+
```bash
|
|
133
|
+
agent-fuel config set show-total false
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Settings are persistently saved to `~/.config/agent-fuel/config.json`.
|
|
102
137
|
|
|
103
138
|
---
|
|
104
139
|
|
|
105
140
|
## ⚙️ Environment Overrides
|
|
106
141
|
|
|
142
|
+
Environment variables take highest precedence and override any values saved in the config JSON file:
|
|
143
|
+
|
|
107
144
|
| Variable | Default | Description |
|
|
108
145
|
|---|---|---|
|
|
109
146
|
| `AGENT_FUEL_CLAUDE_BUDGET` | `20.0` | Claude Code rolling budget in USD |
|
|
110
147
|
| `AGENT_FUEL_CODEX_BUDGET` | `20.0` | **Fallback estimate only** — Codex rolling budget in USD |
|
|
148
|
+
| `AGENT_FUEL_WEIGHT_CLAUDE` | `20` | Weight size ratio of the Claude Code quota pool |
|
|
149
|
+
| `AGENT_FUEL_WEIGHT_CODEX` | `20` | Weight size ratio of the Codex quota pool |
|
|
150
|
+
| `AGENT_FUEL_WEIGHT_AGY_GEMINI` | `10` | Weight size ratio of the AGY Gemini quota pool |
|
|
151
|
+
| `AGENT_FUEL_WEIGHT_AGY_OTHER` | `10` | Weight size ratio of the AGY Other quota pool |
|
|
152
|
+
| `AGENT_FUEL_SHOW_TOTAL` | `true` | Show or hide the consolidated Total quota bar (`true`/`false`) |
|
|
111
153
|
|
|
112
154
|
> **Note on `AGENT_FUEL_CODEX_BUDGET`:** Codex quota is read directly from the Codex TUI via `expect` scraping. This variable is only used as a rough fallback estimate (shown as `[~est]`) when the TUI reports no quota warning and a percentage cannot be determined. It is a guess based on local session cost data — not an official Codex quota signal. The TUI scrape is always preferred.
|
|
113
|
-
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export interface UsageSnapshot {
|
|
2
|
-
tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
|
|
2
|
+
tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other' | 'total';
|
|
3
3
|
remainingPercent: number | null;
|
|
4
4
|
usedPercent?: number | null;
|
|
5
5
|
resetAt?: string | null;
|
|
6
6
|
source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
|
|
7
|
+
isLoading?: boolean;
|
|
7
8
|
raw?: unknown;
|
|
8
9
|
}
|
|
9
10
|
export interface QuotaAdapter {
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
weights: {
|
|
3
|
+
'claude-code': number;
|
|
4
|
+
'codex': number;
|
|
5
|
+
'agy-gemini': number;
|
|
6
|
+
'agy-other': number;
|
|
7
|
+
};
|
|
8
|
+
showTotal: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare const CONFIG_DIR: string;
|
|
11
|
+
export declare const CONFIG_FILE: string;
|
|
12
|
+
export declare const DEFAULT_CONFIG: Config;
|
|
13
|
+
export declare function loadConfig(): Config;
|
|
14
|
+
export declare function saveConfig(config: Config): void;
|
|
15
|
+
export declare function handleConfigCommand(args: string[]): boolean;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.config', 'agent-fuel');
|
|
5
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
6
|
+
export const DEFAULT_CONFIG = {
|
|
7
|
+
weights: {
|
|
8
|
+
'claude-code': 20,
|
|
9
|
+
'codex': 20,
|
|
10
|
+
'agy-gemini': 10,
|
|
11
|
+
'agy-other': 10,
|
|
12
|
+
},
|
|
13
|
+
showTotal: true,
|
|
14
|
+
};
|
|
15
|
+
function ensureDir(dir) {
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Ignore, let write fail if it must
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function loadConfig() {
|
|
26
|
+
const config = { ...DEFAULT_CONFIG, weights: { ...DEFAULT_CONFIG.weights } };
|
|
27
|
+
// 1. Read from config file
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
30
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
31
|
+
const parsed = JSON.parse(content);
|
|
32
|
+
if (parsed && typeof parsed === 'object') {
|
|
33
|
+
if (parsed.weights && typeof parsed.weights === 'object') {
|
|
34
|
+
for (const key of ['claude-code', 'codex', 'agy-gemini', 'agy-other']) {
|
|
35
|
+
const w = parsed.weights[key];
|
|
36
|
+
if (typeof w === 'number' && Number.isFinite(w) && w >= 0) {
|
|
37
|
+
config.weights[key] = w;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed.showTotal === 'boolean') {
|
|
42
|
+
config.showTotal = parsed.showTotal;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Fail silently, use defaults
|
|
49
|
+
}
|
|
50
|
+
// 2. Read from Environment Variables
|
|
51
|
+
const envClaude = process.env.AGENT_FUEL_WEIGHT_CLAUDE_CODE ?? process.env.AGENT_FUEL_WEIGHT_CLAUDE;
|
|
52
|
+
if (envClaude) {
|
|
53
|
+
const val = Number(envClaude);
|
|
54
|
+
if (Number.isFinite(val) && val >= 0)
|
|
55
|
+
config.weights['claude-code'] = val;
|
|
56
|
+
}
|
|
57
|
+
const envCodex = process.env.AGENT_FUEL_WEIGHT_CODEX;
|
|
58
|
+
if (envCodex) {
|
|
59
|
+
const val = Number(envCodex);
|
|
60
|
+
if (Number.isFinite(val) && val >= 0)
|
|
61
|
+
config.weights['codex'] = val;
|
|
62
|
+
}
|
|
63
|
+
const envGemini = process.env.AGENT_FUEL_WEIGHT_AGY_GEMINI ?? process.env.AGENT_FUEL_WEIGHT_GEMINI;
|
|
64
|
+
if (envGemini) {
|
|
65
|
+
const val = Number(envGemini);
|
|
66
|
+
if (Number.isFinite(val) && val >= 0)
|
|
67
|
+
config.weights['agy-gemini'] = val;
|
|
68
|
+
}
|
|
69
|
+
const envOther = process.env.AGENT_FUEL_WEIGHT_AGY_OTHER ?? process.env.AGENT_FUEL_WEIGHT_OTHER;
|
|
70
|
+
if (envOther) {
|
|
71
|
+
const val = Number(envOther);
|
|
72
|
+
if (Number.isFinite(val) && val >= 0)
|
|
73
|
+
config.weights['agy-other'] = val;
|
|
74
|
+
}
|
|
75
|
+
const envShowTotal = process.env.AGENT_FUEL_SHOW_TOTAL;
|
|
76
|
+
if (envShowTotal) {
|
|
77
|
+
if (envShowTotal.toLowerCase() === 'true')
|
|
78
|
+
config.showTotal = true;
|
|
79
|
+
if (envShowTotal.toLowerCase() === 'false')
|
|
80
|
+
config.showTotal = false;
|
|
81
|
+
}
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
export function saveConfig(config) {
|
|
85
|
+
ensureDir(CONFIG_DIR);
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
throw new Error(`Could not write config file ${CONFIG_FILE}: ${msg}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function handleConfigCommand(args) {
|
|
95
|
+
if (args.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
const firstArg = args[0].toLowerCase();
|
|
98
|
+
if (firstArg !== 'config')
|
|
99
|
+
return false;
|
|
100
|
+
const BOLD = '\x1b[1m';
|
|
101
|
+
const CYAN = '\x1b[36m';
|
|
102
|
+
const RED = '\x1b[31m';
|
|
103
|
+
const GREEN = '\x1b[32m';
|
|
104
|
+
const R = '\x1b[0m';
|
|
105
|
+
const GRAY = '\x1b[90m';
|
|
106
|
+
const config = loadConfig();
|
|
107
|
+
const subCommand = args[1]?.toLowerCase();
|
|
108
|
+
if (!subCommand || subCommand === 'list') {
|
|
109
|
+
console.log(`\n${BOLD}${CYAN}⚡️ Agent Fuel Configuration${R}`);
|
|
110
|
+
console.log(`${GRAY}Config file: ${CONFIG_FILE}${R}\n`);
|
|
111
|
+
console.log(`${BOLD}Weights:${R}`);
|
|
112
|
+
console.log(` claude-code : ${config.weights['claude-code']}`);
|
|
113
|
+
console.log(` codex : ${config.weights['codex']}`);
|
|
114
|
+
console.log(` agy-gemini : ${config.weights['agy-gemini']}`);
|
|
115
|
+
console.log(` agy-other : ${config.weights['agy-other']}`);
|
|
116
|
+
console.log();
|
|
117
|
+
console.log(`${BOLD}Settings:${R}`);
|
|
118
|
+
console.log(` show-total : ${config.showTotal}`);
|
|
119
|
+
console.log();
|
|
120
|
+
console.log(`${BOLD}Examples:${R}`);
|
|
121
|
+
console.log(` agent-fuel config set claude-code 50`);
|
|
122
|
+
console.log(` agent-fuel config set show-total false`);
|
|
123
|
+
console.log();
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (subCommand === 'set') {
|
|
127
|
+
const key = args[2]?.toLowerCase();
|
|
128
|
+
const rawVal = args[3];
|
|
129
|
+
if (!key || !rawVal) {
|
|
130
|
+
console.error(`\n${BOLD}${RED}Error:${R} Usage: agent-fuel config set <key> <value>`);
|
|
131
|
+
console.error(`Keys: claude, claude-code, codex, gemini, agy-gemini, other, agy-other, show-total\n`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
if (key === 'show-total') {
|
|
135
|
+
const lowerVal = rawVal.toLowerCase();
|
|
136
|
+
if (lowerVal !== 'true' && lowerVal !== 'false') {
|
|
137
|
+
console.error(`\n${BOLD}${RED}Error:${R} show-total must be true or false\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
config.showTotal = lowerVal === 'true';
|
|
141
|
+
saveConfig(config);
|
|
142
|
+
console.log(`\n${BOLD}${GREEN}✓${R} Set show-total to ${config.showTotal}\n`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// Handle weights keys
|
|
146
|
+
let targetKey = null;
|
|
147
|
+
if (key === 'claude' || key === 'claude-code') {
|
|
148
|
+
targetKey = 'claude-code';
|
|
149
|
+
}
|
|
150
|
+
else if (key === 'codex') {
|
|
151
|
+
targetKey = 'codex';
|
|
152
|
+
}
|
|
153
|
+
else if (key === 'gemini' || key === 'agy-gemini') {
|
|
154
|
+
targetKey = 'agy-gemini';
|
|
155
|
+
}
|
|
156
|
+
else if (key === 'other' || key === 'agy-other') {
|
|
157
|
+
targetKey = 'agy-other';
|
|
158
|
+
}
|
|
159
|
+
if (!targetKey) {
|
|
160
|
+
console.error(`\n${BOLD}${RED}Error:${R} Unknown key "${key}".`);
|
|
161
|
+
console.error(`Valid keys: claude, claude-code, codex, gemini, agy-gemini, other, agy-other, show-total\n`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const val = Number(rawVal);
|
|
165
|
+
if (!Number.isFinite(val) || val < 0) {
|
|
166
|
+
console.error(`\n${BOLD}${RED}Error:${R} Weight must be a positive number or 0.\n`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
config.weights[targetKey] = val;
|
|
170
|
+
saveConfig(config);
|
|
171
|
+
console.log(`\n${BOLD}${GREEN}✓${R} Set weight.${targetKey} to ${val}\n`);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
console.error(`\n${BOLD}${RED}Error:${R} Unknown config sub-command "${subCommand}".`);
|
|
175
|
+
console.error(`Usage: agent-fuel config [list|set]\n`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,23 +3,78 @@ import { debugEnabled, debugLogFile } from './debug.js';
|
|
|
3
3
|
import { ClaudeQuotaAdapter } from './adapters/claude.js';
|
|
4
4
|
import { CodexQuotaAdapter } from './adapters/codex.js';
|
|
5
5
|
import { AgyQuotaAdapter } from './adapters/agy.js';
|
|
6
|
-
import { printHeader, printFooter, formatRow, getDisplayName } from './render.js';
|
|
6
|
+
import { printHeader, printFooter, formatRow, getDisplayName, SHADE_CHAR } from './render.js';
|
|
7
|
+
import { loadConfig, handleConfigCommand } from './config.js';
|
|
7
8
|
// Fixed display order — never changes regardless of which adapter resolves first
|
|
8
9
|
const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
|
|
9
10
|
const BOLD = '\x1b[1m';
|
|
10
11
|
const DIM = '\x1b[2m';
|
|
11
12
|
const R = '\x1b[0m';
|
|
12
13
|
const GRAY = '\x1b[90m';
|
|
14
|
+
const CYAN = '\x1b[36m';
|
|
13
15
|
const isTTY = Boolean(process.stdout.isTTY);
|
|
14
16
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
15
17
|
let spinnerTick = 0;
|
|
18
|
+
const config = loadConfig();
|
|
16
19
|
function spinnerLine(tool) {
|
|
17
20
|
const frame = SPINNER[spinnerTick % SPINNER.length];
|
|
18
21
|
return `${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
|
|
19
22
|
}
|
|
23
|
+
function calculateTotalLine(snapshots) {
|
|
24
|
+
let totalWeight = 0;
|
|
25
|
+
let totalRemainingWeight = 0;
|
|
26
|
+
let hasActive = false;
|
|
27
|
+
let isAnyLoading = false;
|
|
28
|
+
for (const tool of SLOT_ORDER) {
|
|
29
|
+
const snap = snapshots.get(tool);
|
|
30
|
+
if (snap === undefined || snap === null) {
|
|
31
|
+
isAnyLoading = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (snap.remainingPercent !== null) {
|
|
35
|
+
const w = config.weights[tool] ?? 0;
|
|
36
|
+
totalWeight += w;
|
|
37
|
+
totalRemainingWeight += (snap.remainingPercent / 100) * w;
|
|
38
|
+
hasActive = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!hasActive) {
|
|
42
|
+
if (isAnyLoading) {
|
|
43
|
+
const frame = SPINNER[spinnerTick % SPINNER.length];
|
|
44
|
+
return `${BOLD}${CYAN}Total${R} [${GRAY}${SHADE_CHAR.repeat(30)}${R}] ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
|
|
45
|
+
}
|
|
46
|
+
const totalSnap = {
|
|
47
|
+
tool: 'total',
|
|
48
|
+
remainingPercent: null,
|
|
49
|
+
usedPercent: null,
|
|
50
|
+
source: 'unknown',
|
|
51
|
+
isLoading: true
|
|
52
|
+
};
|
|
53
|
+
return formatRow(totalSnap);
|
|
54
|
+
}
|
|
55
|
+
const pct = totalWeight > 0
|
|
56
|
+
? Math.max(0, Math.min(100, Math.round((totalRemainingWeight / totalWeight) * 100)))
|
|
57
|
+
: null;
|
|
58
|
+
const totalSnap = {
|
|
59
|
+
tool: 'total',
|
|
60
|
+
remainingPercent: pct,
|
|
61
|
+
usedPercent: pct !== null ? 100 - pct : null,
|
|
62
|
+
source: 'local-state',
|
|
63
|
+
isLoading: isAnyLoading
|
|
64
|
+
};
|
|
65
|
+
let formatted = formatRow(totalSnap);
|
|
66
|
+
if (isAnyLoading) {
|
|
67
|
+
const frame = SPINNER[spinnerTick % SPINNER.length];
|
|
68
|
+
formatted += ` ${DIM}${GRAY}${frame} loading...${R}`;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
formatted += ` ${DIM}${GRAY}(tune weights: agent-fuel config)${R}`;
|
|
72
|
+
}
|
|
73
|
+
return formatted;
|
|
74
|
+
}
|
|
20
75
|
// In TTY mode: restore cursor to saved position and repaint all slots.
|
|
21
76
|
// In pipe mode: emit each newly-resolved line exactly once (tracked via emitted set).
|
|
22
|
-
function redraw(slots, emitted) {
|
|
77
|
+
function redraw(slots, emitted, snapshots) {
|
|
23
78
|
if (!isTTY) {
|
|
24
79
|
for (const tool of SLOT_ORDER) {
|
|
25
80
|
const line = slots.get(tool);
|
|
@@ -28,9 +83,20 @@ function redraw(slots, emitted) {
|
|
|
28
83
|
emitted.add(tool);
|
|
29
84
|
}
|
|
30
85
|
}
|
|
86
|
+
if (config.showTotal && emitted.size === SLOT_ORDER.length && !emitted.has('total')) {
|
|
87
|
+
const totalLine = calculateTotalLine(snapshots);
|
|
88
|
+
process.stdout.write('\n' + totalLine + '\n');
|
|
89
|
+
emitted.add('total');
|
|
90
|
+
}
|
|
31
91
|
return;
|
|
32
92
|
}
|
|
33
93
|
process.stdout.write('\x1b8'); // DEC restore-cursor — teleports back to saved position
|
|
94
|
+
if (config.showTotal) {
|
|
95
|
+
process.stdout.write('\x1b[2K\r');
|
|
96
|
+
const totalLine = calculateTotalLine(snapshots);
|
|
97
|
+
process.stdout.write(totalLine + '\n');
|
|
98
|
+
process.stdout.write('\x1b[2K\r\n'); // spacer line
|
|
99
|
+
}
|
|
34
100
|
for (const tool of SLOT_ORDER) {
|
|
35
101
|
process.stdout.write('\x1b[2K\r');
|
|
36
102
|
const line = slots.get(tool);
|
|
@@ -38,6 +104,10 @@ function redraw(slots, emitted) {
|
|
|
38
104
|
}
|
|
39
105
|
}
|
|
40
106
|
async function main() {
|
|
107
|
+
const args = process.argv.slice(2);
|
|
108
|
+
if (handleConfigCommand(args)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
41
111
|
const claudeAdapter = new ClaudeQuotaAdapter();
|
|
42
112
|
const codexAdapter = new CodexQuotaAdapter();
|
|
43
113
|
const agyAdapter = new AgyQuotaAdapter();
|
|
@@ -46,24 +116,30 @@ async function main() {
|
|
|
46
116
|
printHeader();
|
|
47
117
|
// Save cursor before the placeholder rows so redraw() can teleport back and overwrite them
|
|
48
118
|
const slots = new Map(SLOT_ORDER.map(t => [t, null]));
|
|
119
|
+
const snapshots = new Map(SLOT_ORDER.map(t => [t, null]));
|
|
49
120
|
const emitted = new Set(); // pipe-mode: tracks which lines have been printed
|
|
50
|
-
if (isTTY)
|
|
121
|
+
if (isTTY) {
|
|
51
122
|
process.stdout.write('\x1b7'); // DEC save-cursor
|
|
123
|
+
if (config.showTotal) {
|
|
124
|
+
process.stdout.write(calculateTotalLine(snapshots) + '\n\n');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
52
127
|
for (const tool of SLOT_ORDER) {
|
|
53
128
|
process.stdout.write(spinnerLine(tool) + '\n');
|
|
54
129
|
}
|
|
55
130
|
// Animate spinner at 80ms while any slot is still loading
|
|
56
131
|
const spinnerTimer = isTTY
|
|
57
|
-
? setInterval(() => { spinnerTick++; redraw(slots, emitted); }, 80)
|
|
132
|
+
? setInterval(() => { spinnerTick++; redraw(slots, emitted, snapshots); }, 80)
|
|
58
133
|
: null;
|
|
59
134
|
// Each adapter fills its slot(s) and triggers a redraw; order is always fixed
|
|
60
135
|
function fill(snaps) {
|
|
61
136
|
for (const snap of snaps) {
|
|
62
137
|
if (SLOT_ORDER.includes(snap.tool)) {
|
|
138
|
+
snapshots.set(snap.tool, snap);
|
|
63
139
|
slots.set(snap.tool, formatRow(snap));
|
|
64
140
|
}
|
|
65
141
|
}
|
|
66
|
-
redraw(slots, emitted);
|
|
142
|
+
redraw(slots, emitted, snapshots);
|
|
67
143
|
}
|
|
68
144
|
await Promise.allSettled([
|
|
69
145
|
claudeAdapter.fetchSnapshots().then(fill),
|
|
@@ -72,7 +148,7 @@ async function main() {
|
|
|
72
148
|
]);
|
|
73
149
|
if (spinnerTimer)
|
|
74
150
|
clearInterval(spinnerTimer);
|
|
75
|
-
redraw(slots, emitted); // final clean repaint with all data
|
|
151
|
+
redraw(slots, emitted, snapshots); // final clean repaint with all data
|
|
76
152
|
printFooter();
|
|
77
153
|
}
|
|
78
154
|
main().catch((error) => {
|
package/dist/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UsageSnapshot } from './adapters/index.js';
|
|
2
|
+
export declare const SHADE_CHAR = "\u2591";
|
|
2
3
|
export declare function getDisplayName(tool: string): string;
|
|
3
4
|
export declare function formatRow(snap: UsageSnapshot): string;
|
|
4
5
|
export declare function printHeader(): void;
|
package/dist/render.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
5
5
|
const BLOCK_CHAR = '█';
|
|
6
|
-
const SHADE_CHAR = '░';
|
|
6
|
+
export const SHADE_CHAR = '░';
|
|
7
7
|
const BAR_WIDTH = 30;
|
|
8
8
|
// ── ANSI colour helpers ────────────────────────────────────────────────────
|
|
9
9
|
const R = '\x1b[0m';
|
|
@@ -21,10 +21,13 @@ export function getDisplayName(tool) {
|
|
|
21
21
|
case 'claude-code': return 'Claude Code';
|
|
22
22
|
case 'agy-gemini': return 'AGY Gemini';
|
|
23
23
|
case 'agy-other': return 'AGY Other';
|
|
24
|
+
case 'total': return 'Total';
|
|
24
25
|
default: return tool;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
function pickColour(remaining) {
|
|
28
|
+
function pickColour(remaining, isLoading) {
|
|
29
|
+
if (isLoading)
|
|
30
|
+
return CYAN;
|
|
28
31
|
if (remaining < 20)
|
|
29
32
|
return RED;
|
|
30
33
|
if (remaining < 50)
|
|
@@ -69,7 +72,7 @@ export function formatRow(snap) {
|
|
|
69
72
|
percentStr = `${GRAY}unknown${R}`;
|
|
70
73
|
}
|
|
71
74
|
else {
|
|
72
|
-
const colour = pickColour(remaining);
|
|
75
|
+
const colour = pickColour(remaining, snap.isLoading);
|
|
73
76
|
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((remaining * BAR_WIDTH) / 100)));
|
|
74
77
|
const empty = BAR_WIDTH - filled;
|
|
75
78
|
barStr = `${colour}${BLOCK_CHAR.repeat(filled)}${R}${GRAY}${SHADE_CHAR.repeat(empty)}${R}`;
|
|
@@ -89,7 +92,9 @@ export function formatRow(snap) {
|
|
|
89
92
|
parts.push(`${DIM}${GRAY}[~est]${R}`);
|
|
90
93
|
}
|
|
91
94
|
const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
92
|
-
|
|
95
|
+
const isTotal = snap.tool === 'total';
|
|
96
|
+
const labelPrefix = isTotal ? `${BOLD}${CYAN}` : BOLD;
|
|
97
|
+
return `${labelPrefix}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
|
|
93
98
|
}
|
|
94
99
|
// ── Public render functions ────────────────────────────────────────────────
|
|
95
100
|
export function printHeader() {
|