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,1835 @@
1
+ param(
2
+ [int]$Interval = 5,
3
+ [string]$Position = $(if ($env:AI_BATTERY_HUD_POSITION) { $env:AI_BATTERY_HUD_POSITION } elseif ($env:CLAUDEX_BATTERY_HUD_POSITION) { $env:CLAUDEX_BATTERY_HUD_POSITION } else { "saved" }),
4
+ [ValidateSet("tray", "statusline", "floating")]
5
+ [string]$Mode = $(if ($env:AI_BATTERY_HUD_MODE) { $env:AI_BATTERY_HUD_MODE } elseif ($env:CLAUDEX_BATTERY_HUD_MODE) { $env:CLAUDEX_BATTERY_HUD_MODE } else { "floating" }),
6
+ [string]$BatteryCommand = $(if ($env:AI_BATTERY_COMMAND) { $env:AI_BATTERY_COMMAND } elseif ($env:CLAUDEX_BATTERY_COMMAND) { $env:CLAUDEX_BATTERY_COMMAND } else { "ai-battery --json" }),
7
+ [string]$InitialJsonBase64 = "",
8
+ [int]$Width = 282,
9
+ [double]$Opacity = $(if ($env:AI_BATTERY_HUD_OPACITY) { [double]$env:AI_BATTERY_HUD_OPACITY } elseif ($env:CLAUDEX_BATTERY_HUD_OPACITY) { [double]$env:CLAUDEX_BATTERY_HUD_OPACITY } else { 1.0 }),
10
+ [switch]$Locked,
11
+ [switch]$Movable,
12
+ [switch]$ClickThrough,
13
+ [switch]$StopExisting,
14
+ [switch]$UseWsl,
15
+ [switch]$Once
16
+ )
17
+
18
+ $ErrorActionPreference = "Stop"
19
+ Add-Type -AssemblyName System.Drawing
20
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
21
+
22
+ function Stop-ExistingHudProcesses {
23
+ $currentPid = $PID
24
+ Get-CimInstance Win32_Process |
25
+ Where-Object {
26
+ $_.ProcessId -ne $currentPid -and
27
+ $_.Name -match '^(powershell|pwsh)(\.exe)?$' -and
28
+ $_.CommandLine -and
29
+ $_.CommandLine -like "*ai-battery-hud.ps1*" -and
30
+ $_.CommandLine -notlike "*Start-Process*"
31
+ } |
32
+ ForEach-Object {
33
+ try {
34
+ Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop
35
+ Wait-Process -Id $_.ProcessId -Timeout 3 -ErrorAction SilentlyContinue
36
+ } catch {
37
+ # The process may already have exited.
38
+ }
39
+ }
40
+ }
41
+
42
+ if ($StopExisting) {
43
+ Stop-ExistingHudProcesses
44
+ if (-not $Once) {
45
+ exit 0
46
+ }
47
+ } elseif (-not $Once) {
48
+ Stop-ExistingHudProcesses
49
+ }
50
+
51
+ $script:singleInstanceMutex = $null
52
+ if (-not $Once) {
53
+ # A replaced instance we just stopped can hold the mutex handle for a
54
+ # moment after Stop-Process returns; retry briefly before treating the
55
+ # mutex owner as a genuinely running HUD.
56
+ $createdNew = $false
57
+ for ($mutexAttempt = 0; $mutexAttempt -lt 20; $mutexAttempt += 1) {
58
+ $script:singleInstanceMutex = [System.Threading.Mutex]::new($true, "Local\AiBatteryHud", [ref]$createdNew)
59
+ if ($createdNew) { break }
60
+ $script:singleInstanceMutex.Dispose()
61
+ $script:singleInstanceMutex = $null
62
+ Start-Sleep -Milliseconds 150
63
+ }
64
+ if (-not $createdNew) {
65
+ exit 0
66
+ }
67
+ }
68
+
69
+ function Release-SingleInstance {
70
+ if ($script:singleInstanceMutex) {
71
+ try {
72
+ $script:singleInstanceMutex.ReleaseMutex()
73
+ } catch {
74
+ # Already released.
75
+ }
76
+ $script:singleInstanceMutex.Dispose()
77
+ $script:singleInstanceMutex = $null
78
+ }
79
+ }
80
+
81
+ function Get-HudStatePath {
82
+ $root = if ($env:LOCALAPPDATA) {
83
+ Join-Path $env:LOCALAPPDATA "ai-battery"
84
+ } else {
85
+ Join-Path $env:TEMP "ai-battery"
86
+ }
87
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
88
+ return Join-Path $root "hud-position.json"
89
+ }
90
+
91
+ function Get-HudSnapshotPath {
92
+ $root = if ($env:LOCALAPPDATA) {
93
+ Join-Path $env:LOCALAPPDATA "ai-battery"
94
+ } else {
95
+ Join-Path $env:TEMP "ai-battery"
96
+ }
97
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
98
+ return Join-Path $root "hud-snapshot.json"
99
+ }
100
+
101
+ function Get-LegacyHudStatePath {
102
+ $root = if ($env:LOCALAPPDATA) {
103
+ Join-Path $env:LOCALAPPDATA "claudex-battery"
104
+ } else {
105
+ Join-Path $env:TEMP "claudex-battery"
106
+ }
107
+ return Join-Path $root "hud-position.json"
108
+ }
109
+
110
+ $script:hudAnchorX = "left"
111
+ $script:hudAnchorY = "top"
112
+ $script:codexRowVisible = $true
113
+ $script:claudeRowVisible = $true
114
+
115
+ function Read-HudPlacement {
116
+ foreach ($path in @((Get-HudStatePath), (Get-LegacyHudStatePath))) {
117
+ try {
118
+ if (-not (Test-Path $path)) { continue }
119
+ $state = Get-Content $path -Raw | ConvertFrom-Json
120
+ if ($null -ne $state.X -and $null -ne $state.Y) {
121
+ return $state
122
+ }
123
+ } catch {
124
+ # Try the next state path.
125
+ }
126
+ }
127
+ return $null
128
+ }
129
+
130
+ function Read-HudPosition {
131
+ $state = Read-HudPlacement
132
+ if (-not $state) { return $null }
133
+ return [System.Drawing.Point]::new([int]$state.X, [int]$state.Y)
134
+ }
135
+
136
+ function Write-HudPosition($Point) {
137
+ try {
138
+ $width = $null
139
+ $height = $null
140
+ if ($form -and -not $form.IsDisposed) {
141
+ $width = [int]$form.Width
142
+ $height = [int]$form.Height
143
+ }
144
+ @{
145
+ X = [int]$Point.X
146
+ Y = [int]$Point.Y
147
+ Width = $width
148
+ Height = $height
149
+ } | ConvertTo-Json -Compress | Set-Content -Encoding UTF8 -Path (Get-HudStatePath)
150
+ } catch {
151
+ # Position persistence is helpful but not required for the HUD to run.
152
+ }
153
+ }
154
+
155
+ function Get-SnapshotAgeSeconds($Snapshot) {
156
+ if (-not $Snapshot -or -not $Snapshot.generatedAt) { return $null }
157
+ try {
158
+ return [math]::Max(0, [int](([datetime]::UtcNow - ([datetime]$Snapshot.generatedAt).ToUniversalTime()).TotalSeconds))
159
+ } catch {
160
+ return $null
161
+ }
162
+ }
163
+
164
+ function Read-HudSnapshot {
165
+ try {
166
+ $path = Get-HudSnapshotPath
167
+ if (-not (Test-Path $path)) { return $null }
168
+ $snapshot = Get-Content $path -Raw | ConvertFrom-Json
169
+ $age = Get-SnapshotAgeSeconds $snapshot
170
+ $maxAge = if ($env:AI_BATTERY_HUD_CACHE_SECONDS) { [int]$env:AI_BATTERY_HUD_CACHE_SECONDS } else { 900 }
171
+ if ($null -ne $age -and $age -le $maxAge) { return $snapshot }
172
+ } catch {
173
+ # Cached data is optional.
174
+ }
175
+ return $null
176
+ }
177
+
178
+ function Read-InitialHudSnapshot {
179
+ if ([string]::IsNullOrWhiteSpace($InitialJsonBase64)) { return $null }
180
+ try {
181
+ $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($InitialJsonBase64))
182
+ if ([string]::IsNullOrWhiteSpace($json)) { return $null }
183
+ return $json | ConvertFrom-Json
184
+ } catch {
185
+ return $null
186
+ }
187
+ }
188
+
189
+ function Write-HudSnapshot($Snapshot) {
190
+ try {
191
+ if (-not $Snapshot) { return }
192
+ $Snapshot | ConvertTo-Json -Depth 20 -Compress | Set-Content -Encoding UTF8 -Path (Get-HudSnapshotPath)
193
+ } catch {
194
+ # The HUD can still run without a warm-start cache.
195
+ }
196
+ }
197
+
198
+ $script:initialHudSnapshot = Read-InitialHudSnapshot
199
+
200
+ $nativeCode = @"
201
+ using System;
202
+ using System.Runtime.InteropServices;
203
+ [StructLayout(LayoutKind.Sequential)]
204
+ public struct AiBatteryRect {
205
+ public int Left;
206
+ public int Top;
207
+ public int Right;
208
+ public int Bottom;
209
+ }
210
+ [StructLayout(LayoutKind.Sequential)]
211
+ public struct AiBatteryMonitorInfo {
212
+ public int CbSize;
213
+ public AiBatteryRect Monitor;
214
+ public AiBatteryRect Work;
215
+ public int Flags;
216
+ }
217
+ public static class AiBatteryNative {
218
+ public const int GWL_EXSTYLE = -20;
219
+ public const int WS_EX_TRANSPARENT = 0x20;
220
+ public const int WS_EX_TOOLWINDOW = 0x80;
221
+ public const int WS_EX_NOACTIVATE = 0x08000000;
222
+ public const UInt32 MONITOR_DEFAULTTONEAREST = 2;
223
+ public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
224
+ public const UInt32 SWP_NOSIZE = 0x0001;
225
+ public const UInt32 SWP_NOMOVE = 0x0002;
226
+ public const UInt32 SWP_NOACTIVATE = 0x0010;
227
+ public const UInt32 SWP_SHOWWINDOW = 0x0040;
228
+ [DllImport("user32.dll")]
229
+ public static extern IntPtr GetForegroundWindow();
230
+ [DllImport("user32.dll")]
231
+ public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
232
+ [DllImport("user32.dll")]
233
+ public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
234
+ [DllImport("user32.dll")]
235
+ public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, UInt32 uFlags);
236
+ [DllImport("user32.dll", CharSet=CharSet.Auto)]
237
+ public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
238
+ [DllImport("user32.dll", CharSet=CharSet.Auto)]
239
+ public static extern IntPtr FindWindowEx(IntPtr hwndParent, IntPtr hwndChildAfter, string lpszClass, string lpszWindow);
240
+ [DllImport("user32.dll")]
241
+ public static extern bool GetWindowRect(IntPtr hWnd, out AiBatteryRect rect);
242
+ [DllImport("user32.dll")]
243
+ public static extern IntPtr MonitorFromWindow(IntPtr hWnd, UInt32 dwFlags);
244
+ [DllImport("user32.dll")]
245
+ public static extern bool GetMonitorInfo(IntPtr hMonitor, ref AiBatteryMonitorInfo lpmi);
246
+ [DllImport("user32.dll")]
247
+ public static extern bool DestroyIcon(IntPtr hIcon);
248
+ }
249
+ "@
250
+ Add-Type -TypeDefinition $nativeCode
251
+
252
+ function Invoke-AiBatteryJson {
253
+ if ($UseWsl) {
254
+ $output = & wsl.exe bash -lc "$BatteryCommand 2>/dev/null"
255
+ } else {
256
+ $output = Invoke-Expression "$BatteryCommand 2>`$null"
257
+ }
258
+ $text = ($output -join "`n").Trim()
259
+ if (-not $text) { throw "ai-battery produced no output" }
260
+ return $text | ConvertFrom-Json
261
+ }
262
+
263
+ function Get-DurationText([Nullable[int]]$Seconds) {
264
+ if ($null -eq $Seconds) { return "?" }
265
+ if ($Seconds -le 0) { return "now" }
266
+ $minutes = [math]::Floor($Seconds / 60)
267
+ if ($minutes -lt 60) { return "${minutes}m" }
268
+ $hours = [math]::Floor($minutes / 60)
269
+ $restMinutes = $minutes % 60
270
+ if ($hours -lt 48) {
271
+ if ($restMinutes -gt 0) { return "${hours}h${restMinutes}m" }
272
+ return "${hours}h"
273
+ }
274
+ $days = [math]::Floor($hours / 24)
275
+ $restHours = $hours % 24
276
+ if ($restHours -gt 0) { return "${days}d${restHours}h" }
277
+ return "${days}d"
278
+ }
279
+
280
+ function Get-ResetClock($Limit) {
281
+ if (-not $Limit -or -not $Limit.resetsAt) { return "--:--" }
282
+ if ($Limit.resetPassed) { return "--:--" }
283
+ try {
284
+ return ([datetime]$Limit.resetsAt).ToLocalTime().ToString("HH:mm")
285
+ } catch {
286
+ return "--:--"
287
+ }
288
+ }
289
+
290
+ function Get-WindowText($Minutes) {
291
+ if ($Minutes -eq 300) { return "5h" }
292
+ if ($Minutes -eq 10080) { return "7d" }
293
+ if (-not $Minutes) { return "?" }
294
+ if (($Minutes % 1440) -eq 0) { return "$($Minutes / 1440)d" }
295
+ if (($Minutes % 60) -eq 0) { return "$($Minutes / 60)h" }
296
+ return "${Minutes}m"
297
+ }
298
+
299
+ function Get-Bar([Nullable[int]]$Percent, [int]$Width = 10) {
300
+ $unknown = [string][char]0x2500
301
+ $fullBlock = [string][char]0x2588
302
+ $emptyBlock = [string][char]0x2591
303
+ if ($null -eq $Percent) { return $unknown * $Width }
304
+ # Whole cells only, matching bar() in ai-battery.js: partial blocks read as
305
+ # a hole in the bar and render inconsistently across fonts.
306
+ $clamped = [math]::Max(0, [math]::Min(100, $Percent))
307
+ $full = [int][math]::Round(($clamped / 100.0) * $Width)
308
+ if ($clamped -gt 0 -and $full -eq 0) { $full = 1 }
309
+ return ($fullBlock * $full) + ($emptyBlock * ($Width - $full))
310
+ }
311
+
312
+ function Get-BatteryIcon([Nullable[int]]$Percent) {
313
+ return ""
314
+ }
315
+
316
+ function Get-PercentColor([Nullable[int]]$Percent) {
317
+ if ($null -eq $Percent) { return [System.Drawing.Color]::FromArgb(150, 150, 150) }
318
+ if ($Percent -le 20) { return [System.Drawing.Color]::FromArgb(255, 92, 92) }
319
+ if ($Percent -le 40) { return [System.Drawing.Color]::FromArgb(255, 158, 67) }
320
+ return [System.Drawing.Color]::FromArgb(80, 220, 120)
321
+ }
322
+
323
+ function Get-ActivityColor($Result) {
324
+ if ($Result -and $Result.running) { return [System.Drawing.Color]::FromArgb(235, 235, 235) }
325
+ return [System.Drawing.Color]::FromArgb(145, 145, 145)
326
+ }
327
+
328
+ function Get-DividerColor {
329
+ return [System.Drawing.Color]::FromArgb(132, 132, 132)
330
+ }
331
+
332
+ function Format-Parts($Result, [string]$Name) {
333
+ $isRunning = [bool]($Result -and $Result.running)
334
+ $textColor = Get-ActivityColor $Result
335
+ $displayName = $Name.PadRight(6)
336
+ $resetText = ""
337
+ $resetValue = ""
338
+ $weekText = ""
339
+ $weekValue = ""
340
+ $extraText = ""
341
+
342
+ if (-not $Result -or -not $Result.ok) {
343
+ return @{
344
+ Prefix = "$displayName "
345
+ Icon = Get-BatteryIcon $null
346
+ Percent = $null
347
+ PercentText = ""
348
+ ResetText = ""
349
+ ResetValue = ""
350
+ WeekText = ""
351
+ WeekValue = ""
352
+ ExtraText = "?"
353
+ Suffix = " ?"
354
+ Running = $isRunning
355
+ TextColor = $textColor
356
+ IconColor = Get-PercentColor $null
357
+ }
358
+ }
359
+
360
+ if ($null -eq $Result.percentRemaining) {
361
+ $resetValue = "5h --:--"
362
+ $weekValue = "7d ---%"
363
+ $divider = [string][char]0x2502
364
+ return @{
365
+ Prefix = "$displayName "
366
+ Icon = Get-BatteryIcon $null
367
+ Percent = $null
368
+ PercentText = "--"
369
+ ResetText = $resetValue
370
+ ResetValue = $resetValue
371
+ WeekText = $weekValue
372
+ WeekValue = $weekValue
373
+ ExtraText = ""
374
+ Suffix = " $divider $resetValue $divider $weekValue"
375
+ Running = $isRunning
376
+ TextColor = $textColor
377
+ IconColor = Get-PercentColor $null
378
+ }
379
+ }
380
+
381
+ $divider = [string][char]0x2502
382
+ if ($Result.primary) {
383
+ $resetWindow = Get-WindowText $Result.primary.windowMinutes
384
+ $resetValue = "$resetWindow $(Get-ResetClock $Result.primary)".PadRight(8)
385
+ $resetText = $resetValue
386
+ }
387
+ if ($Result.secondary) {
388
+ $weekWindow = Get-WindowText $Result.secondary.windowMinutes
389
+ # Space plus a thin space (U+2009): a single space reads slightly tighter
390
+ # here than the "5h <time>" gap because the digits hug their left edge,
391
+ # while a full double space looks padded. The label keeps a fixed width,
392
+ # so the HUD does not resize when the digit count changes.
393
+ $weekValue = "$weekWindow $([char]0x2009)$([int]$Result.secondary.remainingPercent)%"
394
+ $weekText = $weekValue
395
+ }
396
+ $percentText = "$($Result.percentRemaining)"
397
+ $suffix = ""
398
+ if ($resetText) {
399
+ $suffix += " $divider " + $resetText
400
+ }
401
+ if ($weekText) {
402
+ $suffix += " $divider " + $weekText
403
+ }
404
+ return @{
405
+ Prefix = "$displayName "
406
+ Icon = Get-BatteryIcon $Result.percentRemaining
407
+ Percent = $Result.percentRemaining
408
+ PercentText = $percentText
409
+ ResetText = $resetText
410
+ ResetValue = $resetValue
411
+ WeekText = $weekText
412
+ WeekValue = $weekValue
413
+ ExtraText = ""
414
+ Suffix = $suffix
415
+ Running = $isRunning
416
+ TextColor = $textColor
417
+ IconColor = Get-PercentColor $Result.percentRemaining
418
+ }
419
+ }
420
+
421
+ function Get-Provider($Snapshot, [string]$Name) {
422
+ return @($Snapshot.results | Where-Object { $_.provider -eq $Name })[0]
423
+ }
424
+
425
+ function Copy-MissingProperty($Target, $Source, [string]$Name) {
426
+ if (-not $Target -or -not $Source) { return }
427
+ if ($null -eq $Target.$Name -and $null -ne $Source.$Name) {
428
+ $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Source.$Name -Force
429
+ }
430
+ }
431
+
432
+ function Merge-HudSnapshot($Snapshot, $CachedSnapshot) {
433
+ if (-not $Snapshot -or -not $Snapshot.results) { return $CachedSnapshot }
434
+ $Snapshot.results = @($Snapshot.results)
435
+ $nowUtc = [datetime]::UtcNow
436
+
437
+ for ($i = 0; $i -lt $Snapshot.results.Count; $i += 1) {
438
+ $current = $Snapshot.results[$i]
439
+ if (-not $current -or -not $current.provider) { continue }
440
+
441
+ $usable = [bool]$current.ok -and $null -ne $current.percentRemaining
442
+ if ($usable) {
443
+ $current | Add-Member -NotePropertyName "hudCachedAt" -NotePropertyValue $nowUtc.ToString("o") -Force
444
+ }
445
+
446
+ $cached = $null
447
+ if ($CachedSnapshot) { $cached = Get-Provider $CachedSnapshot $current.provider }
448
+ if (-not $cached -or -not $cached.ok -or $null -eq $cached.percentRemaining) { continue }
449
+
450
+ if ($usable) {
451
+ Copy-MissingProperty $current $cached "secondary"
452
+ continue
453
+ }
454
+
455
+ # A transient read failure or a fallback row would flash "?"; keep the
456
+ # last good reading instead, as long as it is reasonably recent.
457
+ $cachedAt = $cached.hudCachedAt
458
+ if (-not $cachedAt) { $cachedAt = $CachedSnapshot.generatedAt }
459
+ $ageSeconds = $null
460
+ try {
461
+ $ageSeconds = ($nowUtc - ([datetime]$cachedAt).ToUniversalTime()).TotalSeconds
462
+ } catch {
463
+ $ageSeconds = $null
464
+ }
465
+ if ($null -eq $ageSeconds -or $ageSeconds -lt -60 -or $ageSeconds -gt 1800) { continue }
466
+
467
+ $replacement = $cached | Select-Object *
468
+ if ($null -ne $current.running) {
469
+ $replacement | Add-Member -NotePropertyName "running" -NotePropertyValue $current.running -Force
470
+ }
471
+ $Snapshot.results[$i] = $replacement
472
+ }
473
+
474
+ return $Snapshot
475
+ }
476
+
477
+ function ConvertTo-HudTexts($Snapshot) {
478
+ $codex = Get-Provider $Snapshot "codex"
479
+ $claude = Get-Provider $Snapshot "claude"
480
+ $codexVisible = $null -ne $codex
481
+ $claudeVisible = $null -ne $claude
482
+ return @{
483
+ Codex = $(if ($codexVisible) { Format-Parts $codex "Codex" } else { $null })
484
+ Claude = $(if ($claudeVisible) { Format-Parts $claude "Claude" } else { $null })
485
+ CodexResult = $codex
486
+ ClaudeResult = $claude
487
+ CodexVisible = $codexVisible
488
+ ClaudeVisible = $claudeVisible
489
+ }
490
+ }
491
+
492
+ function Get-Texts {
493
+ $cachedSnapshot = Read-HudSnapshot
494
+ if ($script:initialHudSnapshot) {
495
+ $snapshot = Merge-HudSnapshot $script:initialHudSnapshot $cachedSnapshot
496
+ $script:initialHudSnapshot = $null
497
+ } else {
498
+ try {
499
+ $snapshot = Merge-HudSnapshot (Invoke-AiBatteryJson) $cachedSnapshot
500
+ } catch {
501
+ if ($cachedSnapshot) {
502
+ $snapshot = $cachedSnapshot
503
+ } else {
504
+ throw
505
+ }
506
+ }
507
+ }
508
+ Write-HudSnapshot $snapshot
509
+ return ConvertTo-HudTexts $snapshot
510
+ }
511
+
512
+ $script:latestSnapshot = $null
513
+ $script:fetchPowerShell = $null
514
+ $script:fetchHandle = $null
515
+ $script:lastFetchStartUtc = [datetime]::MinValue
516
+ $script:weeklyRetryCount = 0
517
+
518
+ function Start-SnapshotFetch {
519
+ if ($script:fetchPowerShell) { return }
520
+ $ps = [powershell]::Create()
521
+ $null = $ps.AddScript({
522
+ param($Command, $UseWslShell)
523
+ try {
524
+ if ($UseWslShell) {
525
+ $output = & wsl.exe bash -lc "$Command 2>/dev/null"
526
+ } else {
527
+ $output = Invoke-Expression "$Command 2>`$null"
528
+ }
529
+ ($output -join "`n").Trim()
530
+ } catch {
531
+ ""
532
+ }
533
+ }).AddArgument($BatteryCommand).AddArgument([bool]$UseWsl)
534
+ $script:fetchHandle = $ps.BeginInvoke()
535
+ $script:fetchPowerShell = $ps
536
+ $script:lastFetchStartUtc = [datetime]::UtcNow
537
+ }
538
+
539
+ function Stop-SnapshotFetch {
540
+ if (-not $script:fetchPowerShell) { return }
541
+ try { $script:fetchPowerShell.Stop() } catch { }
542
+ try { $script:fetchPowerShell.Dispose() } catch { }
543
+ $script:fetchPowerShell = $null
544
+ $script:fetchHandle = $null
545
+ }
546
+
547
+ function Complete-SnapshotFetch {
548
+ if (-not $script:fetchPowerShell -or -not $script:fetchHandle.IsCompleted) { return $null }
549
+ $text = ""
550
+ try {
551
+ $text = (($script:fetchPowerShell.EndInvoke($script:fetchHandle)) -join "`n").Trim()
552
+ } catch {
553
+ $text = ""
554
+ }
555
+ try { $script:fetchPowerShell.Dispose() } catch { }
556
+ $script:fetchPowerShell = $null
557
+ $script:fetchHandle = $null
558
+ if (-not $text) { return $null }
559
+ try {
560
+ return $text | ConvertFrom-Json
561
+ } catch {
562
+ return $null
563
+ }
564
+ }
565
+
566
+ if ($Once) {
567
+ $texts = Get-Texts
568
+ if ($texts.CodexVisible) { Write-Output "$($texts.Codex.Prefix)[battery]$($texts.Codex.Suffix)" }
569
+ if ($texts.ClaudeVisible) { Write-Output "$($texts.Claude.Prefix)[battery]$($texts.Claude.Suffix)" }
570
+ exit 0
571
+ }
572
+
573
+ Add-Type -AssemblyName System.Windows.Forms
574
+ Add-Type -AssemblyName System.Drawing
575
+ [System.Windows.Forms.Application]::EnableVisualStyles()
576
+
577
+ function Get-LineText($Parts) {
578
+ return "$($Parts.Prefix)[battery]$($Parts.Suffix)"
579
+ }
580
+
581
+ function Limit-Text([string]$Text, [int]$MaxLength) {
582
+ if ($Text.Length -le $MaxLength) { return $Text }
583
+ return $Text.Substring(0, [math]::Max(0, $MaxLength - 3)) + "..."
584
+ }
585
+
586
+ function Select-TrayPercent($Texts) {
587
+ $items = @()
588
+ foreach ($result in @($Texts.CodexResult, $Texts.ClaudeResult)) {
589
+ if ($result -and $result.ok -and $null -ne $result.percentRemaining) {
590
+ $items += [PSCustomObject]@{
591
+ Percent = [int]$result.percentRemaining
592
+ Running = [bool]$result.running
593
+ }
594
+ }
595
+ }
596
+
597
+ $runningItems = @($items | Where-Object { $_.Running })
598
+ if ($runningItems.Count -gt 0) {
599
+ return [Nullable[int]](@($runningItems | Sort-Object Percent)[0].Percent)
600
+ }
601
+ if ($items.Count -gt 0) {
602
+ return [Nullable[int]](@($items | Sort-Object Percent)[0].Percent)
603
+ }
604
+ return $null
605
+ }
606
+
607
+ function New-ProviderTrayIcon([string]$ProviderLabel, [Nullable[int]]$Percent, [bool]$Running) {
608
+ $bitmap = [System.Drawing.Bitmap]::new(32, 32)
609
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
610
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
611
+ $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
612
+ $graphics.Clear([System.Drawing.Color]::Transparent)
613
+
614
+ $outlineColor = [System.Drawing.Color]::FromArgb(232, 232, 232)
615
+ $mutedColor = [System.Drawing.Color]::FromArgb(145, 145, 145)
616
+ $activeOutlineColor = if ($Running) { $outlineColor } else { $mutedColor }
617
+ $fillColor = if ($Running) { Get-PercentColor $Percent } else { $mutedColor }
618
+ $textColor = if ($Running) { [System.Drawing.Color]::FromArgb(245, 245, 245) } else { $mutedColor }
619
+ $outlinePen = [System.Drawing.Pen]::new($(if ($null -eq $Percent) { $mutedColor } else { $activeOutlineColor }), 1.6)
620
+ $fillBrush = [System.Drawing.SolidBrush]::new($fillColor)
621
+ $terminalBrush = [System.Drawing.SolidBrush]::new($(if ($null -eq $Percent) { $mutedColor } else { $activeOutlineColor }))
622
+ $textBrush = [System.Drawing.SolidBrush]::new($textColor)
623
+ $labelFont = [System.Drawing.Font]::new("Segoe UI", 7, [System.Drawing.FontStyle]::Bold)
624
+ $percentText = if ($null -eq $Percent) { "--" } else { [string][int]$Percent }
625
+ $percentFontSize = if ($percentText.Length -ge 3) { 8.2 } else { 9.2 }
626
+ $percentFont = [System.Drawing.Font]::new("Segoe UI", $percentFontSize, [System.Drawing.FontStyle]::Bold)
627
+ $centerFormat = [System.Drawing.StringFormat]::new()
628
+ $centerFormat.Alignment = [System.Drawing.StringAlignment]::Center
629
+ $centerFormat.LineAlignment = [System.Drawing.StringAlignment]::Center
630
+
631
+ try {
632
+ $graphics.DrawString($ProviderLabel, $labelFont, $textBrush, [System.Drawing.RectangleF]::new(0, -1, 32, 10), $centerFormat)
633
+ $graphics.DrawRectangle($outlinePen, 3, 11, 23, 16)
634
+ $graphics.FillRectangle($terminalBrush, 27, 16, 3, 6)
635
+
636
+ if ($null -eq $Percent) {
637
+ $dashPen = [System.Drawing.Pen]::new($mutedColor, 2)
638
+ try {
639
+ $graphics.DrawLine($dashPen, 8, 19, 21, 19)
640
+ } finally {
641
+ $dashPen.Dispose()
642
+ }
643
+ } else {
644
+ $clamped = [math]::Max(0, [math]::Min(100, $Percent))
645
+ $fillWidth = [math]::Floor(($clamped / 100.0) * 19)
646
+ if ($clamped -gt 0 -and $fillWidth -lt 2) { $fillWidth = 2 }
647
+ $graphics.FillRectangle($fillBrush, 5, 13, $fillWidth, 12)
648
+ }
649
+ $graphics.DrawString($percentText, $percentFont, $textBrush, [System.Drawing.RectangleF]::new(4, 11, 22, 16), $centerFormat)
650
+ } finally {
651
+ $outlinePen.Dispose()
652
+ $fillBrush.Dispose()
653
+ $terminalBrush.Dispose()
654
+ $textBrush.Dispose()
655
+ $labelFont.Dispose()
656
+ $percentFont.Dispose()
657
+ $centerFormat.Dispose()
658
+ $graphics.Dispose()
659
+ }
660
+
661
+ $handle = $bitmap.GetHicon()
662
+ $sourceIcon = [System.Drawing.Icon]::FromHandle($handle)
663
+ $icon = $sourceIcon.Clone()
664
+ $sourceIcon.Dispose()
665
+ [AiBatteryNative]::DestroyIcon($handle) | Out-Null
666
+ $bitmap.Dispose()
667
+ return $icon
668
+ }
669
+
670
+ function New-SingleBatteryTrayIcon([Nullable[int]]$Percent) {
671
+ $bitmap = [System.Drawing.Bitmap]::new(32, 32)
672
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
673
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
674
+ $graphics.Clear([System.Drawing.Color]::Transparent)
675
+
676
+ $outlineColor = [System.Drawing.Color]::FromArgb(232, 232, 232)
677
+ $mutedColor = [System.Drawing.Color]::FromArgb(145, 145, 145)
678
+ $fillColor = Get-PercentColor $Percent
679
+ $outlinePen = [System.Drawing.Pen]::new($(if ($null -eq $Percent) { $mutedColor } else { $outlineColor }), 2)
680
+ $fillBrush = [System.Drawing.SolidBrush]::new($fillColor)
681
+ $terminalBrush = [System.Drawing.SolidBrush]::new($(if ($null -eq $Percent) { $mutedColor } else { $outlineColor }))
682
+
683
+ try {
684
+ $graphics.DrawRectangle($outlinePen, 4, 9, 21, 14)
685
+ $graphics.FillRectangle($terminalBrush, 26, 13, 3, 6)
686
+
687
+ if ($null -eq $Percent) {
688
+ $dashPen = [System.Drawing.Pen]::new($mutedColor, 2)
689
+ try {
690
+ $graphics.DrawLine($dashPen, 8, 16, 21, 16)
691
+ } finally {
692
+ $dashPen.Dispose()
693
+ }
694
+ } else {
695
+ $clamped = [math]::Max(0, [math]::Min(100, $Percent))
696
+ $fillWidth = [math]::Floor(($clamped / 100.0) * 17)
697
+ if ($clamped -gt 0 -and $fillWidth -lt 2) { $fillWidth = 2 }
698
+ $graphics.FillRectangle($fillBrush, 7, 12, $fillWidth, 8)
699
+ }
700
+ } finally {
701
+ $outlinePen.Dispose()
702
+ $fillBrush.Dispose()
703
+ $terminalBrush.Dispose()
704
+ $graphics.Dispose()
705
+ }
706
+
707
+ $handle = $bitmap.GetHicon()
708
+ $sourceIcon = [System.Drawing.Icon]::FromHandle($handle)
709
+ $icon = $sourceIcon.Clone()
710
+ $sourceIcon.Dispose()
711
+ [AiBatteryNative]::DestroyIcon($handle) | Out-Null
712
+ $bitmap.Dispose()
713
+ return $icon
714
+ }
715
+
716
+ function New-BatteryImage([Nullable[int]]$Percent, [bool]$Running = $true, [int]$Width = 34, [int]$Height = 16) {
717
+ $bitmap = [System.Drawing.Bitmap]::new($Width, $Height)
718
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
719
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
720
+ $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
721
+ $graphics.Clear([System.Drawing.Color]::Transparent)
722
+
723
+ $outlineColor = [System.Drawing.Color]::FromArgb(232, 232, 232)
724
+ $mutedColor = [System.Drawing.Color]::FromArgb(145, 145, 145)
725
+ $activeOutlineColor = if ($Running) { $outlineColor } else { $mutedColor }
726
+ # The fill always keeps its charge color (matching the terminal bar);
727
+ # running state is signalled by the outline and text colors instead.
728
+ $fillColor = Get-PercentColor $Percent
729
+ $outlinePen = [System.Drawing.Pen]::new($(if ($null -eq $Percent) { $mutedColor } else { $activeOutlineColor }), 1.6)
730
+ $fillBrush = [System.Drawing.SolidBrush]::new($fillColor)
731
+ # A solid dark interior keeps the desktop from bleeding through the empty
732
+ # part of the battery, so the percent text stays readable on any wallpaper.
733
+ $interiorBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(46, 46, 46))
734
+ $terminalBrush = [System.Drawing.SolidBrush]::new($(if ($null -eq $Percent) { $mutedColor } else { $activeOutlineColor }))
735
+ $textBrush = [System.Drawing.SolidBrush]::new($(if ($Running) { [System.Drawing.Color]::FromArgb(250, 250, 250) } else { [System.Drawing.Color]::FromArgb(210, 210, 210) }))
736
+ $textHaloBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(200, 15, 15, 15))
737
+ $fontSize = if ($null -eq $Percent) { 7.8 } elseif ($Percent -ge 100) { 7.2 } else { 8.2 }
738
+ $batteryFont = [System.Drawing.Font]::new("Segoe UI", $fontSize, [System.Drawing.FontStyle]::Bold)
739
+ $centerFormat = [System.Drawing.StringFormat]::new()
740
+ $centerFormat.Alignment = [System.Drawing.StringAlignment]::Center
741
+ $centerFormat.LineAlignment = [System.Drawing.StringAlignment]::Center
742
+
743
+ try {
744
+ $bodyX = 1
745
+ $bodyY = 3
746
+ $bodyW = $Width - 6
747
+ $bodyH = $Height - 6
748
+ $capW = 3
749
+ $capH = [math]::Max(4, [math]::Floor($bodyH / 2))
750
+ $capY = $bodyY + [math]::Floor(($bodyH - $capH) / 2)
751
+
752
+ $graphics.FillRectangle($interiorBrush, $bodyX + 1, $bodyY + 1, $bodyW - 1, $bodyH - 1)
753
+ $graphics.DrawRectangle($outlinePen, $bodyX, $bodyY, $bodyW, $bodyH)
754
+ $graphics.FillRectangle($terminalBrush, $bodyX + $bodyW + 1, $capY, $capW, $capH)
755
+
756
+ if ($null -ne $Percent) {
757
+ # The "--" text alone marks the unknown state; a dash line under it
758
+ # just collides with the halo.
759
+ $clamped = [math]::Max(0, [math]::Min(100, $Percent))
760
+ $innerW = $bodyW - 5
761
+ $fillW = [math]::Floor(($clamped / 100.0) * $innerW)
762
+ if ($clamped -gt 0 -and $fillW -lt 2) { $fillW = 2 }
763
+ $graphics.FillRectangle($fillBrush, $bodyX + 3, $bodyY + 3, $fillW, $bodyH - 5)
764
+ }
765
+ $batteryText = if ($null -eq $Percent) { "--" } else { [string][int]$Percent }
766
+ $textRect = [System.Drawing.RectangleF]::new($bodyX + 2, $bodyY, $bodyW - 3, $bodyH + 1)
767
+ # A one-pixel dark halo keeps the number legible over the green/orange/red
768
+ # fill as well as over the dark empty region.
769
+ foreach ($shift in @(@(-1, 0), @(1, 0), @(0, -1), @(0, 1))) {
770
+ $haloRect = [System.Drawing.RectangleF]::new($textRect.X + $shift[0], $textRect.Y + $shift[1], $textRect.Width, $textRect.Height)
771
+ $graphics.DrawString($batteryText, $batteryFont, $textHaloBrush, $haloRect, $centerFormat)
772
+ }
773
+ $graphics.DrawString($batteryText, $batteryFont, $textBrush, $textRect, $centerFormat)
774
+ } finally {
775
+ $outlinePen.Dispose()
776
+ $fillBrush.Dispose()
777
+ $interiorBrush.Dispose()
778
+ $terminalBrush.Dispose()
779
+ $textBrush.Dispose()
780
+ $textHaloBrush.Dispose()
781
+ $batteryFont.Dispose()
782
+ $centerFormat.Dispose()
783
+ $graphics.Dispose()
784
+ }
785
+
786
+ return $bitmap
787
+ }
788
+
789
+ function New-StatusMenuLabel($Font, [int]$Width, $Align = [System.Drawing.ContentAlignment]::MiddleLeft) {
790
+ $label = New-Object System.Windows.Forms.Label
791
+ $label.AutoSize = $false
792
+ $label.Width = $Width
793
+ $label.Height = 20
794
+ $label.Font = $Font
795
+ $label.TextAlign = $Align
796
+ $label.Margin = [System.Windows.Forms.Padding]::new(0)
797
+ $label.BackColor = [System.Drawing.SystemColors]::Menu
798
+ return $label
799
+ }
800
+
801
+ function New-StatusMenuRow($Font) {
802
+ $panel = New-Object System.Windows.Forms.FlowLayoutPanel
803
+ $panel.Width = 252
804
+ $panel.Height = 22
805
+ $panel.FlowDirection = [System.Windows.Forms.FlowDirection]::LeftToRight
806
+ $panel.WrapContents = $false
807
+ $panel.Margin = [System.Windows.Forms.Padding]::new(0)
808
+ $panel.Padding = [System.Windows.Forms.Padding]::new(4, 1, 4, 1)
809
+ $panel.BackColor = [System.Drawing.SystemColors]::Menu
810
+
811
+ $name = New-StatusMenuLabel $Font 44
812
+ $icon = New-Object System.Windows.Forms.PictureBox
813
+ $icon.Width = 36
814
+ $icon.Height = 20
815
+ $icon.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
816
+ $icon.Margin = [System.Windows.Forms.Padding]::new(0)
817
+ $icon.BackColor = [System.Drawing.SystemColors]::Menu
818
+ $percent = New-StatusMenuLabel $Font 0 ([System.Drawing.ContentAlignment]::MiddleRight)
819
+ $reset = New-StatusMenuLabel $Font 58
820
+ $week = New-StatusMenuLabel $Font 50
821
+ $extra = New-StatusMenuLabel $Font 58
822
+
823
+ foreach ($control in @($name, $icon, $percent, $reset, $week, $extra)) {
824
+ $panel.Controls.Add($control) | Out-Null
825
+ }
826
+
827
+ $rowHost = New-Object System.Windows.Forms.ToolStripControlHost($panel)
828
+ $rowHost.AutoSize = $false
829
+ $rowHost.Width = $panel.Width
830
+ $rowHost.Height = $panel.Height
831
+
832
+ return @{
833
+ Host = $rowHost
834
+ Panel = $panel
835
+ Name = $name
836
+ Icon = $icon
837
+ Percent = $percent
838
+ Reset = $reset
839
+ Week = $week
840
+ Extra = $extra
841
+ }
842
+ }
843
+
844
+ function Set-StatusMenuRow($Row, $Parts) {
845
+ $textColor = $Parts.TextColor
846
+ $Row.Name.Text = $Parts.Prefix.TrimEnd()
847
+ $Row.Percent.Text = $Parts.PercentText
848
+ $Row.Reset.Text = $Parts.ResetText
849
+ $Row.Week.Text = $Parts.WeekText
850
+ $Row.Extra.Text = $Parts.ExtraText
851
+
852
+ foreach ($label in @($Row.Name, $Row.Percent, $Row.Reset, $Row.Week, $Row.Extra)) {
853
+ $label.ForeColor = $textColor
854
+ }
855
+
856
+ $oldImage = $Row.Icon.Image
857
+ $Row.Icon.Image = New-BatteryImage $Parts.Percent $Parts.Running
858
+ if ($oldImage) { $oldImage.Dispose() }
859
+ }
860
+
861
+ function Dispose-StatusMenuRow($Row) {
862
+ if ($Row.Icon.Image) {
863
+ $Row.Icon.Image.Dispose()
864
+ $Row.Icon.Image = $null
865
+ }
866
+ }
867
+
868
+ if ($Mode -eq "tray") {
869
+ $context = [System.Windows.Forms.ApplicationContext]::new()
870
+ $trayFont = [System.Drawing.Font]::new("Segoe UI", 9, [System.Drawing.FontStyle]::Regular)
871
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
872
+ $menu.Font = $trayFont
873
+ $codexRow = New-StatusMenuRow $trayFont
874
+ $claudeRow = New-StatusMenuRow $trayFont
875
+ $menu.Items.Add($codexRow.Host) | Out-Null
876
+ $menu.Items.Add($claudeRow.Host) | Out-Null
877
+ $menu.Items.Add("-") | Out-Null
878
+ $refreshItem = $menu.Items.Add("Refresh")
879
+ $exitItem = $menu.Items.Add("Exit")
880
+
881
+ $notifyIcon = New-Object System.Windows.Forms.NotifyIcon
882
+ $notifyIcon.ContextMenuStrip = $menu
883
+ $notifyIcon.Visible = $true
884
+ $notifyIcon.Text = "AI Battery"
885
+ $script:currentTrayIcon = $null
886
+ $script:lastBalloonText = "AI Battery"
887
+
888
+ function Set-SingleNotifyIcon($Texts) {
889
+ $trayPercent = Select-TrayPercent $Texts
890
+ $newIcon = New-SingleBatteryTrayIcon $trayPercent
891
+ $oldIcon = $script:currentTrayIcon
892
+ $notifyIcon.Icon = $newIcon
893
+ $script:currentTrayIcon = $newIcon
894
+ if ($oldIcon) { $oldIcon.Dispose() }
895
+
896
+ $shortParts = @()
897
+ if ($Texts.CodexVisible) {
898
+ $shortParts += $(if ($null -ne $Texts.CodexResult.percentRemaining) { "Codex $($Texts.CodexResult.percentRemaining)%" } else { "Codex --%" })
899
+ }
900
+ if ($Texts.ClaudeVisible) {
901
+ $shortParts += $(if ($null -ne $Texts.ClaudeResult.percentRemaining) { "Claude $($Texts.ClaudeResult.percentRemaining)%" } else { "Claude --%" })
902
+ }
903
+ $notifyIcon.Text = Limit-Text "AI Battery: $($shortParts -join ', ')" 63
904
+ }
905
+
906
+ function Update-Tray {
907
+ try {
908
+ $texts = Get-Texts
909
+ $codexRow.Host.Visible = $texts.CodexVisible
910
+ $claudeRow.Host.Visible = $texts.ClaudeVisible
911
+ $codexLine = if ($texts.CodexVisible) { Get-LineText $texts.Codex } else { "" }
912
+ $claudeLine = if ($texts.ClaudeVisible) { Get-LineText $texts.Claude } else { "" }
913
+ if ($texts.CodexVisible) { Set-StatusMenuRow $codexRow $texts.Codex }
914
+ if ($texts.ClaudeVisible) { Set-StatusMenuRow $claudeRow $texts.Claude }
915
+
916
+ Set-SingleNotifyIcon $texts
917
+ $script:lastBalloonText = (@($codexLine, $claudeLine) | Where-Object { $_ }) -join "`n"
918
+ } catch {
919
+ Set-StatusMenuRow $codexRow @{
920
+ Prefix = "Codex "
921
+ Percent = $null
922
+ PercentText = "?"
923
+ ResetText = ""
924
+ WeekText = ""
925
+ ExtraText = "unavailable"
926
+ TextColor = [System.Drawing.Color]::FromArgb(255, 92, 92)
927
+ }
928
+ Set-StatusMenuRow $claudeRow @{
929
+ Prefix = "Claude "
930
+ Percent = $null
931
+ PercentText = "?"
932
+ ResetText = ""
933
+ WeekText = ""
934
+ ExtraText = ""
935
+ TextColor = [System.Drawing.Color]::FromArgb(145, 145, 145)
936
+ }
937
+ $newIcon = New-SingleBatteryTrayIcon $null
938
+ $oldIcon = $script:currentTrayIcon
939
+ $notifyIcon.Icon = $newIcon
940
+ $script:currentTrayIcon = $newIcon
941
+ if ($oldIcon) { $oldIcon.Dispose() }
942
+ $notifyIcon.Text = "AI Battery unavailable"
943
+ $script:lastBalloonText = "AI Battery unavailable"
944
+ }
945
+ }
946
+
947
+ $refreshItem.add_Click({ Update-Tray })
948
+ $exitItem.add_Click({
949
+ $notifyIcon.Visible = $false
950
+ Dispose-StatusMenuRow $codexRow
951
+ Dispose-StatusMenuRow $claudeRow
952
+ if ($notifyIcon.Icon) { $notifyIcon.Icon.Dispose() }
953
+ $notifyIcon.Dispose()
954
+ $trayFont.Dispose()
955
+ Release-SingleInstance
956
+ $context.ExitThread()
957
+ })
958
+ $openMenu = {
959
+ param($sender, $event)
960
+ if ($event.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
961
+ Update-Tray
962
+ $menu.Show([System.Windows.Forms.Cursor]::Position)
963
+ }
964
+ }
965
+ $notifyIcon.add_MouseClick($openMenu)
966
+
967
+ $timer = New-Object System.Windows.Forms.Timer
968
+ $timer.Interval = [math]::Max(1, $Interval) * 1000
969
+ $timer.add_Tick({ Update-Tray })
970
+ Update-Tray
971
+ $timer.Start()
972
+ [System.Windows.Forms.Application]::Run($context)
973
+ Release-SingleInstance
974
+ exit 0
975
+ }
976
+
977
+ $form = New-Object System.Windows.Forms.Form
978
+ $form.Text = "AI Battery"
979
+ $form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
980
+ $form.Width = $Width
981
+ $form.Height = if ($Mode -eq "floating") { 44 } elseif ($Mode -eq "statusline") { 44 } else { 54 }
982
+ $script:hudTwoRowHeight = $form.Height
983
+ $script:hudOneRowHeight = [math]::Max(20, $form.Height - 18)
984
+ $form.TopMost = $true
985
+ $form.ShowInTaskbar = $false
986
+ $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
987
+ $transparentBackColor = [System.Drawing.Color]::FromArgb(18, 18, 18)
988
+ $form.BackColor = $transparentBackColor
989
+ $form.TransparencyKey = $transparentBackColor
990
+ $form.Opacity = [math]::Max(0.2, [math]::Min(1.0, $Opacity))
991
+
992
+ $font = [System.Drawing.Font]::new("Segoe UI", $(if ($Mode -eq "statusline") { 8.5 } else { 9 }), [System.Drawing.FontStyle]::Regular)
993
+ $symbolFont = [System.Drawing.Font]::new("Cascadia Mono", $(if ($Mode -eq "statusline") { 11.5 } else { 12 }), [System.Drawing.FontStyle]::Regular)
994
+
995
+ $panel = New-Object System.Windows.Forms.TableLayoutPanel
996
+ $panel.Dock = [System.Windows.Forms.DockStyle]::Fill
997
+ $panel.RowCount = 2
998
+ $panel.ColumnCount = 1
999
+ $panel.GrowStyle = [System.Windows.Forms.TableLayoutPanelGrowStyle]::FixedSize
1000
+ $panel.Padding = if ($Mode -eq "floating" -or $Mode -eq "statusline") {
1001
+ [System.Windows.Forms.Padding]::new(6, 4, 2, 3)
1002
+ } else {
1003
+ [System.Windows.Forms.Padding]::new(10, 7, 10, 6)
1004
+ }
1005
+ $panel.BackColor = $form.BackColor
1006
+ $panel.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 50)) | Out-Null
1007
+ $panel.RowStyles.Add([System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 50)) | Out-Null
1008
+ $panel.ColumnStyles.Add([System.Windows.Forms.ColumnStyle]::new([System.Windows.Forms.SizeType]::Percent, 100)) | Out-Null
1009
+
1010
+ function New-HudRow {
1011
+ $row = New-Object System.Windows.Forms.FlowLayoutPanel
1012
+ $row.Dock = [System.Windows.Forms.DockStyle]::Fill
1013
+ $row.FlowDirection = [System.Windows.Forms.FlowDirection]::LeftToRight
1014
+ $row.WrapContents = $false
1015
+ $row.Margin = [System.Windows.Forms.Padding]::new(0)
1016
+ $row.Padding = [System.Windows.Forms.Padding]::new(0)
1017
+ $row.BackColor = $form.BackColor
1018
+ return $row
1019
+ }
1020
+
1021
+ function New-HudLabel([int]$RightMargin = 0) {
1022
+ $label = New-Object System.Windows.Forms.Label
1023
+ $label.AutoSize = $false
1024
+ $label.Width = 0
1025
+ $label.Height = 18
1026
+ $label.Font = $font
1027
+ $label.UseCompatibleTextRendering = $false
1028
+ $label.BackColor = $form.BackColor
1029
+ $label.Margin = [System.Windows.Forms.Padding]::new(0, 0, $RightMargin, 0)
1030
+ $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
1031
+ return $label
1032
+ }
1033
+
1034
+ function New-HudIconBox {
1035
+ $box = New-Object System.Windows.Forms.PictureBox
1036
+ $box.Width = 36
1037
+ $box.Height = 18
1038
+ $box.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
1039
+ $box.BackColor = $form.BackColor
1040
+ $box.Margin = [System.Windows.Forms.Padding]::new(0, 0, 2, 0)
1041
+ return $box
1042
+ }
1043
+
1044
+ function New-HudLineIconBox {
1045
+ $box = New-Object System.Windows.Forms.PictureBox
1046
+ $box.Width = 21
1047
+ $box.Height = 18
1048
+ $box.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
1049
+ $box.BackColor = $form.BackColor
1050
+ $box.Margin = [System.Windows.Forms.Padding]::new(3, 0, 0, 0)
1051
+ return $box
1052
+ }
1053
+
1054
+ function New-HudDivider {
1055
+ $divider = New-Object System.Windows.Forms.Panel
1056
+ $divider.AutoSize = $false
1057
+ $divider.Width = 1
1058
+ $divider.Height = 12
1059
+ $divider.BackColor = Get-DividerColor
1060
+ $divider.Margin = [System.Windows.Forms.Padding]::new(4, 3, 4, 3)
1061
+ return $divider
1062
+ }
1063
+
1064
+ $codexRow = New-HudRow
1065
+ $claudeRow = New-HudRow
1066
+ $codexPrefixLabel = New-HudLabel
1067
+ $codexIconLabel = New-HudIconBox
1068
+ $codexDivider1 = New-HudDivider
1069
+ $codexResetLabel = New-HudLabel
1070
+ $codexDivider2 = New-HudDivider
1071
+ $codexWeekLabel = New-HudLabel
1072
+ $codexExtraLabel = New-HudLabel
1073
+ $claudePrefixLabel = New-HudLabel
1074
+ $claudeIconLabel = New-HudIconBox
1075
+ $claudeDivider1 = New-HudDivider
1076
+ $claudeResetLabel = New-HudLabel
1077
+ $claudeDivider2 = New-HudDivider
1078
+ $claudeWeekLabel = New-HudLabel
1079
+ $claudeExtraLabel = New-HudLabel
1080
+
1081
+ foreach ($label in @($codexPrefixLabel, $claudePrefixLabel)) {
1082
+ $label.AutoSize = $false
1083
+ $label.Width = 48
1084
+ }
1085
+ foreach ($label in @($codexIconLabel, $claudeIconLabel)) {
1086
+ $label.Width = 38
1087
+ }
1088
+
1089
+ $codexHudControls = @($codexPrefixLabel, $codexIconLabel, $codexDivider1, $codexResetLabel, $codexDivider2, $codexWeekLabel, $codexExtraLabel)
1090
+ $claudeHudControls = @($claudePrefixLabel, $claudeIconLabel, $claudeDivider1, $claudeResetLabel, $claudeDivider2, $claudeWeekLabel, $claudeExtraLabel)
1091
+
1092
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
1093
+ $menu.Font = $font
1094
+ $exitItem = $menu.Items.Add("Exit")
1095
+ $exitItem.add_Click({ $form.Close() })
1096
+ $form.ContextMenuStrip = $menu
1097
+ $panel.ContextMenuStrip = $menu
1098
+ foreach ($label in $codexHudControls) {
1099
+ $label.ContextMenuStrip = $menu
1100
+ $codexRow.Controls.Add($label) | Out-Null
1101
+ }
1102
+ foreach ($label in $claudeHudControls) {
1103
+ $label.ContextMenuStrip = $menu
1104
+ $claudeRow.Controls.Add($label) | Out-Null
1105
+ }
1106
+ $codexRow.ContextMenuStrip = $menu
1107
+ $claudeRow.ContextMenuStrip = $menu
1108
+ $panel.Controls.Add($codexRow, 0, 0) | Out-Null
1109
+ $panel.Controls.Add($claudeRow, 0, 1) | Out-Null
1110
+ $form.Controls.Add($panel)
1111
+
1112
+ $hitForm = $null
1113
+ if (-not $ClickThrough) {
1114
+ $hitForm = New-Object System.Windows.Forms.Form
1115
+ $hitForm.Text = "AI Battery hit area"
1116
+ $hitForm.Width = $form.Width
1117
+ $hitForm.Height = $form.Height
1118
+ $hitForm.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
1119
+ $hitForm.ShowInTaskbar = $false
1120
+ $hitForm.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
1121
+ $hitForm.BackColor = [System.Drawing.Color]::Black
1122
+ $hitForm.Opacity = 0.01
1123
+ $hitForm.TopMost = $true
1124
+ $hitForm.ContextMenuStrip = $menu
1125
+ }
1126
+
1127
+ function Sync-HitFormBounds {
1128
+ if (-not $hitForm -or $hitForm.IsDisposed) { return }
1129
+ $hitForm.Bounds = [System.Drawing.Rectangle]::new($form.Left, $form.Top, $form.Width, $form.Height)
1130
+ }
1131
+
1132
+ function Show-HitForm {
1133
+ if (-not $hitForm -or $hitForm.IsDisposed) { return }
1134
+ Sync-HitFormBounds
1135
+ if (-not $hitForm.Visible) {
1136
+ $hitForm.Show()
1137
+ }
1138
+ $style = [AiBatteryNative]::GetWindowLong($hitForm.Handle, [AiBatteryNative]::GWL_EXSTYLE)
1139
+ $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW
1140
+ [AiBatteryNative]::SetWindowLong($hitForm.Handle, [AiBatteryNative]::GWL_EXSTYLE, $style) | Out-Null
1141
+ }
1142
+
1143
+ $dragging = $false
1144
+ $dragOffset = [System.Drawing.Point]::new(0, 0)
1145
+ $mouseDown = {
1146
+ param($sender, $event)
1147
+ if ($event.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
1148
+ $script:dragging = $true
1149
+ $screenPoint = $sender.PointToScreen([System.Drawing.Point]::new($event.X, $event.Y))
1150
+ $script:dragOffset = [System.Drawing.Point]::new(
1151
+ ($screenPoint.X - $form.Location.X),
1152
+ ($screenPoint.Y - $form.Location.Y)
1153
+ )
1154
+ }
1155
+ }
1156
+ $mouseMove = {
1157
+ param($sender, $event)
1158
+ if ($script:dragging) {
1159
+ $screenPoint = $sender.PointToScreen([System.Drawing.Point]::new($event.X, $event.Y))
1160
+ $form.Location = [System.Drawing.Point]::new(($screenPoint.X - $script:dragOffset.X), ($screenPoint.Y - $script:dragOffset.Y))
1161
+ Sync-HitFormBounds
1162
+ }
1163
+ }
1164
+ $mouseUp = {
1165
+ $script:dragging = $false
1166
+ if ($canMove) {
1167
+ Set-HudAnchorFromPlacement $form.Location ([Nullable[int]]$form.Width) ([Nullable[int]]$form.Height)
1168
+ Write-HudPosition $form.Location
1169
+ Ensure-HudTopMost
1170
+ }
1171
+ }
1172
+ $canMove = $Movable -or (($Mode -eq "floating") -and (-not $Locked))
1173
+ $script:positionSaveReady = $false
1174
+ $script:hudHiddenForFullscreen = $false
1175
+
1176
+ function Save-HudPositionIfReady {
1177
+ if (-not $canMove -or -not $script:positionSaveReady) { return }
1178
+ Set-HudAnchorFromPlacement $form.Location ([Nullable[int]]$form.Width) ([Nullable[int]]$form.Height)
1179
+ Write-HudPosition $form.Location
1180
+ }
1181
+
1182
+ if ($canMove) {
1183
+ $dragControls = @($form, $panel, $codexRow, $claudeRow) + $codexHudControls + $claudeHudControls
1184
+ if ($hitForm) { $dragControls += $hitForm }
1185
+ foreach ($control in $dragControls) {
1186
+ $control.add_MouseDown($mouseDown)
1187
+ $control.add_MouseMove($mouseMove)
1188
+ $control.add_MouseUp($mouseUp)
1189
+ }
1190
+ }
1191
+
1192
+ function Limit-Number([int]$Value, [int]$Min, [int]$Max) {
1193
+ if ($Max -lt $Min) { return $Min }
1194
+ return [math]::Max($Min, [math]::Min($Max, $Value))
1195
+ }
1196
+
1197
+ function Set-HudAnchorFromPlacement($Location, [Nullable[int]]$Width, [Nullable[int]]$Height) {
1198
+ $placementWidth = if ($null -ne $Width -and $Width -gt 0) { [int]$Width } else { [int]$form.Width }
1199
+ $placementHeight = if ($null -ne $Height -and $Height -gt 0) { [int]$Height } else { [int]$form.Height }
1200
+ $center = [System.Drawing.Point]::new(
1201
+ [int]($Location.X + [math]::Floor($placementWidth / 2)),
1202
+ [int]($Location.Y + [math]::Floor($placementHeight / 2))
1203
+ )
1204
+ $bounds = [System.Windows.Forms.Screen]::FromPoint($center).Bounds
1205
+ $leftDistance = [math]::Abs($Location.X - $bounds.Left)
1206
+ $rightDistance = [math]::Abs($bounds.Right - ($Location.X + $placementWidth))
1207
+ $topDistance = [math]::Abs($Location.Y - $bounds.Top)
1208
+ $bottomDistance = [math]::Abs($bounds.Bottom - ($Location.Y + $placementHeight))
1209
+
1210
+ $script:hudAnchorX = if ($rightDistance -lt $leftDistance) { "right" } else { "left" }
1211
+ $script:hudAnchorY = if ($bottomDistance -lt $topDistance) { "bottom" } else { "top" }
1212
+ }
1213
+
1214
+ function Set-HudAnchorFromPositionKeyword {
1215
+ $script:hudAnchorX = if ($Position -like "*left*") { "left" } else { "right" }
1216
+ $script:hudAnchorY = if ($Position -like "top*") { "top" } else { "bottom" }
1217
+ }
1218
+
1219
+ function Get-WindowRectObject([IntPtr]$Handle) {
1220
+ if ($Handle -eq [IntPtr]::Zero) { return $null }
1221
+ $rect = New-Object AiBatteryRect
1222
+ if (-not [AiBatteryNative]::GetWindowRect($Handle, [ref]$rect)) { return $null }
1223
+ return [PSCustomObject]@{
1224
+ Left = [int]$rect.Left
1225
+ Top = [int]$rect.Top
1226
+ Right = [int]$rect.Right
1227
+ Bottom = [int]$rect.Bottom
1228
+ Width = [int]($rect.Right - $rect.Left)
1229
+ Height = [int]($rect.Bottom - $rect.Top)
1230
+ }
1231
+ }
1232
+
1233
+ function Get-MonitorRectObject([IntPtr]$Monitor) {
1234
+ if ($Monitor -eq [IntPtr]::Zero) { return $null }
1235
+ $info = New-Object AiBatteryMonitorInfo
1236
+ $info.CbSize = [System.Runtime.InteropServices.Marshal]::SizeOf($info)
1237
+ if (-not [AiBatteryNative]::GetMonitorInfo($Monitor, [ref]$info)) { return $null }
1238
+ return [PSCustomObject]@{
1239
+ Left = [int]$info.Monitor.Left
1240
+ Top = [int]$info.Monitor.Top
1241
+ Right = [int]$info.Monitor.Right
1242
+ Bottom = [int]$info.Monitor.Bottom
1243
+ Width = [int]($info.Monitor.Right - $info.Monitor.Left)
1244
+ Height = [int]($info.Monitor.Bottom - $info.Monitor.Top)
1245
+ }
1246
+ }
1247
+
1248
+ function Test-RectCoversMonitor($WindowRect, $MonitorRect) {
1249
+ if (-not $WindowRect -or -not $MonitorRect) { return $false }
1250
+ $tolerance = 2
1251
+ return (
1252
+ $WindowRect.Left -le ($MonitorRect.Left + $tolerance) -and
1253
+ $WindowRect.Top -le ($MonitorRect.Top + $tolerance) -and
1254
+ $WindowRect.Right -ge ($MonitorRect.Right - $tolerance) -and
1255
+ $WindowRect.Bottom -ge ($MonitorRect.Bottom - $tolerance)
1256
+ )
1257
+ }
1258
+
1259
+ function Test-ForegroundFullscreen {
1260
+ if (-not $form -or $form.IsDisposed) { return $false }
1261
+ $foreground = [AiBatteryNative]::GetForegroundWindow()
1262
+ if ($foreground -eq [IntPtr]::Zero) { return $false }
1263
+ if ($foreground -eq $form.Handle) { return $false }
1264
+ if ($hitForm -and -not $hitForm.IsDisposed -and $foreground -eq $hitForm.Handle) { return $false }
1265
+
1266
+ $hudMonitor = [AiBatteryNative]::MonitorFromWindow($form.Handle, [AiBatteryNative]::MONITOR_DEFAULTTONEAREST)
1267
+ $foregroundMonitor = [AiBatteryNative]::MonitorFromWindow($foreground, [AiBatteryNative]::MONITOR_DEFAULTTONEAREST)
1268
+ if ($hudMonitor -eq [IntPtr]::Zero -or $foregroundMonitor -eq [IntPtr]::Zero) { return $false }
1269
+ if ($hudMonitor -ne $foregroundMonitor) { return $false }
1270
+
1271
+ $windowRect = Get-WindowRectObject $foreground
1272
+ $monitorRect = Get-MonitorRectObject $foregroundMonitor
1273
+ return Test-RectCoversMonitor $windowRect $monitorRect
1274
+ }
1275
+
1276
+ function Set-TaskbarPosition {
1277
+ $taskbarHandle = [AiBatteryNative]::FindWindow("Shell_TrayWnd", $null)
1278
+ if ($taskbarHandle -eq [IntPtr]::Zero) { return $false }
1279
+
1280
+ $taskbarRect = Get-WindowRectObject $taskbarHandle
1281
+ if (-not $taskbarRect) { return $false }
1282
+
1283
+ $trayHandle = [AiBatteryNative]::FindWindowEx($taskbarHandle, [IntPtr]::Zero, "TrayNotifyWnd", $null)
1284
+ $trayRect = Get-WindowRectObject $trayHandle
1285
+ if (-not $trayRect) {
1286
+ $trayRect = [PSCustomObject]@{
1287
+ Left = $taskbarRect.Right
1288
+ Top = $taskbarRect.Bottom
1289
+ Right = $taskbarRect.Right
1290
+ Bottom = $taskbarRect.Bottom
1291
+ Width = 0
1292
+ Height = 0
1293
+ }
1294
+ }
1295
+
1296
+ $screen = [System.Windows.Forms.Screen]::FromHandle($taskbarHandle)
1297
+ $bounds = $screen.Bounds
1298
+ $margin = 6
1299
+ $isHorizontal = $taskbarRect.Width -ge $taskbarRect.Height
1300
+
1301
+ if ($isHorizontal) {
1302
+ $x = $trayRect.Left - $form.Width - $margin
1303
+ $y = $taskbarRect.Top + [math]::Floor(($taskbarRect.Height - $form.Height) / 2)
1304
+ } else {
1305
+ $isLeft = $taskbarRect.Left -le $bounds.Left
1306
+ $x = if ($isLeft) { $taskbarRect.Right + $margin } else { $taskbarRect.Left - $form.Width - $margin }
1307
+ $y = $trayRect.Top - $form.Height - $margin
1308
+ }
1309
+
1310
+ $x = Limit-Number $x ($bounds.Left + $margin) ($bounds.Right - $form.Width - $margin)
1311
+ $y = Limit-Number $y ($bounds.Top + $margin) ($bounds.Bottom - $form.Height - $margin)
1312
+ $form.Location = [System.Drawing.Point]::new([int]$x, [int]$y)
1313
+ Set-HudAnchorFromPlacement $form.Location ([Nullable[int]]$form.Width) ([Nullable[int]]$form.Height)
1314
+ return $true
1315
+ }
1316
+
1317
+ function Get-OnScreenPoint([int]$X, [int]$Y, [int]$W, [int]$H) {
1318
+ $rect = [System.Drawing.Rectangle]::new($X, $Y, [math]::Max(1, $W), [math]::Max(1, $H))
1319
+ foreach ($screen in [System.Windows.Forms.Screen]::AllScreens) {
1320
+ # Compare against the full monitor bounds, not the working area: sitting
1321
+ # on top of the taskbar is this HUD's primary use case, and the taskbar
1322
+ # band is outside every working area.
1323
+ $overlap = [System.Drawing.Rectangle]::Intersect($screen.Bounds, $rect)
1324
+ if ($overlap.Width -ge 24 -and $overlap.Height -ge 10) {
1325
+ return [System.Drawing.Point]::new($X, $Y)
1326
+ }
1327
+ }
1328
+
1329
+ # The saved spot is no longer on any screen (monitor unplugged or layout
1330
+ # changed), so pull the HUD back into the primary working area.
1331
+ $area = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
1332
+ $margin = 18
1333
+ $newX = Limit-Number $X ($area.Left + $margin) ($area.Right - $W - $margin)
1334
+ $newY = Limit-Number $Y ($area.Top + $margin) ($area.Bottom - $H - $margin)
1335
+ return [System.Drawing.Point]::new([int]$newX, [int]$newY)
1336
+ }
1337
+
1338
+ function Set-Position {
1339
+ if ($Position -eq "saved") {
1340
+ $savedPlacement = Read-HudPlacement
1341
+ if ($savedPlacement) {
1342
+ if ($null -ne $savedPlacement.Width -and $savedPlacement.Width -gt 0) {
1343
+ $form.Width = [int]$savedPlacement.Width
1344
+ }
1345
+ if ($null -ne $savedPlacement.Height -and $savedPlacement.Height -gt 0) {
1346
+ $form.Height = [int]$savedPlacement.Height
1347
+ }
1348
+ $form.Location = Get-OnScreenPoint ([int]$savedPlacement.X) ([int]$savedPlacement.Y) ([int]$form.Width) ([int]$form.Height)
1349
+ Set-HudAnchorFromPlacement $form.Location ([Nullable[int]]$form.Width) ([Nullable[int]]$form.Height)
1350
+ return
1351
+ }
1352
+ }
1353
+
1354
+ if ($Position -eq "taskbar") {
1355
+ if (Set-TaskbarPosition) { return }
1356
+ }
1357
+
1358
+ $area = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
1359
+ $margin = if ($Mode -eq "statusline") { 8 } else { 18 }
1360
+ $x = if ($Position -like "*left*") {
1361
+ $area.Left + $margin
1362
+ } elseif ($Position -like "*center*") {
1363
+ $area.Left + [math]::Floor(($area.Width - $form.Width) / 2)
1364
+ } else {
1365
+ $area.Right - $form.Width - $margin
1366
+ }
1367
+ $y = if ($Position -like "top*") { $area.Top + $margin } else { $area.Bottom - $form.Height - $margin }
1368
+ $form.Location = [System.Drawing.Point]::new($x, $y)
1369
+ Set-HudAnchorFromPositionKeyword
1370
+ }
1371
+
1372
+ function Ensure-HudTopMost {
1373
+ if (-not $form -or $form.IsDisposed) { return }
1374
+ if ($script:hudHiddenForFullscreen) { return }
1375
+ if ($hitForm -and -not $hitForm.IsDisposed -and $hitForm.Visible) {
1376
+ $hitForm.TopMost = $true
1377
+ [AiBatteryNative]::SetWindowPos(
1378
+ $hitForm.Handle,
1379
+ [AiBatteryNative]::HWND_TOPMOST,
1380
+ 0,
1381
+ 0,
1382
+ 0,
1383
+ 0,
1384
+ [AiBatteryNative]::SWP_NOMOVE -bor [AiBatteryNative]::SWP_NOSIZE -bor [AiBatteryNative]::SWP_NOACTIVATE -bor [AiBatteryNative]::SWP_SHOWWINDOW
1385
+ ) | Out-Null
1386
+ }
1387
+ $form.TopMost = $true
1388
+ [AiBatteryNative]::SetWindowPos(
1389
+ $form.Handle,
1390
+ [AiBatteryNative]::HWND_TOPMOST,
1391
+ 0,
1392
+ 0,
1393
+ 0,
1394
+ 0,
1395
+ [AiBatteryNative]::SWP_NOMOVE -bor [AiBatteryNative]::SWP_NOSIZE -bor [AiBatteryNative]::SWP_NOACTIVATE -bor [AiBatteryNative]::SWP_SHOWWINDOW
1396
+ ) | Out-Null
1397
+ }
1398
+
1399
+ function Set-HudHiddenForFullscreen([bool]$Hidden) {
1400
+ $script:hudHiddenForFullscreen = $Hidden
1401
+ if ($Hidden) {
1402
+ if ($hitForm -and -not $hitForm.IsDisposed -and $hitForm.Visible) {
1403
+ $hitForm.Hide()
1404
+ }
1405
+ if ($form -and -not $form.IsDisposed -and $form.Visible) {
1406
+ $form.Hide()
1407
+ }
1408
+ return
1409
+ }
1410
+
1411
+ if ($form -and -not $form.IsDisposed -and -not $form.Visible) {
1412
+ $form.Show()
1413
+ }
1414
+ Show-HitForm
1415
+ Sync-HitFormBounds
1416
+ }
1417
+
1418
+ function Update-HudVisibilityForFullscreen {
1419
+ if (-not $form -or $form.IsDisposed) { return }
1420
+ if (Test-ForegroundFullscreen) {
1421
+ Set-HudHiddenForFullscreen $true
1422
+ return
1423
+ }
1424
+ Set-HudHiddenForFullscreen $false
1425
+ Ensure-HudTopMost
1426
+ }
1427
+
1428
+ function Set-HudBatteryImage($Box, [Nullable[int]]$Percent, [bool]$Running) {
1429
+ $oldImage = $Box.Image
1430
+ $Box.Image = New-BatteryImage $Percent $Running
1431
+ $Box.Visible = $true
1432
+ $Box.Tag = $true
1433
+ if ($oldImage) { $oldImage.Dispose() }
1434
+ }
1435
+
1436
+ function New-HudGlyphImage([string]$Kind, [System.Drawing.Color]$Color, [int]$Width = 21, [int]$Height = 18) {
1437
+ $bitmap = [System.Drawing.Bitmap]::new($Width, $Height)
1438
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
1439
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
1440
+ $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
1441
+ $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
1442
+ $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::Half
1443
+ $graphics.Clear([System.Drawing.Color]::Transparent)
1444
+
1445
+ if ($Kind -eq "reset") {
1446
+ $pen = [System.Drawing.Pen]::new($Color, 2.1)
1447
+ $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
1448
+ $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
1449
+ $brush = [System.Drawing.SolidBrush]::new($Color)
1450
+ try {
1451
+ $arcX = 3.6
1452
+ $arcY = 2.5
1453
+ $arcW = 13.0
1454
+ $arcH = 13.0
1455
+ $startAngle = 150.0
1456
+ $sweepAngle = 190.0
1457
+ $endAngle = ($startAngle + $sweepAngle) * [math]::PI / 180.0
1458
+ $centerX = $arcX + ($arcW / 2.0)
1459
+ $centerY = $arcY + ($arcH / 2.0)
1460
+ $radiusX = $arcW / 2.0
1461
+ $radiusY = $arcH / 2.0
1462
+
1463
+ $graphics.DrawArc($pen, [single]$arcX, [single]$arcY, [single]$arcW, [single]$arcH, [single]$startAngle, [single]$sweepAngle)
1464
+
1465
+ $tipX = $centerX + ($radiusX * [math]::Cos($endAngle))
1466
+ $tipY = $centerY + ($radiusY * [math]::Sin($endAngle))
1467
+ $dirX = -[math]::Sin($endAngle)
1468
+ $dirY = [math]::Cos($endAngle)
1469
+ $length = [math]::Sqrt(($dirX * $dirX) + ($dirY * $dirY))
1470
+ if ($length -gt 0) {
1471
+ $dirX = $dirX / $length
1472
+ $dirY = $dirY / $length
1473
+ }
1474
+ $normalX = -$dirY
1475
+ $normalY = $dirX
1476
+ $headLength = 4.8
1477
+ $headWidth = 3.3
1478
+ $baseX = $tipX - ($dirX * $headLength)
1479
+ $baseY = $tipY - ($dirY * $headLength)
1480
+
1481
+ $points = @(
1482
+ [System.Drawing.PointF]::new([single]$tipX, [single]$tipY),
1483
+ [System.Drawing.PointF]::new([single]($baseX + ($normalX * $headWidth)), [single]($baseY + ($normalY * $headWidth))),
1484
+ [System.Drawing.PointF]::new([single]($baseX - ($normalX * $headWidth)), [single]($baseY - ($normalY * $headWidth)))
1485
+ )
1486
+ $graphics.FillPolygon($brush, $points)
1487
+ } finally {
1488
+ $pen.Dispose()
1489
+ $brush.Dispose()
1490
+ $graphics.Dispose()
1491
+ }
1492
+ return $bitmap
1493
+ }
1494
+
1495
+ $sourceSize = 48
1496
+ $source = [System.Drawing.Bitmap]::new($sourceSize, $sourceSize)
1497
+ $sourceGraphics = [System.Drawing.Graphics]::FromImage($source)
1498
+ $sourceGraphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
1499
+ $sourceGraphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
1500
+ $sourceGraphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::Half
1501
+ $sourceGraphics.Clear([System.Drawing.Color]::Transparent)
1502
+
1503
+ $brush = [System.Drawing.SolidBrush]::new($Color)
1504
+ $format = [System.Drawing.StringFormat]::new()
1505
+ $format.Alignment = [System.Drawing.StringAlignment]::Center
1506
+ $format.LineAlignment = [System.Drawing.StringAlignment]::Near
1507
+
1508
+ try {
1509
+ $glyph = [string][char]0x2466
1510
+ $maxIconWidth = 12
1511
+ $maxIconHeight = 12
1512
+ $sourceGraphics.DrawString(
1513
+ $glyph,
1514
+ $symbolFont,
1515
+ $brush,
1516
+ [System.Drawing.RectangleF]::new([single]0, [single]0, [single]$sourceSize, [single]$sourceSize),
1517
+ $format
1518
+ )
1519
+
1520
+ $left = $sourceSize
1521
+ $top = $sourceSize
1522
+ $right = -1
1523
+ $bottom = -1
1524
+ for ($y = 0; $y -lt $sourceSize; $y += 1) {
1525
+ for ($x = 0; $x -lt $sourceSize; $x += 1) {
1526
+ if ($source.GetPixel($x, $y).A -gt 0) {
1527
+ if ($x -lt $left) { $left = $x }
1528
+ if ($x -gt $right) { $right = $x }
1529
+ if ($y -lt $top) { $top = $y }
1530
+ if ($y -gt $bottom) { $bottom = $y }
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ if ($right -ge $left -and $bottom -ge $top) {
1536
+ $sourceRect = [System.Drawing.Rectangle]::new($left, $top, ($right - $left + 1), ($bottom - $top + 1))
1537
+ $scale = [math]::Min(1.0, [math]::Min(($maxIconWidth / [double]$sourceRect.Width), ($maxIconHeight / [double]$sourceRect.Height)))
1538
+ $targetWidth = [math]::Max(1, [int][math]::Round($sourceRect.Width * $scale))
1539
+ $targetHeight = [math]::Max(1, [int][math]::Round($sourceRect.Height * $scale))
1540
+ $targetX = [math]::Floor(($Width - $targetWidth) / 2)
1541
+ $targetY = [math]::Floor(($Height - $targetHeight) / 2)
1542
+ $targetRect = [System.Drawing.Rectangle]::new([int]$targetX, [int]$targetY, $targetWidth, $targetHeight)
1543
+ $graphics.DrawImage($source, $targetRect, $sourceRect, [System.Drawing.GraphicsUnit]::Pixel)
1544
+ }
1545
+ } finally {
1546
+ $brush.Dispose()
1547
+ $format.Dispose()
1548
+ $sourceGraphics.Dispose()
1549
+ $source.Dispose()
1550
+ $graphics.Dispose()
1551
+ }
1552
+
1553
+ return $bitmap
1554
+ }
1555
+
1556
+ function Set-HudLineIconImage($Box, [string]$Kind, [string]$Value, [System.Drawing.Color]$Color) {
1557
+ $oldImage = $Box.Image
1558
+ if ([string]::IsNullOrWhiteSpace($Value)) {
1559
+ $Box.Image = $null
1560
+ $Box.Visible = $false
1561
+ } else {
1562
+ $Box.Image = New-HudGlyphImage $Kind $Color
1563
+ $Box.Visible = $true
1564
+ }
1565
+ if ($oldImage) { $oldImage.Dispose() }
1566
+ }
1567
+
1568
+ function Set-HudMetricLabel($Label, [string]$Value, [System.Drawing.Color]$Color, [Nullable[int]]$FixedWidth = $null) {
1569
+ $Label.Text = $Value
1570
+ $Label.ForeColor = $Color
1571
+ if ([string]::IsNullOrWhiteSpace($Value)) {
1572
+ $Label.Width = 0
1573
+ $Label.Visible = $false
1574
+ $Label.Tag = $false
1575
+ } else {
1576
+ $Label.Width = if ($null -ne $FixedWidth -and $FixedWidth -gt 0) { [int]$FixedWidth } else { (Get-LabelTextWidth $Label) + 1 }
1577
+ $Label.Visible = $true
1578
+ $Label.Tag = $true
1579
+ }
1580
+ }
1581
+
1582
+ function Set-HudDivider($Divider, [string]$Value) {
1583
+ if ([string]::IsNullOrWhiteSpace($Value)) {
1584
+ $Divider.Visible = $false
1585
+ $Divider.Width = 0
1586
+ $Divider.Tag = $false
1587
+ } else {
1588
+ $Divider.Visible = $true
1589
+ $Divider.Width = 1
1590
+ $Divider.Tag = $true
1591
+ }
1592
+ }
1593
+
1594
+ function Set-HudParts($Parts, $PrefixLabel, $BatteryBox, $Divider1, $ResetLabel, $Divider2, $WeekLabel, $ExtraLabel) {
1595
+ $PrefixLabel.Text = $Parts.Prefix
1596
+ $PrefixLabel.ForeColor = $Parts.TextColor
1597
+ $PrefixLabel.Width = 48
1598
+ $PrefixLabel.Visible = $true
1599
+ $PrefixLabel.Tag = $true
1600
+ Set-HudBatteryImage $BatteryBox $Parts.Percent $Parts.Running
1601
+ Set-HudDivider $Divider1 $Parts.ResetValue
1602
+ Set-HudMetricLabel $ResetLabel $Parts.ResetValue $Parts.TextColor 52
1603
+ Set-HudDivider $Divider2 $Parts.WeekValue
1604
+ Set-HudMetricLabel $WeekLabel $Parts.WeekValue $Parts.TextColor 52
1605
+ Set-HudMetricLabel $ExtraLabel $Parts.ExtraText $Parts.TextColor
1606
+ }
1607
+
1608
+ function Set-HudControlsVisible($Controls, [bool]$Visible) {
1609
+ foreach ($control in $Controls) {
1610
+ $control.Visible = $Visible
1611
+ $control.Tag = $Visible
1612
+ }
1613
+ }
1614
+
1615
+ function Get-LabelTextWidth($Label) {
1616
+ if (-not $Label.Text) { return 0 }
1617
+ return [System.Windows.Forms.TextRenderer]::MeasureText(
1618
+ $Label.Text,
1619
+ $Label.Font,
1620
+ [System.Drawing.Size]::new(1000, 1000),
1621
+ [System.Windows.Forms.TextFormatFlags]::NoPadding
1622
+ ).Width
1623
+ }
1624
+
1625
+ function Get-ControlLayoutWidth($Control) {
1626
+ # Control.Visible reads as $false while the form is still hidden, so track
1627
+ # the intended visibility in Tag to keep pre-show layout math correct.
1628
+ if ($Control.Tag -ne $true) { return 0 }
1629
+ $baseWidth = $Control.Width
1630
+ return $baseWidth + $Control.Margin.Left + $Control.Margin.Right
1631
+ }
1632
+
1633
+ function Get-HudControlsWidth($Controls) {
1634
+ $width = 0
1635
+ foreach ($control in $Controls) {
1636
+ $width += Get-ControlLayoutWidth $control
1637
+ }
1638
+ return $width
1639
+ }
1640
+
1641
+ function Resize-HudToContent {
1642
+ if ($Mode -eq "tray") { return }
1643
+
1644
+ $oldRight = $form.Left + $form.Width
1645
+ $oldBottom = $form.Top + $form.Height
1646
+ $codexWidth = $(if ($script:codexRowVisible) { Get-HudControlsWidth $codexHudControls } else { 0 })
1647
+ $claudeWidth = $(if ($script:claudeRowVisible) { Get-HudControlsWidth $claudeHudControls } else { 0 })
1648
+ $contentWidth = [math]::Max($codexWidth, $claudeWidth)
1649
+ $desiredWidth = $panel.Padding.Left + $panel.Padding.Right +
1650
+ $contentWidth +
1651
+ 1
1652
+ $desiredWidth = [math]::Max(150, [int][math]::Ceiling($desiredWidth))
1653
+
1654
+ $bothRows = $script:codexRowVisible -and $script:claudeRowVisible
1655
+ $desiredHeight = [int]$(if ($bothRows) { $script:hudTwoRowHeight } else { $script:hudOneRowHeight })
1656
+ if ($bothRows) {
1657
+ $panel.RowStyles[0] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 50)
1658
+ $panel.RowStyles[1] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 50)
1659
+ } elseif ($script:claudeRowVisible) {
1660
+ $panel.RowStyles[0] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 0)
1661
+ $panel.RowStyles[1] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100)
1662
+ } else {
1663
+ $panel.RowStyles[0] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Percent, 100)
1664
+ $panel.RowStyles[1] = [System.Windows.Forms.RowStyle]::new([System.Windows.Forms.SizeType]::Absolute, 0)
1665
+ }
1666
+
1667
+ if (([math]::Abs($form.Width - $desiredWidth) -gt 1) -or ($form.Height -ne $desiredHeight)) {
1668
+ $form.Width = $desiredWidth
1669
+ $form.Height = $desiredHeight
1670
+ $newX = if ($script:hudAnchorX -eq "right") { $oldRight - $form.Width } else { $form.Left }
1671
+ $newY = if ($script:hudAnchorY -eq "bottom") { $oldBottom - $form.Height } else { $form.Top }
1672
+ $form.Location = [System.Drawing.Point]::new([int]$newX, [int]$newY)
1673
+ Sync-HitFormBounds
1674
+ Save-HudPositionIfReady
1675
+ }
1676
+ }
1677
+
1678
+ function Show-HudMessage([string]$Message) {
1679
+ $codexPrefixLabel.Text = $Message
1680
+ $codexPrefixLabel.ForeColor = [System.Drawing.Color]::FromArgb(145, 145, 145)
1681
+ $codexPrefixLabel.Width = (Get-LabelTextWidth $codexPrefixLabel) + 1
1682
+ $codexPrefixLabel.Visible = $true
1683
+ $codexPrefixLabel.Tag = $true
1684
+ foreach ($label in @($codexResetLabel, $codexWeekLabel, $codexExtraLabel, $claudePrefixLabel, $claudeResetLabel, $claudeWeekLabel, $claudeExtraLabel)) {
1685
+ $label.Text = ""
1686
+ $label.Width = 0
1687
+ $label.Visible = $false
1688
+ $label.Tag = $false
1689
+ }
1690
+ foreach ($divider in @($codexDivider1, $codexDivider2, $claudeDivider1, $claudeDivider2)) {
1691
+ $divider.Visible = $false
1692
+ $divider.Width = 0
1693
+ $divider.Tag = $false
1694
+ }
1695
+ Set-HudBatteryImage $codexIconLabel $null $false
1696
+ $oldImage = $claudeIconLabel.Image
1697
+ $claudeIconLabel.Image = $null
1698
+ $claudeIconLabel.Visible = $false
1699
+ $claudeIconLabel.Tag = $false
1700
+ if ($oldImage) { $oldImage.Dispose() }
1701
+ $script:codexRowVisible = $true
1702
+ $script:claudeRowVisible = $false
1703
+ $codexRow.Visible = $true
1704
+ $claudeRow.Visible = $false
1705
+ Resize-HudToContent
1706
+ }
1707
+
1708
+ function Update-HudFromSnapshot($Snapshot) {
1709
+ if (-not $Snapshot) {
1710
+ Show-HudMessage "AI Battery starting..."
1711
+ Update-HudVisibilityForFullscreen
1712
+ return
1713
+ }
1714
+
1715
+ try {
1716
+ $texts = ConvertTo-HudTexts $Snapshot
1717
+ $script:codexRowVisible = [bool]$texts.CodexVisible
1718
+ $script:claudeRowVisible = [bool]$texts.ClaudeVisible
1719
+ if ($texts.CodexVisible) {
1720
+ $codexRow.Visible = $true
1721
+ Set-HudParts $texts.Codex $codexPrefixLabel $codexIconLabel $codexDivider1 $codexResetLabel $codexDivider2 $codexWeekLabel $codexExtraLabel
1722
+ } else {
1723
+ $codexRow.Visible = $false
1724
+ Set-HudControlsVisible $codexHudControls $false
1725
+ }
1726
+ if ($texts.ClaudeVisible) {
1727
+ $claudeRow.Visible = $true
1728
+ Set-HudParts $texts.Claude $claudePrefixLabel $claudeIconLabel $claudeDivider1 $claudeResetLabel $claudeDivider2 $claudeWeekLabel $claudeExtraLabel
1729
+ } else {
1730
+ $claudeRow.Visible = $false
1731
+ Set-HudControlsVisible $claudeHudControls $false
1732
+ }
1733
+ Resize-HudToContent
1734
+ } catch {
1735
+ Show-HudMessage "AI Battery unavailable"
1736
+ }
1737
+ Update-HudVisibilityForFullscreen
1738
+ }
1739
+
1740
+ function Invoke-HudPump {
1741
+ # Everything here must return quickly: this runs on the UI thread, and the
1742
+ # actual data fetch happens on a background runspace.
1743
+ if ($script:fetchPowerShell -and -not $script:fetchHandle.IsCompleted -and
1744
+ ([datetime]::UtcNow - $script:lastFetchStartUtc).TotalSeconds -gt 30) {
1745
+ Stop-SnapshotFetch
1746
+ }
1747
+
1748
+ $fresh = Complete-SnapshotFetch
1749
+ if ($fresh) {
1750
+ $merged = Merge-HudSnapshot $fresh $script:latestSnapshot
1751
+ $script:latestSnapshot = $merged
1752
+ Write-HudSnapshot $merged
1753
+ Update-HudFromSnapshot $merged
1754
+
1755
+ $missingWeekly = @($merged.results | Where-Object { $_.ok -and $null -ne $_.percentRemaining -and $null -eq $_.secondary })
1756
+ if ($missingWeekly.Count -gt 0 -and $script:weeklyRetryCount -lt 6) {
1757
+ $script:weeklyRetryCount += 1
1758
+ Start-SnapshotFetch
1759
+ return
1760
+ }
1761
+ $script:weeklyRetryCount = 0
1762
+ return
1763
+ }
1764
+
1765
+ if (-not $script:fetchPowerShell -and
1766
+ ([datetime]::UtcNow - $script:lastFetchStartUtc).TotalSeconds -ge [math]::Max(1, $Interval)) {
1767
+ Start-SnapshotFetch
1768
+ }
1769
+ }
1770
+
1771
+ $timer = New-Object System.Windows.Forms.Timer
1772
+ $timer.Interval = 1000
1773
+ $timer.add_Tick({ Invoke-HudPump })
1774
+ $topMostTimer = New-Object System.Windows.Forms.Timer
1775
+ $topMostTimer.Interval = 1000
1776
+ $topMostTimer.add_Tick({ Update-HudVisibilityForFullscreen })
1777
+ $form.add_FormClosed({
1778
+ if ($canMove) {
1779
+ Write-HudPosition $form.Location
1780
+ }
1781
+ $timer.Stop()
1782
+ $timer.Dispose()
1783
+ $topMostTimer.Stop()
1784
+ $topMostTimer.Dispose()
1785
+ Stop-SnapshotFetch
1786
+ foreach ($box in @($codexIconLabel, $claudeIconLabel)) {
1787
+ if ($box.Image) {
1788
+ $box.Image.Dispose()
1789
+ $box.Image = $null
1790
+ }
1791
+ }
1792
+ $font.Dispose()
1793
+ $symbolFont.Dispose()
1794
+ if ($hitForm -and -not $hitForm.IsDisposed) {
1795
+ $hitForm.Close()
1796
+ $hitForm.Dispose()
1797
+ }
1798
+ Release-SingleInstance
1799
+ })
1800
+
1801
+ $form.add_LocationChanged({
1802
+ # No position save here: writing the state file on every drag pixel causes
1803
+ # visible hitches. MouseUp and Resize-HudToContent persist the placement.
1804
+ Sync-HitFormBounds
1805
+ })
1806
+ $form.add_SizeChanged({ Sync-HitFormBounds })
1807
+ $form.add_Shown({
1808
+ Show-HitForm
1809
+ Update-HudVisibilityForFullscreen
1810
+ })
1811
+
1812
+ Set-Position
1813
+ $script:positionSaveReady = $true
1814
+
1815
+ $diskSnapshot = Read-HudSnapshot
1816
+ if ($script:initialHudSnapshot) {
1817
+ $script:latestSnapshot = Merge-HudSnapshot $script:initialHudSnapshot $diskSnapshot
1818
+ $script:initialHudSnapshot = $null
1819
+ Write-HudSnapshot $script:latestSnapshot
1820
+ } else {
1821
+ $script:latestSnapshot = $diskSnapshot
1822
+ }
1823
+ Update-HudFromSnapshot $script:latestSnapshot
1824
+ Start-SnapshotFetch
1825
+ if ($Mode -eq "statusline" -or $ClickThrough) {
1826
+ $style = [AiBatteryNative]::GetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE)
1827
+ $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW -bor [AiBatteryNative]::WS_EX_NOACTIVATE
1828
+ if ($ClickThrough) {
1829
+ $style = $style -bor [AiBatteryNative]::WS_EX_TRANSPARENT
1830
+ }
1831
+ [AiBatteryNative]::SetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE, $style) | Out-Null
1832
+ }
1833
+ $timer.Start()
1834
+ $topMostTimer.Start()
1835
+ [System.Windows.Forms.Application]::Run($form)