ai-battery 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/ai-battery-hud +89 -0
- package/bin/ai-battery-hud.js +332 -0
- package/bin/ai-battery-hud.ps1 +1835 -0
- package/bin/ai-battery-run +344 -0
- package/bin/ai-battery.js +2082 -0
- package/docs/claude-statusline-preview.svg +37 -0
- package/docs/terminal-preview.svg +44 -0
- package/package.json +48 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
if os.name == "nt":
|
|
11
|
+
print(
|
|
12
|
+
"ai-battery-run requires a POSIX PTY and is supported in WSL, Linux, and macOS, not native Windows.",
|
|
13
|
+
file=sys.stderr,
|
|
14
|
+
)
|
|
15
|
+
raise SystemExit(2)
|
|
16
|
+
|
|
17
|
+
import errno
|
|
18
|
+
import fcntl
|
|
19
|
+
import pty
|
|
20
|
+
import select
|
|
21
|
+
import struct
|
|
22
|
+
import termios
|
|
23
|
+
import tty
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
27
|
+
BATTERY_BIN = os.environ.get("AI_BATTERY_BIN") or os.environ.get("CLAUDEX_BATTERY_BIN", os.path.join(SCRIPT_DIR, "ai-battery.js"))
|
|
28
|
+
ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def battery_command_base():
|
|
32
|
+
# Running the .js file directly depends on its exec bit and shebang;
|
|
33
|
+
# prefer an explicit node invocation when node is available.
|
|
34
|
+
if BATTERY_BIN.endswith(".js"):
|
|
35
|
+
node = shutil.which("node")
|
|
36
|
+
if node:
|
|
37
|
+
return [node, BATTERY_BIN]
|
|
38
|
+
return [BATTERY_BIN]
|
|
39
|
+
DEFAULT_COLUMN_GUARD = 4
|
|
40
|
+
DEFAULT_LEFT_PADDING = 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def usage():
|
|
44
|
+
print(
|
|
45
|
+
"Usage: ai-battery-run [--interval SECONDS] [--bar-width N] [--provider auto|all|codex|claude] [--layout auto|overlay|reserve] [--left-padding N] -- COMMAND [ARGS...]\n"
|
|
46
|
+
" ai-battery-run [--interval SECONDS] [--bar-width N] [--provider auto|all|codex|claude] [--layout auto|overlay|reserve] [--left-padding N] COMMAND [ARGS...]\n\n"
|
|
47
|
+
"Runs COMMAND in a PTY and keeps AI Battery on\n"
|
|
48
|
+
"the terminal's bottom row. Example:\n"
|
|
49
|
+
" ai-battery-run codex\n"
|
|
50
|
+
" ai-battery-run --provider codex codex\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_args(argv):
|
|
55
|
+
interval = float(os.environ.get("AI_BATTERY_INTERVAL") or os.environ.get("CLAUDEX_BATTERY_INTERVAL", "10"))
|
|
56
|
+
bar_width = os.environ.get("AI_BATTERY_BAR_WIDTH") or os.environ.get("CLAUDEX_BATTERY_BAR_WIDTH", "10")
|
|
57
|
+
provider = os.environ.get("AI_BATTERY_PROVIDER") or os.environ.get("CLAUDEX_BATTERY_PROVIDER", "auto")
|
|
58
|
+
layout = os.environ.get("AI_BATTERY_LAYOUT") or os.environ.get("CLAUDEX_BATTERY_LAYOUT", "auto")
|
|
59
|
+
left_padding = os.environ.get("AI_BATTERY_LEFT_PADDING") or os.environ.get("CLAUDEX_BATTERY_LEFT_PADDING", str(DEFAULT_LEFT_PADDING))
|
|
60
|
+
command = []
|
|
61
|
+
i = 0
|
|
62
|
+
while i < len(argv):
|
|
63
|
+
arg = argv[i]
|
|
64
|
+
if arg in ("-h", "--help"):
|
|
65
|
+
usage()
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
if arg == "--interval":
|
|
68
|
+
i += 1
|
|
69
|
+
if i >= len(argv):
|
|
70
|
+
raise SystemExit("--interval requires a value")
|
|
71
|
+
interval = max(0.5, float(argv[i]))
|
|
72
|
+
elif arg == "--bar-width":
|
|
73
|
+
i += 1
|
|
74
|
+
if i >= len(argv):
|
|
75
|
+
raise SystemExit("--bar-width requires a value")
|
|
76
|
+
bar_width = argv[i]
|
|
77
|
+
elif arg == "--provider":
|
|
78
|
+
i += 1
|
|
79
|
+
if i >= len(argv):
|
|
80
|
+
raise SystemExit("--provider requires a value")
|
|
81
|
+
provider = argv[i]
|
|
82
|
+
if provider not in ("auto", "all", "codex", "claude"):
|
|
83
|
+
raise SystemExit("--provider must be one of: auto, all, codex, claude")
|
|
84
|
+
elif arg == "--layout":
|
|
85
|
+
i += 1
|
|
86
|
+
if i >= len(argv):
|
|
87
|
+
raise SystemExit("--layout requires a value")
|
|
88
|
+
layout = argv[i]
|
|
89
|
+
if layout not in ("auto", "overlay", "reserve"):
|
|
90
|
+
raise SystemExit("--layout must be one of: auto, overlay, reserve")
|
|
91
|
+
elif arg == "--left-padding":
|
|
92
|
+
i += 1
|
|
93
|
+
if i >= len(argv):
|
|
94
|
+
raise SystemExit("--left-padding requires a value")
|
|
95
|
+
left_padding = argv[i]
|
|
96
|
+
elif arg == "--":
|
|
97
|
+
command = argv[i + 1 :]
|
|
98
|
+
break
|
|
99
|
+
else:
|
|
100
|
+
command = argv[i:]
|
|
101
|
+
break
|
|
102
|
+
i += 1
|
|
103
|
+
|
|
104
|
+
if not command:
|
|
105
|
+
usage()
|
|
106
|
+
raise SystemExit(2)
|
|
107
|
+
if layout not in ("auto", "overlay", "reserve"):
|
|
108
|
+
raise SystemExit("--layout must be one of: auto, overlay, reserve")
|
|
109
|
+
try:
|
|
110
|
+
left_padding = min(20, max(0, int(float(left_padding))))
|
|
111
|
+
except ValueError:
|
|
112
|
+
left_padding = DEFAULT_LEFT_PADDING
|
|
113
|
+
return interval, bar_width, provider, layout, left_padding, command
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def infer_provider(command):
|
|
117
|
+
joined = " ".join(command).lower()
|
|
118
|
+
names = [os.path.basename(part).lower() for part in command]
|
|
119
|
+
if any("codex" in name for name in names) or "@openai/codex" in joined:
|
|
120
|
+
return "codex"
|
|
121
|
+
if any("claude" in name for name in names) or "@anthropic-ai/claude-code" in joined:
|
|
122
|
+
return "claude"
|
|
123
|
+
return "all"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_layout(layout, command_provider):
|
|
127
|
+
if layout != "auto":
|
|
128
|
+
return layout
|
|
129
|
+
return "reserve"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def term_size():
|
|
133
|
+
try:
|
|
134
|
+
size = os.get_terminal_size(sys.stdout.fileno())
|
|
135
|
+
return max(20, size.columns or 80), max(1, size.lines or 24)
|
|
136
|
+
except OSError:
|
|
137
|
+
return 80, 24
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_winsize(fd, rows, cols):
|
|
141
|
+
packed = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
|
|
142
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, packed)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def child_rows_for(layout, rows):
|
|
146
|
+
if layout == "overlay":
|
|
147
|
+
return max(1, rows)
|
|
148
|
+
return max(1, rows - 1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def strip_ansi(text):
|
|
152
|
+
return ANSI_RE.sub("", text)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def column_guard():
|
|
156
|
+
raw = os.environ.get("AI_BATTERY_COLUMN_GUARD") or os.environ.get("CLAUDEX_BATTERY_COLUMN_GUARD")
|
|
157
|
+
try:
|
|
158
|
+
return min(20, max(0, int(float(raw)))) if raw is not None else DEFAULT_COLUMN_GUARD
|
|
159
|
+
except ValueError:
|
|
160
|
+
return DEFAULT_COLUMN_GUARD
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fit_ansi(text, width, pad=True):
|
|
164
|
+
plain = strip_ansi(text)
|
|
165
|
+
if len(plain) <= width:
|
|
166
|
+
return text + (" " * (width - len(plain)) if pad else "")
|
|
167
|
+
suffix = " " if pad else ""
|
|
168
|
+
return plain[: max(0, width - len(suffix))] + suffix
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class StatusLine:
|
|
172
|
+
def __init__(self, interval, bar_width, provider, left_padding, active_provider):
|
|
173
|
+
self.interval = interval
|
|
174
|
+
self.bar_width = bar_width
|
|
175
|
+
self.provider = provider
|
|
176
|
+
self.left_padding = left_padding
|
|
177
|
+
self.active_provider = active_provider
|
|
178
|
+
self.next_fetch = 0.0
|
|
179
|
+
self.last_draw = 0.0
|
|
180
|
+
self.text = "AI Battery starting..."
|
|
181
|
+
self.last_line = None
|
|
182
|
+
self.cols, self.rows = term_size()
|
|
183
|
+
|
|
184
|
+
def resize(self):
|
|
185
|
+
self.cols, self.rows = term_size()
|
|
186
|
+
|
|
187
|
+
def refresh(self, force=False):
|
|
188
|
+
now = time.monotonic()
|
|
189
|
+
if not force and now < self.next_fetch:
|
|
190
|
+
return False
|
|
191
|
+
self.next_fetch = now + self.interval
|
|
192
|
+
previous = self.text
|
|
193
|
+
max_width = max(20, self.cols - column_guard())
|
|
194
|
+
try:
|
|
195
|
+
battery_command = battery_command_base() + [
|
|
196
|
+
"--muted",
|
|
197
|
+
"--bar-width",
|
|
198
|
+
str(self.bar_width),
|
|
199
|
+
"--max-width",
|
|
200
|
+
str(max_width),
|
|
201
|
+
"--left-padding",
|
|
202
|
+
str(self.left_padding),
|
|
203
|
+
]
|
|
204
|
+
if self.active_provider in ("codex", "claude"):
|
|
205
|
+
battery_command.extend(["--active-provider", self.active_provider])
|
|
206
|
+
if self.provider != "all":
|
|
207
|
+
battery_command.extend(["--provider", self.provider])
|
|
208
|
+
output = subprocess.check_output(
|
|
209
|
+
battery_command,
|
|
210
|
+
stderr=subprocess.DEVNULL,
|
|
211
|
+
timeout=1.5,
|
|
212
|
+
)
|
|
213
|
+
text = output.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
214
|
+
if text:
|
|
215
|
+
self.text = text
|
|
216
|
+
except Exception:
|
|
217
|
+
self.text = "AI Battery unavailable"
|
|
218
|
+
return self.text != previous
|
|
219
|
+
|
|
220
|
+
def refresh_due(self):
|
|
221
|
+
return time.monotonic() >= self.next_fetch
|
|
222
|
+
|
|
223
|
+
def seconds_until_refresh(self):
|
|
224
|
+
return max(0.0, self.next_fetch - time.monotonic())
|
|
225
|
+
|
|
226
|
+
def draw(self, force=False, dirty=False):
|
|
227
|
+
now = time.monotonic()
|
|
228
|
+
if not force and now - self.last_draw < 0.15:
|
|
229
|
+
return
|
|
230
|
+
self.refresh(force)
|
|
231
|
+
paint_width = max(1, self.cols - 1)
|
|
232
|
+
line = fit_ansi(self.text, paint_width, pad=True)
|
|
233
|
+
if not force and not dirty and line == self.last_line:
|
|
234
|
+
return
|
|
235
|
+
self.last_draw = now
|
|
236
|
+
self.last_line = line
|
|
237
|
+
payload = (
|
|
238
|
+
"\x1b7"
|
|
239
|
+
"\x1b[0m"
|
|
240
|
+
f"\x1b[{self.rows};1H"
|
|
241
|
+
"\r\x1b[1G"
|
|
242
|
+
f"{line}"
|
|
243
|
+
"\x1b[K"
|
|
244
|
+
"\x1b[0m"
|
|
245
|
+
"\x1b8"
|
|
246
|
+
)
|
|
247
|
+
os.write(sys.stdout.fileno(), payload.encode("utf-8", errors="replace"))
|
|
248
|
+
|
|
249
|
+
def clear(self):
|
|
250
|
+
payload = "\x1b7" "\x1b[0m" f"\x1b[{self.rows};1H" "\r\x1b[1G" "\x1b[2K" "\x1b8"
|
|
251
|
+
os.write(sys.stdout.fileno(), payload.encode("ascii"))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main():
|
|
255
|
+
interval, bar_width, provider, layout, left_padding, command = parse_args(sys.argv[1:])
|
|
256
|
+
command_provider = infer_provider(command)
|
|
257
|
+
if provider == "auto":
|
|
258
|
+
provider = command_provider
|
|
259
|
+
layout = resolve_layout(layout, command_provider)
|
|
260
|
+
|
|
261
|
+
if not os.isatty(sys.stdin.fileno()) or not os.isatty(sys.stdout.fileno()):
|
|
262
|
+
print(
|
|
263
|
+
"ai-battery-run: stdin/stdout is not a real terminal.\n"
|
|
264
|
+
"Run this directly in an interactive terminal, not through a non-TTY command runner.",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
raise SystemExit(2)
|
|
268
|
+
|
|
269
|
+
active_provider = command_provider if command_provider in ("codex", "claude") else None
|
|
270
|
+
status = StatusLine(interval, bar_width, provider, left_padding, active_provider)
|
|
271
|
+
cols, rows = term_size()
|
|
272
|
+
child_rows = child_rows_for(layout, rows)
|
|
273
|
+
|
|
274
|
+
pid, master_fd = pty.fork()
|
|
275
|
+
if pid == 0:
|
|
276
|
+
set_winsize(0, child_rows, cols)
|
|
277
|
+
os.execvp(command[0], command)
|
|
278
|
+
|
|
279
|
+
set_winsize(master_fd, child_rows, cols)
|
|
280
|
+
old_term = termios.tcgetattr(sys.stdin.fileno())
|
|
281
|
+
child_exit = 0
|
|
282
|
+
|
|
283
|
+
def on_resize(_signum, _frame):
|
|
284
|
+
status.resize()
|
|
285
|
+
new_cols, new_rows = term_size()
|
|
286
|
+
set_winsize(master_fd, child_rows_for(layout, new_rows), new_cols)
|
|
287
|
+
try:
|
|
288
|
+
os.kill(pid, signal.SIGWINCH)
|
|
289
|
+
except ProcessLookupError:
|
|
290
|
+
pass
|
|
291
|
+
status.draw(True)
|
|
292
|
+
|
|
293
|
+
signal.signal(signal.SIGWINCH, on_resize)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
tty.setraw(sys.stdin.fileno())
|
|
297
|
+
status.draw(True)
|
|
298
|
+
screen_dirty = False
|
|
299
|
+
last_child_output = 0.0
|
|
300
|
+
|
|
301
|
+
while True:
|
|
302
|
+
wait_pid, wait_status = os.waitpid(pid, os.WNOHANG)
|
|
303
|
+
if wait_pid == pid:
|
|
304
|
+
if os.WIFEXITED(wait_status):
|
|
305
|
+
child_exit = os.WEXITSTATUS(wait_status)
|
|
306
|
+
elif os.WIFSIGNALED(wait_status):
|
|
307
|
+
child_exit = 128 + os.WTERMSIG(wait_status)
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
timeout = 0.05 if screen_dirty else min(0.1, max(0.01, status.seconds_until_refresh()))
|
|
311
|
+
readable, _, _ = select.select([master_fd, sys.stdin.fileno()], [], [], timeout)
|
|
312
|
+
if master_fd in readable:
|
|
313
|
+
try:
|
|
314
|
+
data = os.read(master_fd, 8192)
|
|
315
|
+
except OSError as exc:
|
|
316
|
+
if exc.errno == errno.EIO:
|
|
317
|
+
break
|
|
318
|
+
raise
|
|
319
|
+
if not data:
|
|
320
|
+
break
|
|
321
|
+
os.write(sys.stdout.fileno(), data)
|
|
322
|
+
screen_dirty = True
|
|
323
|
+
last_child_output = time.monotonic()
|
|
324
|
+
|
|
325
|
+
if sys.stdin.fileno() in readable:
|
|
326
|
+
data = os.read(sys.stdin.fileno(), 8192)
|
|
327
|
+
if data:
|
|
328
|
+
os.write(master_fd, data)
|
|
329
|
+
|
|
330
|
+
now = time.monotonic()
|
|
331
|
+
if screen_dirty and (now - last_child_output >= 0.05 or now - status.last_draw >= 0.75):
|
|
332
|
+
status.draw(False, dirty=True)
|
|
333
|
+
screen_dirty = False
|
|
334
|
+
elif status.refresh_due():
|
|
335
|
+
status.draw(False)
|
|
336
|
+
finally:
|
|
337
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_term)
|
|
338
|
+
status.clear()
|
|
339
|
+
|
|
340
|
+
raise SystemExit(child_exit)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
main()
|