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 CHANGED
@@ -9,7 +9,7 @@ Codex와 Claude Code의 남은 사용량을 배터리처럼 확인하는 터미
9
9
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20WSL%20%7C%20Linux%20%7C%20macOS-blue)
10
10
  ![License](https://img.shields.io/badge/license-MIT-green)
11
11
 
12
- [Install](#install) · [Features](#features) · [Quick Start](#quick-start) · [Claude StatusLine](#claude-statusline) · [Floating HUD](#floating-hud) · [Caution](#caution)
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
- | Windows HUD | Windows nativeWSL에서 실행하는 작은 floating HUD 제공합니다. |
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` | 지원 | 지원 | 미지원 | 미지원 | Windows PowerShell/WinForms HUD입니다. Linux/macOS 터미널에서는 `ai-battery --watch`를 사용하세요. |
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. Windows HUD 필요하면 실행합니다.
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
- ## Floating HUD
205
+ ## Desktop HUD
200
206
 
201
- 일반 터미널 위에 외부 프로세스가 안전하게 status line을 덧그리는 방식은 안정적이지 않습니다. 그래서 HUD는 Windows floating overlay 제공합니다. Windows native에서는 WSL 없이 PowerShell/WinForms로 바로 실행되고, WSL에서는 `powershell.exe`를 통해 같은 HUD를 띄웁니다.
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 종료합니다. (`--stop`도 동일) |
218
- | `ai-battery hud status` | HUD 실행 여부와 autostart 등록 상태를 보여줍니다. |
219
- | `ai-battery hud autostart on` | Windows 로그인 시 HUD 자동 실행을 등록합니다. |
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`에 두어 로그인 WSL이 아직 시작되지 않아도 HUD가 먼저 뜨도록 합니다(WSL 로그 기반 사용량은 WSL이 올라온 뒤 채워집니다). ai-battery를 업데이트한 뒤에는 `ai-battery hud autostart on`을 다시 실행해 사본을 갱신하세요.
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[Windows HUD]
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에서 PowerShell HUD 실행 |
262
- | HUD UI | PowerShell WinForms | Windows floating overlay tray icon |
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 사용하므로 Windows native와 WSL에서만 지원됩니다.
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 기반입니다. Windows native에서는 직접 실행하고, WSL에서는 `powershell.exe`와 `wsl.exe`를 함께 사용합니다.
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가 잠시 흔들릴 수 있습니다.
@@ -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, "&amp;")
220
+ .replace(/</g, "&lt;")
221
+ .replace(/>/g, "&gt;")
222
+ .replace(/"/g, "&quot;");
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: the floating HUD needs Windows (native or WSL).");
120
- console.error("On macOS/Linux terminals use: ai-battery --watch");
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 usedKey = options.usedKey || "used_percent";
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?.[usedKey];
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 resetPassed = Number.isFinite(limit.resets_at) && limit.resets_at <= nowSeconds;
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: limit.resets_at ? new Date(limit.resets_at * 1000).toISOString() : null,
697
- resetsInSeconds: limit.resets_at ? Math.max(0, limit.resets_at - nowSeconds) : null,
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 hasUsedPercentage = Number.isFinite(limit.used_percentage);
936
- const remainingPercentage = [
937
- limit.remaining_percentage,
938
- limit.remaining_percent,
939
- limit.percent_remaining
940
- ].find((value) => Number.isFinite(value));
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(limit.resets_at);
1019
+ const hasReset = Number.isFinite(resetsAt);
943
1020
  if (!hasUsedPercentage && !hasRemainingPercentage && !hasReset) return null;
944
1021
  return {
945
1022
  ...limit,
946
- used_percentage: hasUsedPercentage ? limit.used_percentage : null,
1023
+ used_percentage: hasUsedPercentage ? usedPercentage : null,
947
1024
  remaining_percentage: hasRemainingPercentage ? remainingPercentage : null,
948
- resets_at: hasReset ? limit.resets_at : null,
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.rate_limits ?? {};
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.five_hour, 300),
1130
- sevenDay: claudeLimitFromStatusline(rateLimits.seven_day, 10080)
1282
+ fiveHour: claudeLimitFromStatusline(rateLimits.fiveHour, 300),
1283
+ sevenDay: claudeLimitFromStatusline(rateLimits.sevenDay, 10080)
1131
1284
  },
1132
- rawRateLimits: rateLimits,
1133
- contextWindow: input.context_window
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 (sameSession && !snapshot.rateLimits.fiveHour) {
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 (sameSession && !snapshot.rateLimits.sevenDay) {
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.context_window ?? input.contextWindow ?? null;
1679
- const remaining = context?.remaining_percentage ?? context?.remainingPercentage;
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?.used_percentage ?? context?.usedPercentage;
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.workspace?.project_dir
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 = input.model?.display_name || input.model?.id || "Claude";
1751
- const effort = input.effort?.level || null;
1752
- const workspaceRoot = input.workspace?.project_dir || input.cwd || input.workspace?.current_dir || "";
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.0",
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"