ai-battery 0.1.0 → 0.1.1
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 +25 -19
- package/bin/ai-battery-hud.js +233 -2
- package/bin/ai-battery-macos-status.applescript +78 -0
- package/bin/ai-battery.js +236 -56
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Codex와 Claude Code의 남은 사용량을 배터리처럼 확인하는 터미
|
|
|
9
9
|

|
|
10
10
|

|
|
11
11
|
|
|
12
|
-
[Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [
|
|
12
|
+
[Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [Desktop HUD](#desktop-hud) · [Caution](#caution)
|
|
13
13
|
|
|
14
14
|
## Overview
|
|
15
15
|
|
|
@@ -40,7 +40,7 @@ Codex 86% │ 5h 18:09 │ 7d 82% ┃ Claude 4% │ 5h 18:10 │ 7d 71%
|
|
|
40
40
|
| 색상 기준 | 40% 초과 초록, 21-40% 주황, 20% 이하 빨강으로 배터리만 강조합니다. |
|
|
41
41
|
| Codex terminal row | Codex 아래에 별도 사용량 행을 고정하는 PTY wrapper를 제공합니다. |
|
|
42
42
|
| Claude statusLine | Claude Code 내장 statusLine hook과 실제 429 hit 로그로 Claude rate limit 상태를 읽습니다. |
|
|
43
|
-
|
|
|
43
|
+
| HUD / menu bar | Windows native/WSL에서는 floating HUD, macOS에서는 menu bar status item을 제공합니다. |
|
|
44
44
|
| npm 실행 | `npm install -g` 또는 `npx`로 실행할 수 있습니다. |
|
|
45
45
|
|
|
46
46
|
## Platform Support
|
|
@@ -52,7 +52,7 @@ Codex 86% │ 5h 18:09 │ 7d 82% ┃ Claude 4% │ 5h 18:10 │ 7d 71%
|
|
|
52
52
|
| Claude statusLine | 지원 | 지원 | 지원 | 지원 | Claude Code `statusLine`에 `node <script>` 명령을 저장합니다. |
|
|
53
53
|
| Codex terminal row | 미지원 | 지원 | 지원 | 지원 | `ai-battery-run`이 POSIX PTY와 `python3`를 사용합니다. |
|
|
54
54
|
| `ai-battery setup codex` | 미지원 | 지원 | 지원 | 지원 | `codex` wrapper가 POSIX shell/PTY 기반입니다. |
|
|
55
|
-
| `ai-battery hud` | 지원 | 지원 | 미지원 |
|
|
55
|
+
| `ai-battery hud` | 지원 | 지원 | 미지원 | 지원 | Windows/WSL은 PowerShell/WinForms HUD, macOS는 menu bar status item입니다. |
|
|
56
56
|
|
|
57
57
|
실행 중 감지(흰색/회색 표시)는 Linux/WSL은 `/proc`, macOS는 `ps`, Windows는 PowerShell 프로세스 목록을 사용합니다.
|
|
58
58
|
|
|
@@ -91,7 +91,7 @@ npx ai-battery
|
|
|
91
91
|
codex
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
4.
|
|
94
|
+
4. 데스크톱 HUD나 macOS menu bar 표시가 필요하면 실행합니다.
|
|
95
95
|
|
|
96
96
|
```bash
|
|
97
97
|
ai-battery hud
|
|
@@ -134,7 +134,13 @@ ai-battery setup claude
|
|
|
134
134
|
ai-battery setup codex
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 `~/.local/bin`을 PATH 앞쪽으로 추가합니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다.
|
|
137
|
+
Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 `~/.local/bin`을 PATH 앞쪽으로 추가합니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다. 현재 터미널에서 바로 쓰려면 `setup` 출력에 표시되는 `source ...` 명령을 실행하세요.
|
|
138
|
+
|
|
139
|
+
Codex 하단 행이 보이지 않으면 진단을 실행합니다.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
ai-battery doctor
|
|
143
|
+
```
|
|
138
144
|
|
|
139
145
|
표시할 provider는 짧은 on/off 명령으로 바꿉니다.
|
|
140
146
|
|
|
@@ -196,15 +202,15 @@ ai-battery uninstall-claude-statusline
|
|
|
196
202
|
|
|
197
203
|
Claude가 한 번 이상 statusLine payload를 전달해야 Claude의 사용량 캐시가 생성됩니다. 그 전에는 Claude 로컬 로그 기반 fallback이 표시됩니다.
|
|
198
204
|
|
|
199
|
-
##
|
|
205
|
+
## Desktop HUD
|
|
200
206
|
|
|
201
|
-
일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서
|
|
207
|
+
일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서 Windows에서는 floating overlay를, macOS에서는 상단 menu bar status item을 제공합니다. Windows native에서는 WSL 없이 PowerShell/WinForms로 바로 실행되고, WSL에서는 `powershell.exe`를 통해 같은 HUD를 띄웁니다. macOS에서는 메뉴바 두께에 맞춰 `Cx 75% | Cl 4%`처럼 한 줄 요약만 표시하고, 클릭하면 자세한 상태를 볼 수 있습니다.
|
|
202
208
|
|
|
203
209
|
```bash
|
|
204
210
|
ai-battery hud
|
|
205
211
|
```
|
|
206
212
|
|
|
207
|
-
HUD는 백그라운드에서 실행되고 터미널을 바로 돌려줍니다.
|
|
213
|
+
HUD는 백그라운드에서 실행되고 터미널을 바로 돌려줍니다. Windows HUD는 위치를 드래그로 옮길 수 있으며, 다음 실행 때 저장된 위치를 재사용합니다. macOS menu bar item은 시스템 메뉴바 오른쪽 영역에 표시됩니다.
|
|
208
214
|
|
|
209
215
|
```text
|
|
210
216
|
Codex [battery:88] │ 5h 00:47 │ 7d 93%
|
|
@@ -213,18 +219,18 @@ Claude [battery:76] │ 5h 00:47 │ 7d 59%
|
|
|
213
219
|
|
|
214
220
|
| Command | Role |
|
|
215
221
|
| --- | --- |
|
|
216
|
-
| `ai-battery hud` / `ai-battery hud start` | floating HUD
|
|
217
|
-
| `ai-battery hud stop` | 실행 중인 HUD
|
|
218
|
-
| `ai-battery hud status` | HUD 실행 여부와 autostart 등록 상태를 보여줍니다. |
|
|
219
|
-
| `ai-battery hud autostart on` | Windows 로그인 시
|
|
222
|
+
| `ai-battery hud` / `ai-battery hud start` | Windows floating HUD 또는 macOS menu bar item을 시작합니다. |
|
|
223
|
+
| `ai-battery hud stop` | 실행 중인 HUD/menu bar item을 종료합니다. (`--stop`도 동일) |
|
|
224
|
+
| `ai-battery hud status` | HUD/menu bar 실행 여부와 autostart 등록 상태를 보여줍니다. |
|
|
225
|
+
| `ai-battery hud autostart on` | Windows 로그인 또는 macOS 로그인 시 자동 실행을 등록합니다. |
|
|
220
226
|
| `ai-battery hud autostart off` | 자동 실행 등록을 해제합니다. |
|
|
221
227
|
| `ai-battery hud autostart status` | 자동 실행 등록 상태만 보여줍니다. |
|
|
222
228
|
| `ai-battery hud -Foreground` | 디버깅용으로 터미널에 붙여 실행합니다. |
|
|
223
229
|
| `ai-battery hud -Once` | 콘솔에서 한 번만 출력합니다. |
|
|
224
230
|
| `ai-battery hud -Interval 2` | 갱신 주기를 바꿉니다. |
|
|
225
|
-
| `ai-battery hud -Mode tray` | Windows tray icon 모드로 실행합니다. |
|
|
231
|
+
| `ai-battery hud -Mode tray` | Windows tray icon 모드로 실행합니다. macOS에서는 menu bar item이 기본입니다. |
|
|
226
232
|
|
|
227
|
-
autostart는 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 사용자 단위로 등록됩니다. Windows native에서는 WSL 없이 바로 실행되고, WSL에서 등록한 경우에는 HUD 스크립트 사본을 `%LOCALAPPDATA%\ai-battery`에
|
|
233
|
+
Windows autostart는 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`에 사용자 단위로 등록됩니다. Windows native에서는 WSL 없이 바로 실행되고, WSL에서 등록한 경우에는 HUD 스크립트 사본을 `%LOCALAPPDATA%\ai-battery`에 둡니다. macOS autostart는 `~/Library/LaunchAgents/com.ai-battery.hud.plist`로 등록됩니다. ai-battery를 업데이트한 뒤에는 `ai-battery hud autostart on`을 다시 실행해 등록 경로를 갱신하세요.
|
|
228
234
|
|
|
229
235
|
## Shell Prompt
|
|
230
236
|
|
|
@@ -247,7 +253,7 @@ flowchart LR
|
|
|
247
253
|
F --> R
|
|
248
254
|
R --> T[Terminal output]
|
|
249
255
|
R --> P[PTY reserved row]
|
|
250
|
-
R --> H[
|
|
256
|
+
R --> H[Desktop HUD / menu bar]
|
|
251
257
|
```
|
|
252
258
|
|
|
253
259
|
Codex는 최근 세션 로그에서 `rate_limits` 이벤트를 찾습니다. Claude Code는 statusLine payload로 사용률과 리셋 시각을 제공하고, 실제 429 rate-limit hit 로그가 있으면 reset 전까지 0%로 반영합니다. fallback 모드에서는 최근 토큰 사용량만 확인할 수 있습니다.
|
|
@@ -258,13 +264,13 @@ Codex는 최근 세션 로그에서 `rate_limits` 이벤트를 찾습니다. Cla
|
|
|
258
264
|
| --- | --- | --- |
|
|
259
265
|
| CLI | Node.js | 로그 파싱, Claude cache, ANSI/statusLine 출력 |
|
|
260
266
|
| PTY row | Python 3 | Codex 실행용 reserved terminal row |
|
|
261
|
-
| HUD launcher | Node.js / Bash compatibility wrapper | Windows native/WSL
|
|
262
|
-
| HUD UI | PowerShell WinForms | Windows floating overlay
|
|
267
|
+
| HUD launcher | Node.js / Bash compatibility wrapper | Windows native/WSL PowerShell HUD와 macOS menu bar 실행 |
|
|
268
|
+
| HUD UI | PowerShell WinForms / AppleScriptObjC | Windows floating overlay, tray icon, macOS menu bar item |
|
|
263
269
|
| Data | JSONL logs, statusLine JSON | Codex/Claude 사용량 소스 |
|
|
264
270
|
|
|
265
271
|
## Source Environment
|
|
266
272
|
|
|
267
|
-
기본 CLI는 Node.js 18 이상이 있으면 Windows native, WSL, Linux, macOS에서 실행됩니다. `ai-battery-run`과 Codex terminal row는 Python 3와 POSIX PTY가 필요해서 WSL/Linux/macOS에서 지원됩니다. HUD는 Windows PowerShell/WinForms
|
|
273
|
+
기본 CLI는 Node.js 18 이상이 있으면 Windows native, WSL, Linux, macOS에서 실행됩니다. `ai-battery-run`과 Codex terminal row는 Python 3와 POSIX PTY가 필요해서 WSL/Linux/macOS에서 지원됩니다. HUD는 Windows/WSL에서는 PowerShell/WinForms, macOS에서는 내장 `osascript`와 AppleScriptObjC를 사용합니다.
|
|
268
274
|
|
|
269
275
|
Codex 데이터는 기본적으로 `~/.codex/sessions`를 읽습니다. 다른 위치를 쓰고 있다면 `CODEX_HOME`을 설정하세요.
|
|
270
276
|
|
|
@@ -279,5 +285,5 @@ Claude의 사용량 표시는 Claude Code statusLine hook을 설치한 뒤부터
|
|
|
279
285
|
- 이 도구는 로컬 로그와 Claude statusLine payload를 읽습니다. 서비스의 공식 과금/한도 화면을 대체하지 않습니다.
|
|
280
286
|
- Codex rate limit 이벤트가 아직 생성되지 않았거나 오래된 경우 최신 상태와 차이가 있을 수 있습니다.
|
|
281
287
|
- Claude statusLine은 사용률과 reset 시각만 제공하므로, 실제 hit 상태는 Claude가 남긴 429 rate-limit 로그를 함께 읽어 반영합니다.
|
|
282
|
-
- HUD는 Windows PowerShell/WinForms
|
|
288
|
+
- HUD는 Windows에서는 PowerShell/WinForms 기반이고, macOS에서는 menu bar status item 기반입니다. WSL에서는 `powershell.exe`와 `wsl.exe`를 함께 사용합니다.
|
|
283
289
|
- `ai-battery-run`은 PTY wrapper입니다. 일부 전체 화면 TUI는 화면을 지우는 escape sequence 때문에 status row가 잠시 흔들릴 수 있습니다.
|
package/bin/ai-battery-hud.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
const scriptPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
10
10
|
const scriptDir = path.dirname(scriptPath);
|
|
11
11
|
const ps1Path = path.join(scriptDir, "ai-battery-hud.ps1");
|
|
12
|
+
const macStatusPath = path.join(scriptDir, "ai-battery-macos-status.applescript");
|
|
12
13
|
|
|
13
14
|
function isWsl() {
|
|
14
15
|
if (process.platform !== "linux") return false;
|
|
@@ -112,12 +113,242 @@ function prefetchInitialJson(command, useWslCommand) {
|
|
|
112
113
|
return null;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
function relevantEnvPrefix() {
|
|
117
|
+
const names = [
|
|
118
|
+
"AI_BATTERY_STATE_DIR",
|
|
119
|
+
"CLAUDEX_BATTERY_STATE_DIR",
|
|
120
|
+
"AI_BATTERY_COLUMNS",
|
|
121
|
+
"CLAUDEX_BATTERY_COLUMNS",
|
|
122
|
+
"AI_BATTERY_COLUMN_GUARD",
|
|
123
|
+
"CLAUDEX_BATTERY_COLUMN_GUARD",
|
|
124
|
+
"CODEX_HOME"
|
|
125
|
+
];
|
|
126
|
+
const pairs = [`HOME=${shellQuote(os.homedir())}`];
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
if (process.env[name]) pairs.push(`${name}=${shellQuote(process.env[name])}`);
|
|
129
|
+
}
|
|
130
|
+
return pairs.join(" ");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseMacHudArgs(cliArgs) {
|
|
134
|
+
const options = {
|
|
135
|
+
foreground: false,
|
|
136
|
+
once: false,
|
|
137
|
+
stop: false,
|
|
138
|
+
subcommand: null,
|
|
139
|
+
autostartAction: "status",
|
|
140
|
+
interval: 10,
|
|
141
|
+
provider: "all"
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < cliArgs.length; i += 1) {
|
|
145
|
+
const arg = cliArgs[i];
|
|
146
|
+
if (arg === "-Foreground" || arg === "--foreground") {
|
|
147
|
+
options.foreground = true;
|
|
148
|
+
} else if (arg === "-Once" || arg === "--once") {
|
|
149
|
+
options.once = true;
|
|
150
|
+
} else if (arg === "-Stop" || arg === "--stop" || arg === "stop") {
|
|
151
|
+
options.stop = true;
|
|
152
|
+
} else if (arg === "start") {
|
|
153
|
+
// Starting is the default action.
|
|
154
|
+
} else if (arg === "status") {
|
|
155
|
+
options.subcommand = "status";
|
|
156
|
+
} else if (arg === "autostart") {
|
|
157
|
+
options.subcommand = "autostart";
|
|
158
|
+
const next = cliArgs[i + 1];
|
|
159
|
+
if (next === "on" || next === "off" || next === "status") {
|
|
160
|
+
options.autostartAction = next;
|
|
161
|
+
i += 1;
|
|
162
|
+
}
|
|
163
|
+
} else if (arg === "-Interval" || arg === "--interval") {
|
|
164
|
+
const next = cliArgs[i + 1];
|
|
165
|
+
if (next && !next.startsWith("-")) {
|
|
166
|
+
options.interval = Math.max(1, Number(next) || options.interval);
|
|
167
|
+
i += 1;
|
|
168
|
+
}
|
|
169
|
+
} else if (arg === "--provider" || arg === "-p") {
|
|
170
|
+
const next = cliArgs[i + 1];
|
|
171
|
+
if (["all", "codex", "claude"].includes(next)) {
|
|
172
|
+
options.provider = next;
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return options;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function macProviderArgs(provider) {
|
|
182
|
+
return provider === "all" ? "" : ` --provider ${shellQuote(provider)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function macCommands(options) {
|
|
186
|
+
const node = shellQuote(process.execPath);
|
|
187
|
+
const batteryJs = shellQuote(path.join(scriptDir, "ai-battery.js"));
|
|
188
|
+
const providerArgs = macProviderArgs(options.provider);
|
|
189
|
+
const envPrefix = relevantEnvPrefix();
|
|
190
|
+
return {
|
|
191
|
+
title: `${envPrefix} ${node} ${batteryJs} --menu-bar${providerArgs} 2>/dev/null`,
|
|
192
|
+
detail: `${envPrefix} ${node} ${batteryJs} --no-color${providerArgs} 2>/dev/null`
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function macHudPids() {
|
|
197
|
+
const result = spawnSync("pgrep", ["-f", macStatusPath], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
200
|
+
});
|
|
201
|
+
if (result.status !== 0) return [];
|
|
202
|
+
return (result.stdout || "")
|
|
203
|
+
.trim()
|
|
204
|
+
.split(/\s+/)
|
|
205
|
+
.map((pid) => Number(pid))
|
|
206
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function macLaunchAgentPath() {
|
|
210
|
+
return path.join(os.homedir(), "Library", "LaunchAgents", "com.ai-battery.hud.plist");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function macLaunchctlDomain() {
|
|
214
|
+
return `gui/${typeof process.getuid === "function" ? process.getuid() : ""}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function xmlEscape(value) {
|
|
218
|
+
return String(value)
|
|
219
|
+
.replace(/&/g, "&")
|
|
220
|
+
.replace(/</g, "<")
|
|
221
|
+
.replace(/>/g, ">")
|
|
222
|
+
.replace(/"/g, """);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function plistString(value) {
|
|
226
|
+
return ` <string>${xmlEscape(value)}</string>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function macAutostartPlist(options) {
|
|
230
|
+
const args = [process.execPath, scriptPath, "start", "--interval", String(options.interval)];
|
|
231
|
+
if (options.provider !== "all") args.push("--provider", options.provider);
|
|
232
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
233
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
234
|
+
<plist version="1.0">
|
|
235
|
+
<dict>
|
|
236
|
+
<key>Label</key>
|
|
237
|
+
<string>com.ai-battery.hud</string>
|
|
238
|
+
<key>ProgramArguments</key>
|
|
239
|
+
<array>
|
|
240
|
+
${args.map(plistString).join("\n")}
|
|
241
|
+
</array>
|
|
242
|
+
<key>RunAtLoad</key>
|
|
243
|
+
<true/>
|
|
244
|
+
</dict>
|
|
245
|
+
</plist>
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function macAutostartStatus() {
|
|
250
|
+
const filePath = macLaunchAgentPath();
|
|
251
|
+
return { enabled: fs.existsSync(filePath), filePath };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function macAutostartEnable(options) {
|
|
255
|
+
const filePath = macLaunchAgentPath();
|
|
256
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
257
|
+
fs.writeFileSync(filePath, macAutostartPlist(options), "utf8");
|
|
258
|
+
spawnSync("launchctl", ["bootstrap", macLaunchctlDomain(), filePath], { stdio: "ignore" });
|
|
259
|
+
return filePath;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function macAutostartDisable() {
|
|
263
|
+
const filePath = macLaunchAgentPath();
|
|
264
|
+
spawnSync("launchctl", ["bootout", macLaunchctlDomain(), filePath], { stdio: "ignore" });
|
|
265
|
+
fs.rmSync(filePath, { force: true });
|
|
266
|
+
return filePath;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function runMacHud(cliArgs) {
|
|
270
|
+
const options = parseMacHudArgs(cliArgs);
|
|
271
|
+
const commands = macCommands(options);
|
|
272
|
+
|
|
273
|
+
if (options.once) {
|
|
274
|
+
const result = spawnSync(process.execPath, [
|
|
275
|
+
path.join(scriptDir, "ai-battery.js"),
|
|
276
|
+
"--menu-bar",
|
|
277
|
+
...(options.provider === "all" ? [] : ["--provider", options.provider])
|
|
278
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
279
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
280
|
+
process.exit(result.status ?? 0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (options.subcommand === "status") {
|
|
284
|
+
const pids = macHudPids();
|
|
285
|
+
const auto = macAutostartStatus();
|
|
286
|
+
console.log(`HUD: ${pids.length ? `running (PID ${pids[0]})` : "stopped"}`);
|
|
287
|
+
console.log(`Autostart: ${auto.enabled ? "on" : "off"}`);
|
|
288
|
+
if (auto.enabled) console.log(` ${auto.filePath}`);
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (options.subcommand === "autostart") {
|
|
293
|
+
if (options.autostartAction === "on") {
|
|
294
|
+
console.log(`HUD autostart enabled: ${macAutostartEnable(options)}`);
|
|
295
|
+
} else if (options.autostartAction === "off") {
|
|
296
|
+
console.log(`HUD autostart disabled: ${macAutostartDisable()}`);
|
|
297
|
+
} else {
|
|
298
|
+
const auto = macAutostartStatus();
|
|
299
|
+
console.log(`Autostart: ${auto.enabled ? "on" : "off"}`);
|
|
300
|
+
if (auto.enabled) console.log(` ${auto.filePath}`);
|
|
301
|
+
}
|
|
302
|
+
process.exit(0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (options.stop) {
|
|
306
|
+
const pids = macHudPids();
|
|
307
|
+
if (!pids.length) {
|
|
308
|
+
console.log("AI Battery menu bar is not running.");
|
|
309
|
+
process.exit(0);
|
|
310
|
+
}
|
|
311
|
+
const result = spawnSync("kill", pids.map(String), { stdio: "inherit" });
|
|
312
|
+
if ((result.status ?? 0) === 0) console.log("AI Battery menu bar stopped.");
|
|
313
|
+
process.exit(result.status ?? 0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (macHudPids().length) {
|
|
317
|
+
console.log("AI Battery menu bar is already running.");
|
|
318
|
+
process.exit(0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const osascriptArgs = [
|
|
322
|
+
macStatusPath,
|
|
323
|
+
commands.title,
|
|
324
|
+
commands.detail,
|
|
325
|
+
String(options.interval)
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
if (options.foreground) {
|
|
329
|
+
const result = spawnSync("osascript", osascriptArgs, { stdio: "inherit" });
|
|
330
|
+
process.exit(result.status ?? 0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const child = spawn("osascript", osascriptArgs, {
|
|
334
|
+
detached: true,
|
|
335
|
+
stdio: "ignore"
|
|
336
|
+
});
|
|
337
|
+
child.unref();
|
|
338
|
+
console.log("AI Battery menu bar started. Click the menu bar item for details; use \"ai-battery hud stop\" to close it.");
|
|
339
|
+
process.exit(0);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (process.platform === "darwin") {
|
|
343
|
+
runMacHud(process.argv.slice(2));
|
|
344
|
+
}
|
|
345
|
+
|
|
115
346
|
const useWsl = isWsl();
|
|
116
347
|
const powershell = "powershell.exe";
|
|
117
348
|
|
|
118
349
|
if (!useWsl && process.platform !== "win32") {
|
|
119
|
-
console.error("ai-battery-hud:
|
|
120
|
-
console.error("On
|
|
350
|
+
console.error("ai-battery-hud: desktop HUD needs Windows (native or WSL) or macOS.");
|
|
351
|
+
console.error("On Linux terminals use: ai-battery --watch");
|
|
121
352
|
process.exit(1);
|
|
122
353
|
}
|
|
123
354
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
use framework "AppKit"
|
|
2
|
+
use framework "Foundation"
|
|
3
|
+
use scripting additions
|
|
4
|
+
|
|
5
|
+
property statusItem : missing value
|
|
6
|
+
property detailItem : missing value
|
|
7
|
+
property refreshTimer : missing value
|
|
8
|
+
property titleCommand : ""
|
|
9
|
+
property detailCommand : ""
|
|
10
|
+
property refreshInterval : 10
|
|
11
|
+
|
|
12
|
+
on run argv
|
|
13
|
+
if (count of argv) < 2 then error "ai-battery macOS status item requires title and detail commands"
|
|
14
|
+
|
|
15
|
+
set titleCommand to item 1 of argv
|
|
16
|
+
set detailCommand to item 2 of argv
|
|
17
|
+
if (count of argv) is greater than or equal to 3 then
|
|
18
|
+
try
|
|
19
|
+
set refreshInterval to (item 3 of argv) as real
|
|
20
|
+
end try
|
|
21
|
+
end if
|
|
22
|
+
|
|
23
|
+
current application's NSApplication's sharedApplication()
|
|
24
|
+
current application's NSApp's setActivationPolicy:(current application's NSApplicationActivationPolicyAccessory)
|
|
25
|
+
|
|
26
|
+
set statusItem to current application's NSStatusBar's systemStatusBar()'s statusItemWithLength:(current application's NSVariableStatusItemLength)
|
|
27
|
+
statusItem's button()'s setTitle:"AI --"
|
|
28
|
+
statusItem's button()'s setToolTip:"AI Battery"
|
|
29
|
+
|
|
30
|
+
set statusMenu to current application's NSMenu's alloc()'s initWithTitle:"AI Battery"
|
|
31
|
+
set detailItem to current application's NSMenuItem's alloc()'s initWithTitle:"Loading..." action:(missing value) keyEquivalent:""
|
|
32
|
+
detailItem's setEnabled:false
|
|
33
|
+
statusMenu's addItem:detailItem
|
|
34
|
+
statusMenu's addItem:(current application's NSMenuItem's separatorItem())
|
|
35
|
+
|
|
36
|
+
set refreshItem to current application's NSMenuItem's alloc()'s initWithTitle:"Refresh" action:"refresh:" keyEquivalent:"r"
|
|
37
|
+
refreshItem's setTarget:me
|
|
38
|
+
statusMenu's addItem:refreshItem
|
|
39
|
+
|
|
40
|
+
set quitItem to current application's NSMenuItem's alloc()'s initWithTitle:"Quit AI Battery" action:"quit:" keyEquivalent:"q"
|
|
41
|
+
quitItem's setTarget:me
|
|
42
|
+
statusMenu's addItem:quitItem
|
|
43
|
+
|
|
44
|
+
statusItem's setMenu:statusMenu
|
|
45
|
+
my refresh_(missing value)
|
|
46
|
+
|
|
47
|
+
set refreshTimer to current application's NSTimer's scheduledTimerWithTimeInterval:refreshInterval target:me selector:"refresh:" userInfo:(missing value) repeats:true
|
|
48
|
+
current application's NSRunLoop's currentRunLoop()'s addTimer:refreshTimer forMode:(current application's NSRunLoopCommonModes)
|
|
49
|
+
current application's NSApp's run()
|
|
50
|
+
end run
|
|
51
|
+
|
|
52
|
+
on refresh_(sender)
|
|
53
|
+
set titleText to "AI --"
|
|
54
|
+
set detailText to "AI Battery unavailable"
|
|
55
|
+
|
|
56
|
+
try
|
|
57
|
+
set titleText to do shell script titleCommand
|
|
58
|
+
end try
|
|
59
|
+
|
|
60
|
+
try
|
|
61
|
+
set detailText to do shell script detailCommand
|
|
62
|
+
end try
|
|
63
|
+
|
|
64
|
+
if titleText is "" then set titleText to "AI --"
|
|
65
|
+
if detailText is "" then set detailText to "AI Battery unavailable"
|
|
66
|
+
|
|
67
|
+
statusItem's button()'s setTitle:titleText
|
|
68
|
+
statusItem's button()'s setToolTip:detailText
|
|
69
|
+
detailItem's setTitle:detailText
|
|
70
|
+
end refresh_
|
|
71
|
+
|
|
72
|
+
on quit_(sender)
|
|
73
|
+
try
|
|
74
|
+
refreshTimer's invalidate()
|
|
75
|
+
end try
|
|
76
|
+
current application's NSStatusBar's systemStatusBar()'s removeStatusItem:statusItem
|
|
77
|
+
current application's NSApp's terminate:me
|
|
78
|
+
end quit_
|
package/bin/ai-battery.js
CHANGED
|
@@ -41,6 +41,7 @@ function parseArgs(argv) {
|
|
|
41
41
|
leftPadding: 0,
|
|
42
42
|
activeProvider: null,
|
|
43
43
|
showPaths: false,
|
|
44
|
+
menuBar: false,
|
|
44
45
|
silent: false,
|
|
45
46
|
force: false,
|
|
46
47
|
header: true,
|
|
@@ -95,6 +96,9 @@ function parseArgs(argv) {
|
|
|
95
96
|
args.activeProvider = argv[++i] || null;
|
|
96
97
|
} else if (arg === "--show-paths") {
|
|
97
98
|
args.showPaths = true;
|
|
99
|
+
} else if (arg === "--menu-bar") {
|
|
100
|
+
args.menuBar = true;
|
|
101
|
+
args.style = "plain";
|
|
98
102
|
} else if (arg === "--help" || arg === "-h") {
|
|
99
103
|
args.help = true;
|
|
100
104
|
} else {
|
|
@@ -139,6 +143,7 @@ Options:
|
|
|
139
143
|
--left-padding N Prefix status output with N spaces
|
|
140
144
|
--active-provider codex|claude
|
|
141
145
|
--show-paths Include source log paths in text output
|
|
146
|
+
--menu-bar Print compact macOS menu bar text
|
|
142
147
|
--tmux Emit tmux status-line color markup
|
|
143
148
|
--force Replace an existing Claude statusLine
|
|
144
149
|
--no-color Disable ANSI colors
|
|
@@ -350,6 +355,7 @@ function shellRcPath() {
|
|
|
350
355
|
if (process.env.AI_BATTERY_RC) return process.env.AI_BATTERY_RC;
|
|
351
356
|
const shell = path.basename(process.env.SHELL || "");
|
|
352
357
|
if (shell === "zsh") return homePath(".zshrc");
|
|
358
|
+
if (shell === "bash" && process.platform === "darwin") return homePath(".bash_profile");
|
|
353
359
|
if (shell === "bash") return homePath(".bashrc");
|
|
354
360
|
if (shell === "fish") return homePath(".config", "fish", "config.fish");
|
|
355
361
|
return homePath(".profile");
|
|
@@ -448,6 +454,13 @@ function installCodexWrapper(args) {
|
|
|
448
454
|
};
|
|
449
455
|
}
|
|
450
456
|
|
|
457
|
+
function sourcePathCommand(rcPath) {
|
|
458
|
+
const shell = path.basename(process.env.SHELL || "");
|
|
459
|
+
if (!rcPath) return null;
|
|
460
|
+
if (["bash", "fish", "zsh"].includes(shell)) return `source ${shellArg(rcPath)}`;
|
|
461
|
+
return `. ${shellArg(rcPath)}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
451
464
|
function codexRestartNote() {
|
|
452
465
|
if (!runningInsideCodex() || runningInsideAiBatteryCodexWrapper()) return null;
|
|
453
466
|
return "Current Codex was not started through AI Battery. Exit this Codex session and run plain \"codex\" again from a normal terminal.";
|
|
@@ -461,6 +474,9 @@ function diagnoseCodex() {
|
|
|
461
474
|
const wrapperInstalled = configuredWrapper ? managedCodexWrapper(configuredWrapper) : false;
|
|
462
475
|
const activeIsWrapper = activeCodex ? managedCodexWrapper(activeCodex) : false;
|
|
463
476
|
const originalExists = configuredOriginal ? fs.existsSync(configuredOriginal) : false;
|
|
477
|
+
const runnerPath = path.join(scriptDir(), "ai-battery-run");
|
|
478
|
+
const runnerExists = isExecutable(runnerPath);
|
|
479
|
+
const python3 = findCommand("python3");
|
|
464
480
|
const providerEnabled = providerVisible("codex");
|
|
465
481
|
const notes = [];
|
|
466
482
|
|
|
@@ -473,6 +489,12 @@ function diagnoseCodex() {
|
|
|
473
489
|
if (wrapperInstalled && !activeIsWrapper) {
|
|
474
490
|
notes.push("Plain \"codex\" does not resolve to the AI Battery wrapper in this shell. Open a new terminal or put ~/.local/bin before the original codex on PATH.");
|
|
475
491
|
}
|
|
492
|
+
if (!runnerExists) {
|
|
493
|
+
notes.push(`AI Battery runner is missing or not executable: ${runnerPath}`);
|
|
494
|
+
}
|
|
495
|
+
if (wrapperInstalled && !python3) {
|
|
496
|
+
notes.push("Codex wrapper needs python3 for the POSIX PTY row. Install Python 3, then run plain \"codex\" again.");
|
|
497
|
+
}
|
|
476
498
|
if (configuredOriginal && !originalExists) {
|
|
477
499
|
notes.push(`Original codex path saved by setup no longer exists: ${configuredOriginal}`);
|
|
478
500
|
}
|
|
@@ -485,6 +507,9 @@ function diagnoseCodex() {
|
|
|
485
507
|
activeIsWrapper,
|
|
486
508
|
wrapperPath: configuredWrapper,
|
|
487
509
|
wrapperInstalled,
|
|
510
|
+
runnerPath,
|
|
511
|
+
runnerExists,
|
|
512
|
+
python3,
|
|
488
513
|
originalCommand: configuredOriginal,
|
|
489
514
|
originalExists: configuredOriginal ? originalExists : null,
|
|
490
515
|
insideCodex: runningInsideCodex(),
|
|
@@ -662,18 +687,60 @@ function prioritizeCodexSessionFiles(files) {
|
|
|
662
687
|
});
|
|
663
688
|
}
|
|
664
689
|
|
|
690
|
+
function firstFiniteValue(source, keys) {
|
|
691
|
+
for (const key of [keys].flat().filter(Boolean)) {
|
|
692
|
+
const value = source?.[key];
|
|
693
|
+
if (Number.isFinite(value)) return value;
|
|
694
|
+
if (typeof value === "string" && value.trim()) {
|
|
695
|
+
const numeric = Number(value);
|
|
696
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function percentValue(value) {
|
|
703
|
+
if (!Number.isFinite(value)) return null;
|
|
704
|
+
return value >= 0 && value <= 1 ? value * 100 : value;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function usageInputTokens(usage) {
|
|
708
|
+
if (!usage) return null;
|
|
709
|
+
const inputTokens = firstFiniteValue(usage, ["input_tokens", "inputTokens"]);
|
|
710
|
+
const cacheCreationTokens = firstFiniteValue(usage, ["cache_creation_input_tokens", "cacheCreationInputTokens"]);
|
|
711
|
+
const cacheReadTokens = firstFiniteValue(usage, ["cache_read_input_tokens", "cacheReadInputTokens"]);
|
|
712
|
+
if (
|
|
713
|
+
!Number.isFinite(inputTokens)
|
|
714
|
+
&& !Number.isFinite(cacheCreationTokens)
|
|
715
|
+
&& !Number.isFinite(cacheReadTokens)
|
|
716
|
+
) {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
return (Number(inputTokens) || 0) + (Number(cacheCreationTokens) || 0) + (Number(cacheReadTokens) || 0);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function resetEpochSeconds(value) {
|
|
723
|
+
if (Number.isFinite(value)) return value > 1_000_000_000_000 ? Math.floor(value / 1000) : value;
|
|
724
|
+
if (typeof value === "string" && value.trim()) {
|
|
725
|
+
const numeric = Number(value);
|
|
726
|
+
if (Number.isFinite(numeric)) return resetEpochSeconds(numeric);
|
|
727
|
+
const millis = Date.parse(value);
|
|
728
|
+
if (!Number.isNaN(millis)) return Math.floor(millis / 1000);
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
665
733
|
function normalizeLimit(limit, options = {}) {
|
|
666
734
|
if (!limit) return null;
|
|
667
735
|
|
|
668
|
-
const
|
|
736
|
+
const usedKeys = options.usedKey || "used_percent";
|
|
669
737
|
const remainingKeys = [options.remainingKey].flat().filter(Boolean);
|
|
670
|
-
const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? null;
|
|
671
|
-
const usedValue = limit
|
|
672
|
-
const remainingValue = remainingKeys
|
|
673
|
-
.map((key) => limit?.[key])
|
|
674
|
-
.find((value) => Number.isFinite(value));
|
|
738
|
+
const windowMinutes = options.windowMinutes ?? limit?.window_minutes ?? limit?.windowMinutes ?? null;
|
|
739
|
+
const usedValue = percentValue(firstFiniteValue(limit, usedKeys));
|
|
740
|
+
const remainingValue = percentValue(firstFiniteValue(limit, remainingKeys));
|
|
675
741
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
676
|
-
const
|
|
742
|
+
const resetsAtSeconds = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
|
|
743
|
+
const resetPassed = Number.isFinite(resetsAtSeconds) && resetsAtSeconds <= nowSeconds;
|
|
677
744
|
const inferResetPassed = options.inferResetPassed !== false;
|
|
678
745
|
|
|
679
746
|
if (!Number.isFinite(usedValue) && !Number.isFinite(remainingValue) && !(resetPassed && inferResetPassed)) return null;
|
|
@@ -693,8 +760,8 @@ function normalizeLimit(limit, options = {}) {
|
|
|
693
760
|
usedPercent,
|
|
694
761
|
remainingPercent,
|
|
695
762
|
windowMinutes,
|
|
696
|
-
resetsAt:
|
|
697
|
-
resetsInSeconds:
|
|
763
|
+
resetsAt: resetsAtSeconds ? new Date(resetsAtSeconds * 1000).toISOString() : null,
|
|
764
|
+
resetsInSeconds: resetsAtSeconds ? Math.max(0, resetsAtSeconds - nowSeconds) : null,
|
|
698
765
|
resetPassed
|
|
699
766
|
};
|
|
700
767
|
}
|
|
@@ -932,34 +999,119 @@ function readStdin() {
|
|
|
932
999
|
|
|
933
1000
|
function claudeLimitFromStatusline(limit, windowMinutes) {
|
|
934
1001
|
if (!limit) return null;
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1002
|
+
const usedPercentage = percentValue(firstFiniteValue(limit, [
|
|
1003
|
+
"used_percentage",
|
|
1004
|
+
"usedPercent",
|
|
1005
|
+
"percent_used",
|
|
1006
|
+
"percentUsed",
|
|
1007
|
+
"utilization"
|
|
1008
|
+
]));
|
|
1009
|
+
const remainingPercentage = percentValue(firstFiniteValue(limit, [
|
|
1010
|
+
"remaining_percentage",
|
|
1011
|
+
"remainingPercent",
|
|
1012
|
+
"remaining_percent",
|
|
1013
|
+
"percent_remaining",
|
|
1014
|
+
"percentRemaining"
|
|
1015
|
+
]));
|
|
1016
|
+
const resetsAt = resetEpochSeconds(limit.resets_at ?? limit.resetsAt ?? limit.reset_at ?? limit.resetAt);
|
|
1017
|
+
const hasUsedPercentage = Number.isFinite(usedPercentage);
|
|
941
1018
|
const hasRemainingPercentage = Number.isFinite(remainingPercentage);
|
|
942
|
-
const hasReset = Number.isFinite(
|
|
1019
|
+
const hasReset = Number.isFinite(resetsAt);
|
|
943
1020
|
if (!hasUsedPercentage && !hasRemainingPercentage && !hasReset) return null;
|
|
944
1021
|
return {
|
|
945
1022
|
...limit,
|
|
946
|
-
used_percentage: hasUsedPercentage ?
|
|
1023
|
+
used_percentage: hasUsedPercentage ? usedPercentage : null,
|
|
947
1024
|
remaining_percentage: hasRemainingPercentage ? remainingPercentage : null,
|
|
948
|
-
resets_at: hasReset ?
|
|
949
|
-
window_minutes: limit.window_minutes ?? windowMinutes
|
|
1025
|
+
resets_at: hasReset ? resetsAt : null,
|
|
1026
|
+
window_minutes: limit.window_minutes ?? limit.windowMinutes ?? windowMinutes
|
|
950
1027
|
};
|
|
951
1028
|
}
|
|
952
1029
|
|
|
953
1030
|
function normalizeClaudeCachedLimit(limit, options = {}) {
|
|
954
1031
|
if (!limit) return null;
|
|
955
1032
|
return normalizeLimit(limit, {
|
|
956
|
-
usedKey: "used_percentage",
|
|
957
|
-
remainingKey: ["remaining_percentage", "remaining_percent", "percent_remaining"],
|
|
958
|
-
windowMinutes: limit.window_minutes ?? null,
|
|
1033
|
+
usedKey: ["used_percentage", "usedPercent", "percent_used", "percentUsed", "utilization"],
|
|
1034
|
+
remainingKey: ["remaining_percentage", "remainingPercent", "remaining_percent", "percent_remaining", "percentRemaining"],
|
|
1035
|
+
windowMinutes: limit.window_minutes ?? limit.windowMinutes ?? null,
|
|
959
1036
|
...options
|
|
960
1037
|
});
|
|
961
1038
|
}
|
|
962
1039
|
|
|
1040
|
+
function claudeRateLimitsFromStatusline(input) {
|
|
1041
|
+
const rateLimits = input.rate_limits ?? input.rateLimits ?? input.limits ?? {};
|
|
1042
|
+
const fiveHour = rateLimits.five_hour
|
|
1043
|
+
?? rateLimits.fiveHour
|
|
1044
|
+
?? rateLimits.five_hour_limit
|
|
1045
|
+
?? rateLimits.fiveHourLimit
|
|
1046
|
+
?? rateLimits.session
|
|
1047
|
+
?? rateLimits.session_limit
|
|
1048
|
+
?? rateLimits.sessionLimit
|
|
1049
|
+
?? rateLimits.primary
|
|
1050
|
+
?? null;
|
|
1051
|
+
const sevenDay = rateLimits.seven_day
|
|
1052
|
+
?? rateLimits.sevenDay
|
|
1053
|
+
?? rateLimits.seven_day_limit
|
|
1054
|
+
?? rateLimits.sevenDayLimit
|
|
1055
|
+
?? rateLimits.weekly
|
|
1056
|
+
?? rateLimits.weekly_limit
|
|
1057
|
+
?? rateLimits.weeklyLimit
|
|
1058
|
+
?? rateLimits.weekly_scoped
|
|
1059
|
+
?? rateLimits.weeklyScoped
|
|
1060
|
+
?? rateLimits.secondary
|
|
1061
|
+
?? null;
|
|
1062
|
+
return { fiveHour, sevenDay, raw: rateLimits };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function normalizeClaudeContextWindow(context) {
|
|
1066
|
+
if (!context) return null;
|
|
1067
|
+
|
|
1068
|
+
const usedPercentage = percentValue(firstFiniteValue(context, ["used_percentage", "usedPercentage", "percent_used", "percentUsed"]));
|
|
1069
|
+
let remainingPercentage = percentValue(firstFiniteValue(context, [
|
|
1070
|
+
"remaining_percentage",
|
|
1071
|
+
"remainingPercentage",
|
|
1072
|
+
"percent_remaining",
|
|
1073
|
+
"percentRemaining"
|
|
1074
|
+
]));
|
|
1075
|
+
const currentUsage = context.current_usage ?? context.currentUsage ?? null;
|
|
1076
|
+
const contextWindowSize = firstFiniteValue(context, ["context_window_size", "contextWindowSize", "size", "max_tokens", "maxTokens"]);
|
|
1077
|
+
const totalInputTokens = firstFiniteValue(context, ["total_input_tokens", "totalInputTokens"])
|
|
1078
|
+
?? usageInputTokens(currentUsage)
|
|
1079
|
+
?? firstFiniteValue(context, ["input_tokens", "inputTokens"]);
|
|
1080
|
+
const totalOutputTokens = firstFiniteValue(context, ["total_output_tokens", "totalOutputTokens", "output_tokens", "outputTokens"])
|
|
1081
|
+
?? firstFiniteValue(currentUsage, ["output_tokens", "outputTokens"]);
|
|
1082
|
+
const totalTokens = firstFiniteValue(context, ["total_tokens", "totalTokens"])
|
|
1083
|
+
?? ((Number.isFinite(totalInputTokens) || Number.isFinite(totalOutputTokens))
|
|
1084
|
+
? (Number(totalInputTokens) || 0) + (Number(totalOutputTokens) || 0)
|
|
1085
|
+
: null);
|
|
1086
|
+
|
|
1087
|
+
if (!Number.isFinite(remainingPercentage) && Number.isFinite(usedPercentage)) {
|
|
1088
|
+
remainingPercentage = 100 - usedPercentage;
|
|
1089
|
+
}
|
|
1090
|
+
if (!Number.isFinite(remainingPercentage) && Number.isFinite(contextWindowSize) && Number.isFinite(totalTokens) && contextWindowSize > 0) {
|
|
1091
|
+
remainingPercentage = 100 - ((totalTokens / contextWindowSize) * 100);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return {
|
|
1095
|
+
usedPercentage: Number.isFinite(usedPercentage)
|
|
1096
|
+
? clamp(Math.round(usedPercentage), 0, 100)
|
|
1097
|
+
: (Number.isFinite(remainingPercentage) ? clamp(100 - Math.round(remainingPercentage), 0, 100) : null),
|
|
1098
|
+
remainingPercentage: Number.isFinite(remainingPercentage) ? clamp(Math.round(remainingPercentage), 0, 100) : null,
|
|
1099
|
+
contextWindowSize: contextWindowSize ?? null,
|
|
1100
|
+
totalInputTokens: totalInputTokens ?? null,
|
|
1101
|
+
totalOutputTokens: totalOutputTokens ?? null
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function claudeContextWindowFromStatusline(input) {
|
|
1106
|
+
const context = input.context_window
|
|
1107
|
+
?? input.contextWindow
|
|
1108
|
+
?? input.context
|
|
1109
|
+
?? input.context_window_info
|
|
1110
|
+
?? (input.context_window_size || input.contextWindowSize ? input : null)
|
|
1111
|
+
?? null;
|
|
1112
|
+
return normalizeClaudeContextWindow(context);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
963
1115
|
function claudeTranscriptSessionKind(transcriptPath) {
|
|
964
1116
|
if (!transcriptPath) return null;
|
|
965
1117
|
const text = readTail(transcriptPath, 256 * 1024);
|
|
@@ -1107,48 +1259,44 @@ function applyClaudeRateLimitHit(limit, hit, window) {
|
|
|
1107
1259
|
}
|
|
1108
1260
|
|
|
1109
1261
|
function captureClaudeStatusline(input) {
|
|
1110
|
-
const rateLimits = input
|
|
1111
|
-
const sessionId = input.session_id ?? null;
|
|
1262
|
+
const rateLimits = claudeRateLimitsFromStatusline(input);
|
|
1263
|
+
const sessionId = input.session_id ?? input.sessionId ?? null;
|
|
1112
1264
|
const previous = (sessionId ? readJson(claudeSessionCachePath(sessionId)) : null) ?? readJson(claudeCachePath());
|
|
1113
1265
|
const sessionKind = claudeStatuslineSessionKind(input);
|
|
1266
|
+
const contextWindow = claudeContextWindowFromStatusline(input);
|
|
1114
1267
|
const snapshot = {
|
|
1115
1268
|
version: 1,
|
|
1116
1269
|
provider: "claude",
|
|
1117
1270
|
sourceType: "statusline",
|
|
1118
1271
|
capturedAt: new Date().toISOString(),
|
|
1119
1272
|
sessionId,
|
|
1120
|
-
promptId: input.prompt_id ?? null,
|
|
1121
|
-
transcriptPath: input.transcript_path ?? null,
|
|
1273
|
+
promptId: input.prompt_id ?? input.promptId ?? null,
|
|
1274
|
+
transcriptPath: input.transcript_path ?? input.transcriptPath ?? null,
|
|
1122
1275
|
sessionKind,
|
|
1123
1276
|
claudeVersion: input.version ?? null,
|
|
1124
1277
|
model: {
|
|
1125
|
-
id: input.model?.id ?? null,
|
|
1126
|
-
displayName: input.model?.display_name ?? null
|
|
1278
|
+
id: typeof input.model === "string" ? input.model : input.model?.id ?? null,
|
|
1279
|
+
displayName: typeof input.model === "string" ? input.model : input.model?.display_name ?? input.model?.displayName ?? null
|
|
1127
1280
|
},
|
|
1128
1281
|
rateLimits: {
|
|
1129
|
-
fiveHour: claudeLimitFromStatusline(rateLimits.
|
|
1130
|
-
sevenDay: claudeLimitFromStatusline(rateLimits.
|
|
1282
|
+
fiveHour: claudeLimitFromStatusline(rateLimits.fiveHour, 300),
|
|
1283
|
+
sevenDay: claudeLimitFromStatusline(rateLimits.sevenDay, 10080)
|
|
1131
1284
|
},
|
|
1132
|
-
rawRateLimits: rateLimits,
|
|
1133
|
-
contextWindow
|
|
1134
|
-
? {
|
|
1135
|
-
usedPercentage: input.context_window.used_percentage ?? null,
|
|
1136
|
-
remainingPercentage: input.context_window.remaining_percentage ?? null,
|
|
1137
|
-
contextWindowSize: input.context_window.context_window_size ?? null,
|
|
1138
|
-
totalInputTokens: input.context_window.total_input_tokens ?? null,
|
|
1139
|
-
totalOutputTokens: input.context_window.total_output_tokens ?? null
|
|
1140
|
-
}
|
|
1141
|
-
: null
|
|
1285
|
+
rawRateLimits: rateLimits.raw,
|
|
1286
|
+
contextWindow
|
|
1142
1287
|
};
|
|
1143
1288
|
|
|
1144
1289
|
if (previous?.provider === "claude" && previous?.sourceType === "statusline") {
|
|
1145
|
-
const sameSession = previous.sessionId && previous.sessionId === snapshot.sessionId;
|
|
1146
|
-
if (
|
|
1290
|
+
const sameSession = previous.sessionId && snapshot.sessionId && previous.sessionId === snapshot.sessionId;
|
|
1291
|
+
if (!snapshot.rateLimits.fiveHour) {
|
|
1147
1292
|
snapshot.rateLimits.fiveHour = previous.rateLimits?.fiveHour ?? null;
|
|
1148
1293
|
}
|
|
1149
|
-
if (
|
|
1294
|
+
if (!snapshot.rateLimits.sevenDay) {
|
|
1150
1295
|
snapshot.rateLimits.sevenDay = previous.rateLimits?.sevenDay ?? null;
|
|
1151
1296
|
}
|
|
1297
|
+
if (sameSession && !snapshot.contextWindow) {
|
|
1298
|
+
snapshot.contextWindow = previous.contextWindow ?? null;
|
|
1299
|
+
}
|
|
1152
1300
|
}
|
|
1153
1301
|
|
|
1154
1302
|
if (snapshot.sessionId) {
|
|
@@ -1674,18 +1822,18 @@ function statusLineUsableColumns(input) {
|
|
|
1674
1822
|
return Math.max(20, statusLineColumns(input) - guard);
|
|
1675
1823
|
}
|
|
1676
1824
|
|
|
1677
|
-
function contextRemainingPercent(input) {
|
|
1678
|
-
const context = input
|
|
1679
|
-
const remaining = context?.
|
|
1825
|
+
function contextRemainingPercent(input, fallbackContext = null) {
|
|
1826
|
+
const context = claudeContextWindowFromStatusline(input) ?? fallbackContext;
|
|
1827
|
+
const remaining = context?.remainingPercentage;
|
|
1680
1828
|
if (typeof remaining === "number") return clamp(Math.round(remaining), 0, 100);
|
|
1681
1829
|
|
|
1682
|
-
const used = context?.
|
|
1830
|
+
const used = context?.usedPercentage;
|
|
1683
1831
|
if (typeof used === "number") return clamp(100 - Math.round(used), 0, 100);
|
|
1684
1832
|
return null;
|
|
1685
1833
|
}
|
|
1686
1834
|
|
|
1687
|
-
function contextLeftText(input) {
|
|
1688
|
-
const remaining = contextRemainingPercent(input);
|
|
1835
|
+
function contextLeftText(input, fallbackContext = null) {
|
|
1836
|
+
const remaining = contextRemainingPercent(input, fallbackContext);
|
|
1689
1837
|
if (remaining === null) return null;
|
|
1690
1838
|
return `${remaining}% context left`;
|
|
1691
1839
|
}
|
|
@@ -1730,13 +1878,20 @@ function statuslineGitBranch(input) {
|
|
|
1730
1878
|
|| input.git_branch
|
|
1731
1879
|
|| input.workspace?.git_branch
|
|
1732
1880
|
|| input.workspace?.git?.branch
|
|
1881
|
+
|| input.workspace?.branch
|
|
1882
|
+
|| input.worktree?.branch
|
|
1883
|
+
|| input.worktree?.original_branch
|
|
1733
1884
|
|| null;
|
|
1734
1885
|
if (explicit) return explicit;
|
|
1735
1886
|
|
|
1736
1887
|
const dirs = [
|
|
1737
1888
|
input.workspace?.current_dir,
|
|
1889
|
+
input.workspace?.currentDir,
|
|
1738
1890
|
input.cwd,
|
|
1739
|
-
input.
|
|
1891
|
+
input.current_dir,
|
|
1892
|
+
input.currentDir,
|
|
1893
|
+
input.workspace?.project_dir,
|
|
1894
|
+
input.workspace?.projectDir
|
|
1740
1895
|
].filter(Boolean);
|
|
1741
1896
|
|
|
1742
1897
|
for (const dir of dirs) {
|
|
@@ -1746,10 +1901,19 @@ function statuslineGitBranch(input) {
|
|
|
1746
1901
|
return null;
|
|
1747
1902
|
}
|
|
1748
1903
|
|
|
1749
|
-
function claudeHeader(input, args) {
|
|
1750
|
-
const model =
|
|
1751
|
-
|
|
1752
|
-
|
|
1904
|
+
function claudeHeader(input, args, capturedClaude = null) {
|
|
1905
|
+
const model = typeof input.model === "string"
|
|
1906
|
+
? input.model
|
|
1907
|
+
: input.model?.display_name || input.model?.displayName || input.model?.id || "Claude";
|
|
1908
|
+
const effort = input.effort?.level || input.effortLevel || null;
|
|
1909
|
+
const workspaceRoot = input.workspace?.project_dir
|
|
1910
|
+
|| input.workspace?.projectDir
|
|
1911
|
+
|| input.cwd
|
|
1912
|
+
|| input.current_dir
|
|
1913
|
+
|| input.currentDir
|
|
1914
|
+
|| input.workspace?.current_dir
|
|
1915
|
+
|| input.workspace?.currentDir
|
|
1916
|
+
|| "";
|
|
1753
1917
|
const gitBranch = statuslineGitBranch(input);
|
|
1754
1918
|
const parts = [model];
|
|
1755
1919
|
if (effort) parts.push(effort);
|
|
@@ -1760,7 +1924,7 @@ function claudeHeader(input, args) {
|
|
|
1760
1924
|
parts.push(gitBranch);
|
|
1761
1925
|
}
|
|
1762
1926
|
const left = parts.join(" ");
|
|
1763
|
-
const right = contextLeftText(input);
|
|
1927
|
+
const right = contextLeftText(input, capturedClaude?.contextWindow ?? null);
|
|
1764
1928
|
const columns = Math.max(20, statusLineUsableColumns(input) - (args.leftPadding || 0));
|
|
1765
1929
|
const header = alignHeader(left, right, columns);
|
|
1766
1930
|
return statusColorize({ running: false }, header, args);
|
|
@@ -1891,6 +2055,17 @@ function renderLine(snapshot, args) {
|
|
|
1891
2055
|
.join(providerDivider);
|
|
1892
2056
|
}
|
|
1893
2057
|
|
|
2058
|
+
function renderMenuBar(snapshot) {
|
|
2059
|
+
const parts = snapshot.results
|
|
2060
|
+
.map((result) => {
|
|
2061
|
+
const label = result.provider === "codex" ? "Cx" : "Cl";
|
|
2062
|
+
if (!result.ok || typeof result.percentRemaining !== "number") return `${label} --`;
|
|
2063
|
+
return `${label} ${result.percentRemaining}%`;
|
|
2064
|
+
})
|
|
2065
|
+
.filter(Boolean);
|
|
2066
|
+
return parts.length ? parts.join(" | ") : "AI --";
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1894
2069
|
function applyLeftPadding(output, args) {
|
|
1895
2070
|
const padding = Math.max(0, Number(args.leftPadding) || 0);
|
|
1896
2071
|
if (!padding) return output;
|
|
@@ -1903,6 +2078,7 @@ function applyLeftPadding(output, args) {
|
|
|
1903
2078
|
|
|
1904
2079
|
function render(snapshot, args) {
|
|
1905
2080
|
if (args.json) return JSON.stringify(snapshot, null, 2);
|
|
2081
|
+
if (args.menuBar) return renderMenuBar(snapshot);
|
|
1906
2082
|
|
|
1907
2083
|
const maxWidth = args.maxWidth
|
|
1908
2084
|
? Math.max(20, args.maxWidth - (Number(args.leftPadding) || 0))
|
|
@@ -1938,6 +2114,8 @@ async function main() {
|
|
|
1938
2114
|
console.log(`Codex wrapper installed: ${result.codex.wrapperPath}`);
|
|
1939
2115
|
console.log(`Original codex: ${result.codex.originalCommand}`);
|
|
1940
2116
|
if (result.codex.path?.note) console.log(result.codex.path.note);
|
|
2117
|
+
const reloadCommand = sourcePathCommand(result.codex.path?.rcPath);
|
|
2118
|
+
if (reloadCommand) console.log(`For this terminal now, run: ${reloadCommand}`);
|
|
1941
2119
|
} else if (result.codex?.skipped) {
|
|
1942
2120
|
console.log(`Codex wrapper skipped: ${result.codex.reason}`);
|
|
1943
2121
|
}
|
|
@@ -1958,6 +2136,8 @@ async function main() {
|
|
|
1958
2136
|
console.log(`Codex provider: ${result.codex.providerEnabled ? "on" : "off"}`);
|
|
1959
2137
|
console.log(`Codex on PATH: ${result.codex.activeCodex || "not found"}`);
|
|
1960
2138
|
console.log(`Codex wrapper: ${result.codex.wrapperInstalled ? "installed" : "missing"} (${result.codex.wrapperPath})`);
|
|
2139
|
+
console.log(`AI Battery runner: ${result.codex.runnerExists ? "found" : "missing"} (${result.codex.runnerPath})`);
|
|
2140
|
+
console.log(`python3: ${result.codex.python3 || "not found"}`);
|
|
1961
2141
|
console.log(`PATH uses wrapper: ${result.codex.activeIsWrapper ? "yes" : "no"}`);
|
|
1962
2142
|
console.log(`Inside Codex: ${result.codex.insideCodex ? "yes" : "no"}`);
|
|
1963
2143
|
console.log(`Current Codex wrapped: ${result.codex.currentCodexWrapped ? "yes" : "no"}`);
|
|
@@ -2032,7 +2212,7 @@ async function main() {
|
|
|
2032
2212
|
const claudeData = claudeStatuslineResultFromCache(capturedClaude, capturedSource, { includeBackground: true }) ?? readClaude();
|
|
2033
2213
|
results.push({ ...claudeData, running: true });
|
|
2034
2214
|
}
|
|
2035
|
-
const header = args.header ? applyLeftPadding(claudeHeader(input, args), args) : "";
|
|
2215
|
+
const header = args.header ? applyLeftPadding(claudeHeader(input, args, capturedClaude), args) : "";
|
|
2036
2216
|
const usage = render({
|
|
2037
2217
|
generatedAt: new Date().toISOString(),
|
|
2038
2218
|
results
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-battery",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Tiny terminal battery meter for local Codex and Claude Code usage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
"bin/ai-battery-run",
|
|
26
26
|
"bin/ai-battery-hud",
|
|
27
27
|
"bin/ai-battery-hud.js",
|
|
28
|
+
"bin/ai-battery-macos-status.applescript",
|
|
28
29
|
"bin/ai-battery-hud.ps1",
|
|
29
30
|
"docs/*.svg",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
34
|
"start": "node ./bin/ai-battery.js",
|
|
34
|
-
"check": "node --check ./bin/ai-battery.js"
|
|
35
|
+
"check": "node --check ./bin/ai-battery.js && node --check ./bin/ai-battery-hud.js"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
37
38
|
"node": ">=18"
|