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.
@@ -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()