ai-battery 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ Codex 86% │ 5h 18:09 │ 7d 82% ┃ Claude 4% │ 5h 18:10 │ 7d 71%
51
51
  | `ai-battery --watch` | 지원 | 지원 | 지원 | 지원 | 터미널 안에서 주기적으로 갱신합니다. |
52
52
  | Claude statusLine | 지원 | 지원 | 지원 | 지원 | Claude Code `statusLine`에 `node <script>` 명령을 저장합니다. |
53
53
  | Codex terminal row | 지원 | 지원 | 지원 | 지원 | Windows는 `node-pty`/ConPTY가 있으면 reserved row, 없으면 overlay fallback을 사용합니다. WSL/Linux/macOS는 POSIX PTY와 `python3`를 사용합니다. |
54
- | `ai-battery setup codex` | 지원 | 지원 | 지원 | 지원 | Windows는 `codex.cmd` wrapper, WSL/Linux/macOS는 POSIX shell wrapper를 설치합니다. |
54
+ | `ai-battery setup codex` | 지원 | 지원 | 지원 | 지원 | Codex `[tui].status_line`을 맞추고, Windows는 `codex.cmd` wrapper, WSL/Linux/macOS는 POSIX shell wrapper를 설치합니다. |
55
55
  | `ai-battery hud` | 지원 | 지원 | 미지원 | 지원 | Windows/WSL은 PowerShell/WinForms HUD, macOS는 menu bar status item입니다. |
56
56
 
57
57
  실행 중 감지(흰색/회색 표시)는 Linux/WSL은 `/proc`, macOS는 `ps`, Windows는 PowerShell 프로세스 목록을 사용합니다.
@@ -141,7 +141,7 @@ ai-battery uninstall claude
141
141
  ai-battery uninstall hud
142
142
  ```
143
143
 
144
- 이 명령은 AI Battery가 관리 마커를 넣은 Codex wrapper, Claude `statusLine`, HUD/menu bar autostart와 실행 중인 HUD를 정리합니다. 다른 도구가 만든 `codex` 파일이나 Claude `statusLine`은 건드리지 않습니다. 이전 버전이나 `--force`로 기존 파일을 백업한 경우에는 가능한 한 원래 파일/symlink를 복원합니다. 현재 이미 AI Battery wrapper 안에서 실행 중인 Codex 세션의 terminal row는 그 세션을 종료해야 사라집니다.
144
+ 이 명령은 AI Battery가 관리 마커를 넣은 Codex wrapper, Codex `[tui].status_line`, Claude `statusLine`, HUD/menu bar autostart와 실행 중인 HUD를 정리합니다. 다른 도구가 만든 `codex` 파일이나 Claude `statusLine`은 건드리지 않습니다. Codex config가 setup 이후 사용자가 수정한 상태라면 안전하게 그대로 둡니다. 이전 버전이나 `--force`로 기존 파일을 백업한 경우에는 가능한 한 원래 파일/symlink를 복원합니다. 현재 이미 AI Battery wrapper 안에서 실행 중인 Codex 세션의 terminal row는 그 세션을 종료해야 사라집니다.
145
145
 
146
146
  최신 npm은 패키지의 uninstall lifecycle을 실행하지 않기 때문에 `npm uninstall ai-battery` 또는 `npm uninstall -g ai-battery`만으로는 외부 통합 지점을 자동 정리할 수 없습니다. 완전히 제거하려면 npm 패키지를 지우기 전에 먼저 실행하세요.
147
147
 
@@ -150,11 +150,11 @@ ai-battery uninstall
150
150
  npm uninstall -g ai-battery
151
151
  ```
152
152
 
153
- 이미 npm 패키지를 먼저 지운 뒤라면, 다시 설치한 다음 `ai-battery uninstall`을 실행하거나 아래 항목을 직접 확인해 제거해야 합니다: AI Battery가 만든 Codex wrapper, shell rc의 `# >>> ai-battery setup >>>` block, Claude `statusLine`, HUD/menu bar autostart.
153
+ 이미 npm 패키지를 먼저 지운 뒤라면, 다시 설치한 다음 `ai-battery uninstall`을 실행하거나 아래 항목을 직접 확인해 제거해야 합니다: AI Battery가 만든 Codex wrapper, shell rc의 `# >>> ai-battery setup >>>` block, Codex `~/.codex/config.toml`의 `[tui].status_line`, Claude `statusLine`, HUD/menu bar autostart.
154
154
 
155
155
  ## Setup
156
156
 
157
- `setup`은 한 번만 실행합니다. Claude Code에는 statusLine hook을 설치하고, Codex에는 platform wrapper를 설치해서 이후에는 추가 명령 없이 원래처럼 실행하게 합니다.
157
+ `setup`은 한 번만 실행합니다. Claude Code에는 statusLine hook을 설치하고, Codex에는 기본 status line 구성과 platform wrapper를 설치해서 이후에는 추가 명령 없이 원래처럼 실행하게 합니다.
158
158
 
159
159
  ```bash
160
160
  ai-battery setup
@@ -167,7 +167,7 @@ ai-battery setup claude
167
167
  ai-battery setup codex
168
168
  ```
169
169
 
170
- Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin`이 이미 PATH에서 원본 `codex`보다 앞에 있고 `~/.local/bin/codex`가 비어 있거나 AI Battery 관리 파일이면 그 위치에 wrapper를 둬서 바로 잡히게 합니다. 그렇지 않으면 `~/.local/share/ai-battery/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 이 디렉터리를 PATH 앞쪽으로 추가합니다. `~/.local/bin/codex` 같은 공용 위치에 이미 다른 파일이 있으면 덮어쓰지 않습니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다. 같은 터미널에서 이미 `codex`를 실행한 적이 있으면 셸 캐시 때문에 `hash -r`이 한 번 필요할 수 있고, PATH 추가가 필요한 경우에는 `setup` 출력에 표시되는 `source ...` 명령을 실행하세요.
170
+ Codex setup은 `~/.codex/config.toml`의 `[tui]`에 `model-with-reasoning`, `current-dir`, `git-branch` status line을 설정합니다. 기존 값이 있으면 uninstall 복구용으로 백업합니다. Codex wrapper는 기존 `codex` 명령을 직접 덮어쓰지 않습니다. `~/.local/bin`이 이미 PATH에서 원본 `codex`보다 앞에 있고 `~/.local/bin/codex`가 비어 있거나 AI Battery 관리 파일이면 그 위치에 wrapper를 둬서 바로 잡히게 합니다. 그렇지 않으면 `~/.local/share/ai-battery/bin/codex`에 관리형 wrapper를 만들고, 필요한 경우 셸 설정에 이 디렉터리를 PATH 앞쪽으로 추가합니다. `~/.local/bin/codex` 같은 공용 위치에 이미 다른 파일이 있으면 덮어쓰지 않습니다. 새 터미널부터 `codex`가 자동으로 AI Battery 하단 행과 함께 실행됩니다. 같은 터미널에서 이미 `codex`를 실행한 적이 있으면 셸 캐시 때문에 `hash -r`이 한 번 필요할 수 있고, PATH 추가가 필요한 경우에는 `setup` 출력에 표시되는 `source ...` 명령을 실행하세요.
171
171
 
172
172
  Windows native `cmd`/PowerShell에서는 `codex.cmd` wrapper가 Windows runner를 실행합니다. `node-pty`가 설치되어 있으면 ConPTY로 Codex 화면을 한 줄 짧게 띄워 하단 row를 예약하고, `node-pty`가 없거나 로드되지 않으면 같은 콘솔의 하단에 overlay row를 다시 그리는 fallback을 사용합니다. Claude statusLine은 일반 `cmd`/PowerShell 프롬프트가 아니라 Claude Code 안에서만 표시됩니다.
173
173
 
@@ -192,7 +192,7 @@ ai-battery on all
192
192
 
193
193
  ## Codex Terminal Row
194
194
 
195
- Codex 자체 status line 수정하지 않습니다. `ai-battery setup`은 `codex` wrapper 설치해서 사용자가 원래처럼 `codex`만 입력해도 `ai-battery-run`이 내부에서 Codex를 한 줄 짧은 PTY 안에서 실행하도록 합니다.
195
+ `ai-battery setup`은 Codex 자체 status line `모델/추론강도 · 워크스페이스 · git branch` 구성으로 맞춥니다. 사용량 표시는 별도의 `codex` wrapper 담당하므로, 사용자는 원래처럼 `codex`만 입력하면 `ai-battery-run`이 내부에서 Codex를 한 줄 짧은 PTY 안에서 실행합니다.
196
196
 
197
197
  ```bash
198
198
  codex
@@ -74,6 +74,23 @@ function buildStartProcessCommand(filePath, args) {
74
74
  return `Start-Process -WindowStyle Hidden -FilePath ${psSingleQuote(filePath)} -ArgumentList ${psSingleQuote(commandLine)}`;
75
75
  }
76
76
 
77
+ function psInvocationArg(value) {
78
+ const text = String(value);
79
+ if (/^-[A-Za-z][A-Za-z0-9]*$/.test(text)) return text;
80
+ return psSingleQuote(text);
81
+ }
82
+
83
+ function powerShellCommandArgs(scriptPath, scriptArgs) {
84
+ const invocation = `& ${psSingleQuote(scriptPath)} ${scriptArgs.map(psInvocationArg).join(" ")}`;
85
+ return [
86
+ "-NoProfile",
87
+ "-ExecutionPolicy",
88
+ "Bypass",
89
+ "-Command",
90
+ invocation
91
+ ];
92
+ }
93
+
77
94
  function sleepSync(ms) {
78
95
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
79
96
  }
@@ -413,6 +430,7 @@ const envPrefix = useWsl ? wslEnvPrefix() : "";
413
430
  const batteryCommand = configuredCommand || (useWsl
414
431
  ? `${envPrefix ? `${envPrefix} ` : ""}HOME=${shellQuote(os.homedir())} ${shellQuote(nodePath)} ${shellQuote(batteryJs)} --json`
415
432
  : `${winArgQuote(nodePath)} ${winArgQuote(batteryJs)} --json`);
433
+ const batteryCommandBase64 = Buffer.from(batteryCommand, "utf8").toString("base64");
416
434
 
417
435
  const AUTOSTART_REG_PATH = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
418
436
  const AUTOSTART_REG_NAME = "AiBatteryHud";
@@ -430,7 +448,7 @@ function hudProcessStatus() {
430
448
  + "$_.Name -match '^(powershell|pwsh)' -and "
431
449
  + "$_.CommandLine -like '*ai-battery-hud.ps1*' -and "
432
450
  + "$_.CommandLine -notlike '*Start-Process*' }; "
433
- + "if ($hud) { 'running (PID ' + @($hud)[0].ProcessId + ')' } else { 'stopped' }";
451
+ + "if ($hud) { $p = @($hud)[0]; $source = if ($p.CommandLine -like '*-UseWsl*') { 'WSL' } else { 'Windows' }; 'running (' + $source + ', PID ' + $p.ProcessId + ')' } else { 'stopped' }";
434
452
  const result = runPowerShell(query);
435
453
  return (result.stdout || "").trim() || "unknown";
436
454
  }
@@ -448,26 +466,17 @@ function autostartEnable() {
448
466
  // autostart.ps1 refreshes the local copy of the HUD script when the source
449
467
  // is reachable (the WSL share is not mounted until the distro starts), then
450
468
  // launches the copy so sign-in start never depends on WSL being up. The HUD
451
- // must run as a separate "powershell -File ...ai-battery-hud.ps1" process:
452
- // stop/status and the single-instance cleanup all match that command line.
453
- const hudArgLiterals = [
454
- "'-NoProfile'",
455
- "'-ExecutionPolicy'",
456
- "'Bypass'",
457
- "'-File'",
458
- "('\"' + $hud + '\"')",
459
- "'-BatteryCommand'",
460
- "('\"' + ($battery -replace '\"', '\\\"') + '\"')"
461
- ];
462
- if (useWsl) hudArgLiterals.push("'-UseWsl'");
463
-
469
+ // must run as a separate PowerShell process whose command line contains
470
+ // ai-battery-hud.ps1: stop/status and single-instance cleanup match it.
464
471
  const autostartScript = [
465
472
  "# Generated by: ai-battery hud autostart on",
466
473
  `$src = ${psSingleQuote(hudScript)}`,
467
474
  "$hud = Join-Path $PSScriptRoot 'ai-battery-hud.ps1'",
468
475
  "try { Copy-Item $src $hud -Force -ErrorAction Stop } catch { }",
469
476
  `$battery = ${psSingleQuote(batteryCommand)}`,
470
- `$argList = @(${hudArgLiterals.join(", ")}) -join ' '`,
477
+ "$batteryB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($battery))",
478
+ "$invoke = '& ' + \"'\" + ($hud -replace \"'\", \"''\") + \"'\" + ' -BatteryCommandBase64 ' + $batteryB64" + (useWsl ? " + ' -UseWsl'" : ""),
479
+ "$argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ('\"' + ($invoke -replace '\"', '\\\"') + '\"')) -join ' '",
471
480
  "Start-Process -WindowStyle Hidden -FilePath 'powershell.exe' -ArgumentList $argList",
472
481
  ""
473
482
  ].join("\r\n");
@@ -527,29 +536,26 @@ const readyPath = (!useWsl && process.platform === "win32" && !foreground && !on
527
536
  ? path.join(os.tmpdir(), `ai-battery-hud-ready-${process.pid}-${Date.now()}.json`)
528
537
  : null;
529
538
 
530
- const psArgs = [
531
- "-NoProfile",
532
- "-ExecutionPolicy",
533
- "Bypass",
534
- "-File",
535
- hudScript,
536
- "-BatteryCommand",
537
- batteryCommand,
539
+ const hudScriptArgs = [
540
+ "-BatteryCommandBase64",
541
+ batteryCommandBase64,
538
542
  ...filteredArgs
539
543
  ];
540
544
 
541
545
  if (readyPath) {
542
- psArgs.push("-ReadyPath", readyPath);
546
+ hudScriptArgs.push("-ReadyPath", readyPath);
543
547
  }
544
548
 
545
549
  if (initialJson) {
546
- psArgs.push("-InitialJsonBase64", Buffer.from(initialJson, "utf8").toString("base64"));
550
+ hudScriptArgs.push("-InitialJsonBase64", Buffer.from(initialJson, "utf8").toString("base64"));
547
551
  }
548
552
 
549
553
  if (useWsl) {
550
- psArgs.push("-UseWsl");
554
+ hudScriptArgs.push("-UseWsl");
551
555
  }
552
556
 
557
+ const psArgs = powerShellCommandArgs(hudScript, hudScriptArgs);
558
+
553
559
  if (foreground || once || stop) {
554
560
  const result = spawnSync(powershell, psArgs, { stdio: "inherit", windowsHide: true });
555
561
  if (stop && (result.status ?? 0) === 0) {
@@ -571,12 +577,17 @@ if (useWsl) {
571
577
  });
572
578
  if (start.status !== 0) process.exit(start.status ?? 1);
573
579
  } else {
574
- const child = spawn(powershell, psArgs, {
575
- detached: true,
580
+ const start = spawnSync(powershell, [
581
+ "-NoProfile",
582
+ "-ExecutionPolicy",
583
+ "Bypass",
584
+ "-Command",
585
+ buildStartProcessCommand("powershell.exe", psArgs)
586
+ ], {
576
587
  stdio: "ignore",
577
588
  windowsHide: true
578
589
  });
579
- child.unref();
590
+ if (start.status !== 0) process.exit(start.status ?? 1);
580
591
  }
581
592
 
582
593
  if (readyPath && !waitForFile(readyPath)) {
@@ -4,6 +4,7 @@ param(
4
4
  [ValidateSet("tray", "statusline", "floating")]
5
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
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]$BatteryCommandBase64 = "",
7
8
  [string]$InitialJsonBase64 = "",
8
9
  [int]$Width = 282,
9
10
  [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 }),
@@ -20,6 +21,14 @@ $ErrorActionPreference = "Stop"
20
21
  Add-Type -AssemblyName System.Drawing
21
22
  [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
22
23
 
24
+ if (-not [string]::IsNullOrWhiteSpace($BatteryCommandBase64)) {
25
+ try {
26
+ $BatteryCommand = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($BatteryCommandBase64))
27
+ } catch {
28
+ # Fall back to BatteryCommand/env defaults; the HUD can still show an error row.
29
+ }
30
+ }
31
+
23
32
  function Stop-ExistingHudProcesses {
24
33
  $currentPid = $PID
25
34
  Get-CimInstance Win32_Process |
@@ -89,6 +98,11 @@ function Get-HudStatePath {
89
98
  return Join-Path $root "hud-position.json"
90
99
  }
91
100
 
101
+ function Get-HudSourceId {
102
+ if ($UseWsl) { return "wsl" }
103
+ return "windows"
104
+ }
105
+
92
106
  function Get-HudSnapshotPath {
93
107
  $root = if ($env:LOCALAPPDATA) {
94
108
  Join-Path $env:LOCALAPPDATA "ai-battery"
@@ -96,7 +110,7 @@ function Get-HudSnapshotPath {
96
110
  Join-Path $env:TEMP "ai-battery"
97
111
  }
98
112
  New-Item -ItemType Directory -Force -Path $root | Out-Null
99
- return Join-Path $root "hud-snapshot.json"
113
+ return Join-Path $root "hud-snapshot-$(Get-HudSourceId).json"
100
114
  }
101
115
 
102
116
  function Get-LegacyHudStatePath {
@@ -206,6 +220,7 @@ function Read-InitialHudSnapshot {
206
220
  function Write-HudSnapshot($Snapshot) {
207
221
  try {
208
222
  if (-not $Snapshot) { return }
223
+ $Snapshot | Add-Member -NotePropertyName "hudSource" -NotePropertyValue (Get-HudSourceId) -Force
209
224
  $Snapshot | ConvertTo-Json -Depth 20 -Compress | Set-Content -Encoding UTF8 -Path (Get-HudSnapshotPath)
210
225
  } catch {
211
226
  # The HUD can still run without a warm-start cache.
@@ -231,12 +246,36 @@ public struct AiBatteryMonitorInfo {
231
246
  public AiBatteryRect Work;
232
247
  public int Flags;
233
248
  }
249
+ [StructLayout(LayoutKind.Sequential)]
250
+ public struct AiBatteryPowerThrottlingState {
251
+ public uint Version;
252
+ public uint ControlMask;
253
+ public uint StateMask;
254
+ }
255
+ public delegate void AiBatteryWinEventDelegate(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
234
256
  public static class AiBatteryNative {
257
+ public const int ProcessPowerThrottling = 4;
258
+ public const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
259
+ public const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
260
+ public const int OBJID_WINDOW = 0;
235
261
  public const int GWL_EXSTYLE = -20;
236
262
  public const int WS_EX_TRANSPARENT = 0x20;
237
263
  public const int WS_EX_TOOLWINDOW = 0x80;
238
264
  public const int WS_EX_NOACTIVATE = 0x08000000;
265
+ public const int SW_HIDE = 0;
266
+ public const int SW_SHOWNOACTIVATE = 4;
239
267
  public const UInt32 MONITOR_DEFAULTTONEAREST = 2;
268
+ public const UInt32 GW_HWNDNEXT = 2;
269
+ public const int DWMWA_CLOAKED = 14;
270
+ public const uint EVENT_SYSTEM_FOREGROUND = 0x0003;
271
+ public const uint EVENT_OBJECT_SHOW = 0x8002;
272
+ public const uint EVENT_OBJECT_HIDE = 0x8003;
273
+ public const uint EVENT_OBJECT_REORDER = 0x8004;
274
+ public const uint EVENT_OBJECT_LOCATIONCHANGE = 0x800B;
275
+ public const uint EVENT_OBJECT_CLOAKED = 0x8017;
276
+ public const uint EVENT_OBJECT_UNCLOAKED = 0x8018;
277
+ public const uint WINEVENT_OUTOFCONTEXT = 0x0000;
278
+ public const uint WINEVENT_SKIPOWNPROCESS = 0x0002;
240
279
  public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
241
280
  public const UInt32 SWP_NOSIZE = 0x0001;
242
281
  public const UInt32 SWP_NOMOVE = 0x0002;
@@ -250,6 +289,26 @@ public static class AiBatteryNative {
250
289
  public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
251
290
  [DllImport("user32.dll")]
252
291
  public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, UInt32 uFlags);
292
+ [DllImport("user32.dll")]
293
+ public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
294
+ [DllImport("user32.dll")]
295
+ public static extern IntPtr GetWindow(IntPtr hWnd, UInt32 uCmd);
296
+ [DllImport("user32.dll")]
297
+ public static extern IntPtr GetTopWindow(IntPtr hWnd);
298
+ [DllImport("user32.dll")]
299
+ public static extern bool IsWindowVisible(IntPtr hWnd);
300
+ [DllImport("user32.dll", CharSet=CharSet.Auto)]
301
+ public static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder lpClassName, int nMaxCount);
302
+ [DllImport("dwmapi.dll")]
303
+ public static extern int DwmGetWindowAttribute(IntPtr hWnd, int dwAttribute, out int pvAttribute, int cbAttribute);
304
+ [DllImport("kernel32.dll")]
305
+ public static extern IntPtr GetCurrentProcess();
306
+ [DllImport("kernel32.dll", SetLastError=true)]
307
+ public static extern bool SetProcessInformation(IntPtr hProcess, int ProcessInformationClass, ref AiBatteryPowerThrottlingState ProcessInformation, int ProcessInformationSize);
308
+ [DllImport("user32.dll")]
309
+ public static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, AiBatteryWinEventDelegate lpfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
310
+ [DllImport("user32.dll")]
311
+ public static extern bool UnhookWinEvent(IntPtr hWinEventHook);
253
312
  [DllImport("user32.dll", CharSet=CharSet.Auto)]
254
313
  public static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
255
314
  [DllImport("user32.dll", CharSet=CharSet.Auto)]
@@ -266,6 +325,27 @@ public static class AiBatteryNative {
266
325
  "@
267
326
  Add-Type -TypeDefinition $nativeCode
268
327
 
328
+ function Disable-ProcessPowerThrottling {
329
+ # When a fullscreen app is in the foreground, Windows applies EcoQoS power
330
+ # throttling to background processes like this HUD, which stretches the
331
+ # WinForms timer from 250ms out to several seconds. That made the HUD linger
332
+ # over a fullscreen window for 3-5s before hiding (while unhide, which happens
333
+ # after throttling lifts, stayed instant). Opt the process out so the
334
+ # fullscreen check keeps firing on time even while backgrounded.
335
+ try {
336
+ $state = New-Object AiBatteryPowerThrottlingState
337
+ $state.Version = [AiBatteryNative]::PROCESS_POWER_THROTTLING_CURRENT_VERSION
338
+ $state.ControlMask = [AiBatteryNative]::PROCESS_POWER_THROTTLING_EXECUTION_SPEED
339
+ $state.StateMask = 0
340
+ $size = [System.Runtime.InteropServices.Marshal]::SizeOf([type][AiBatteryPowerThrottlingState])
341
+ return [AiBatteryNative]::SetProcessInformation([AiBatteryNative]::GetCurrentProcess(), [AiBatteryNative]::ProcessPowerThrottling, [ref]$state, $size)
342
+ } catch {
343
+ # Older Windows builds lack ProcessPowerThrottling; the HUD still works.
344
+ return $false
345
+ }
346
+ }
347
+ $script:powerThrottlingDisabled = Disable-ProcessPowerThrottling
348
+
269
349
  function Invoke-AiBatteryJson {
270
350
  if ($UseWsl) {
271
351
  $output = & wsl.exe bash -lc "$BatteryCommand 2>/dev/null"
@@ -1154,7 +1234,7 @@ function Show-HitForm {
1154
1234
  $hitForm.Show()
1155
1235
  }
1156
1236
  $style = [AiBatteryNative]::GetWindowLong($hitForm.Handle, [AiBatteryNative]::GWL_EXSTYLE)
1157
- $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW
1237
+ $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW -bor [AiBatteryNative]::WS_EX_NOACTIVATE
1158
1238
  [AiBatteryNative]::SetWindowLong($hitForm.Handle, [AiBatteryNative]::GWL_EXSTYLE, $style) | Out-Null
1159
1239
  }
1160
1240
 
@@ -1274,21 +1354,89 @@ function Test-RectCoversMonitor($WindowRect, $MonitorRect) {
1274
1354
  )
1275
1355
  }
1276
1356
 
1277
- function Test-ForegroundFullscreen {
1278
- if (-not $form -or $form.IsDisposed) { return $false }
1279
- $foreground = [AiBatteryNative]::GetForegroundWindow()
1280
- if ($foreground -eq [IntPtr]::Zero) { return $false }
1281
- if ($foreground -eq $form.Handle) { return $false }
1282
- if ($hitForm -and -not $hitForm.IsDisposed -and $foreground -eq $hitForm.Handle) { return $false }
1357
+ function Test-RectIsSubstantialOnMonitor($WindowRect, $MonitorRect) {
1358
+ if (-not $WindowRect -or -not $MonitorRect) { return $false }
1359
+ $left = [math]::Max([int]$WindowRect.Left, [int]$MonitorRect.Left)
1360
+ $top = [math]::Max([int]$WindowRect.Top, [int]$MonitorRect.Top)
1361
+ $right = [math]::Min([int]$WindowRect.Right, [int]$MonitorRect.Right)
1362
+ $bottom = [math]::Min([int]$WindowRect.Bottom, [int]$MonitorRect.Bottom)
1363
+ $width = [math]::Max(0, $right - $left)
1364
+ $height = [math]::Max(0, $bottom - $top)
1365
+ $monitorArea = [math]::Max(1, [int]$MonitorRect.Width * [int]$MonitorRect.Height)
1366
+ $windowArea = $width * $height
1367
+ return (($windowArea / $monitorArea) -ge 0.20)
1368
+ }
1369
+
1370
+ function Test-HudWindowHandle([IntPtr]$Handle) {
1371
+ if ($Handle -eq [IntPtr]::Zero) { return $false }
1372
+ if ($form -and -not $form.IsDisposed -and $Handle -eq $form.Handle) { return $true }
1373
+ if ($hitForm -and -not $hitForm.IsDisposed -and $Handle -eq $hitForm.Handle) { return $true }
1374
+ return $false
1375
+ }
1376
+
1377
+ function Get-WindowClassName([IntPtr]$Handle) {
1378
+ if ($Handle -eq [IntPtr]::Zero) { return "" }
1379
+ $builder = New-Object System.Text.StringBuilder 256
1380
+ [AiBatteryNative]::GetClassName($Handle, $builder, $builder.Capacity) | Out-Null
1381
+ return $builder.ToString()
1382
+ }
1383
+
1384
+ function Test-WindowCloaked([IntPtr]$Handle) {
1385
+ if ($Handle -eq [IntPtr]::Zero) { return $false }
1386
+ $value = 0
1387
+ try {
1388
+ $hr = [AiBatteryNative]::DwmGetWindowAttribute($Handle, [AiBatteryNative]::DWMWA_CLOAKED, [ref]$value, 4)
1389
+ } catch {
1390
+ return $false
1391
+ }
1392
+ if ($hr -ne 0) { return $false }
1393
+ return ($value -ne 0)
1394
+ }
1395
+
1396
+ # Desktop and shell surfaces span the monitor but must never hide the HUD.
1397
+ $script:hudSkipWindowClasses = @(
1398
+ "Shell_TrayWnd",
1399
+ "Shell_SecondaryTrayWnd",
1400
+ "NotifyIconOverflowWindow",
1401
+ "Progman",
1402
+ "WorkerW"
1403
+ )
1283
1404
 
1405
+ function Test-FullscreenOnHudMonitor {
1406
+ if (-not $form -or $form.IsDisposed) { return $false }
1284
1407
  $hudMonitor = [AiBatteryNative]::MonitorFromWindow($form.Handle, [AiBatteryNative]::MONITOR_DEFAULTTONEAREST)
1285
- $foregroundMonitor = [AiBatteryNative]::MonitorFromWindow($foreground, [AiBatteryNative]::MONITOR_DEFAULTTONEAREST)
1286
- if ($hudMonitor -eq [IntPtr]::Zero -or $foregroundMonitor -eq [IntPtr]::Zero) { return $false }
1287
- if ($hudMonitor -ne $foregroundMonitor) { return $false }
1408
+ if ($hudMonitor -eq [IntPtr]::Zero) { return $false }
1409
+ $monitorRect = Get-MonitorRectObject $hudMonitor
1410
+ if (-not $monitorRect) { return $false }
1411
+
1412
+ # Walk the Z-order from the top down. Small transient overlays (volume, IME,
1413
+ # Game Bar, browser controls) are ignored so we can still see the fullscreen
1414
+ # window beneath them. A substantial normal window stops the search: any
1415
+ # fullscreen window below it is not the visible fullscreen surface.
1416
+ $handle = [AiBatteryNative]::GetTopWindow([IntPtr]::Zero)
1417
+ for ($scanned = 0; $scanned -lt 400 -and $handle -ne [IntPtr]::Zero; $scanned += 1) {
1418
+ $next = [AiBatteryNative]::GetWindow($handle, [AiBatteryNative]::GW_HWNDNEXT)
1419
+ # Filter cheapest-first: most windows live on another monitor, so skip the
1420
+ # DWM cloak and class-name lookups until a window is on the HUD monitor.
1421
+ if ((-not (Test-HudWindowHandle $handle)) -and
1422
+ [AiBatteryNative]::IsWindowVisible($handle) -and
1423
+ ([AiBatteryNative]::MonitorFromWindow($handle, [AiBatteryNative]::MONITOR_DEFAULTTONEAREST) -eq $hudMonitor) -and
1424
+ (-not (Test-WindowCloaked $handle)) -and
1425
+ ($script:hudSkipWindowClasses -notcontains (Get-WindowClassName $handle))) {
1426
+ $windowRect = Get-WindowRectObject $handle
1427
+ if ($windowRect -and $windowRect.Width -gt 0 -and $windowRect.Height -gt 0) {
1428
+ if (Test-RectCoversMonitor $windowRect $monitorRect) {
1429
+ return $true
1430
+ }
1431
+ if (Test-RectIsSubstantialOnMonitor $windowRect $monitorRect) {
1432
+ return $false
1433
+ }
1434
+ }
1435
+ }
1436
+ $handle = $next
1437
+ }
1288
1438
 
1289
- $windowRect = Get-WindowRectObject $foreground
1290
- $monitorRect = Get-MonitorRectObject $foregroundMonitor
1291
- return Test-RectCoversMonitor $windowRect $monitorRect
1439
+ return $false
1292
1440
  }
1293
1441
 
1294
1442
  function Set-TaskbarPosition {
@@ -1418,9 +1566,11 @@ function Set-HudHiddenForFullscreen([bool]$Hidden) {
1418
1566
  $script:hudHiddenForFullscreen = $Hidden
1419
1567
  if ($Hidden) {
1420
1568
  if ($hitForm -and -not $hitForm.IsDisposed -and $hitForm.Visible) {
1569
+ [AiBatteryNative]::ShowWindow($hitForm.Handle, [AiBatteryNative]::SW_HIDE) | Out-Null
1421
1570
  $hitForm.Hide()
1422
1571
  }
1423
1572
  if ($form -and -not $form.IsDisposed -and $form.Visible) {
1573
+ [AiBatteryNative]::ShowWindow($form.Handle, [AiBatteryNative]::SW_HIDE) | Out-Null
1424
1574
  $form.Hide()
1425
1575
  }
1426
1576
  return
@@ -1428,21 +1578,95 @@ function Set-HudHiddenForFullscreen([bool]$Hidden) {
1428
1578
 
1429
1579
  if ($form -and -not $form.IsDisposed -and -not $form.Visible) {
1430
1580
  $form.Show()
1581
+ [AiBatteryNative]::ShowWindow($form.Handle, [AiBatteryNative]::SW_SHOWNOACTIVATE) | Out-Null
1431
1582
  }
1432
1583
  Show-HitForm
1433
1584
  Sync-HitFormBounds
1434
1585
  }
1435
1586
 
1587
+ $script:fullscreenClearCount = 0
1588
+
1436
1589
  function Update-HudVisibilityForFullscreen {
1437
1590
  if (-not $form -or $form.IsDisposed) { return }
1438
- if (Test-ForegroundFullscreen) {
1591
+ if (Test-FullscreenOnHudMonitor) {
1592
+ $script:fullscreenClearCount = 0
1439
1593
  Set-HudHiddenForFullscreen $true
1440
1594
  return
1441
1595
  }
1596
+ if ($script:hudHiddenForFullscreen) {
1597
+ $script:fullscreenClearCount += 1
1598
+ if ($script:fullscreenClearCount -lt 2) { return }
1599
+ }
1600
+ $script:fullscreenClearCount = 0
1442
1601
  Set-HudHiddenForFullscreen $false
1443
1602
  Ensure-HudTopMost
1444
1603
  }
1445
1604
 
1605
+ $script:fullscreenCheckPending = $false
1606
+
1607
+ function Request-FullscreenCheck {
1608
+ if (-not $form -or $form.IsDisposed) { return }
1609
+ if ($form.InvokeRequired) {
1610
+ if ($script:fullscreenCheckPending) { return }
1611
+ $script:fullscreenCheckPending = $true
1612
+ try {
1613
+ $form.BeginInvoke([Action]{
1614
+ $script:fullscreenCheckPending = $false
1615
+ Update-HudVisibilityForFullscreen
1616
+ }) | Out-Null
1617
+ } catch {
1618
+ $script:fullscreenCheckPending = $false
1619
+ }
1620
+ return
1621
+ }
1622
+ Update-HudVisibilityForFullscreen
1623
+ }
1624
+
1625
+ $script:fullscreenEventCallback = $null
1626
+ $script:fullscreenEventHooks = @()
1627
+
1628
+ function Start-FullscreenEventHooks {
1629
+ if ($script:fullscreenEventCallback) { return }
1630
+ $script:fullscreenEventCallback = [AiBatteryWinEventDelegate]{
1631
+ param($hook, $eventType, $hwnd, $idObject, $idChild, $eventThread, $eventTime)
1632
+ if ($idObject -ne [AiBatteryNative]::OBJID_WINDOW) { return }
1633
+ Request-FullscreenCheck
1634
+ }
1635
+
1636
+ $events = @(
1637
+ [AiBatteryNative]::EVENT_SYSTEM_FOREGROUND,
1638
+ [AiBatteryNative]::EVENT_OBJECT_SHOW,
1639
+ [AiBatteryNative]::EVENT_OBJECT_HIDE,
1640
+ [AiBatteryNative]::EVENT_OBJECT_REORDER,
1641
+ [AiBatteryNative]::EVENT_OBJECT_LOCATIONCHANGE,
1642
+ [AiBatteryNative]::EVENT_OBJECT_CLOAKED,
1643
+ [AiBatteryNative]::EVENT_OBJECT_UNCLOAKED
1644
+ )
1645
+ $flags = [AiBatteryNative]::WINEVENT_OUTOFCONTEXT -bor [AiBatteryNative]::WINEVENT_SKIPOWNPROCESS
1646
+ foreach ($eventId in $events) {
1647
+ try {
1648
+ $hook = [AiBatteryNative]::SetWinEventHook($eventId, $eventId, [IntPtr]::Zero, $script:fullscreenEventCallback, 0, 0, $flags)
1649
+ if ($hook -ne [IntPtr]::Zero) {
1650
+ $script:fullscreenEventHooks += $hook
1651
+ }
1652
+ } catch {
1653
+ # Polling still covers older or restricted Windows environments.
1654
+ }
1655
+ }
1656
+ }
1657
+
1658
+ function Stop-FullscreenEventHooks {
1659
+ foreach ($hook in $script:fullscreenEventHooks) {
1660
+ try {
1661
+ [AiBatteryNative]::UnhookWinEvent($hook) | Out-Null
1662
+ } catch {
1663
+ # The hook may already be gone during shutdown.
1664
+ }
1665
+ }
1666
+ $script:fullscreenEventHooks = @()
1667
+ $script:fullscreenEventCallback = $null
1668
+ }
1669
+
1446
1670
  function Set-HudBatteryImage($Box, [Nullable[int]]$Percent, [bool]$Running) {
1447
1671
  $oldImage = $Box.Image
1448
1672
  $Box.Image = New-BatteryImage $Percent $Running
@@ -1789,8 +2013,11 @@ function Invoke-HudPump {
1789
2013
  $timer = New-Object System.Windows.Forms.Timer
1790
2014
  $timer.Interval = 1000
1791
2015
  $timer.add_Tick({ Invoke-HudPump })
2016
+ # WinEvent hooks wake fullscreen checks immediately on foreground/Z-order/show
2017
+ # changes. The UI timer is only a fallback for Windows builds or shells that do
2018
+ # not deliver every event we subscribe to.
1792
2019
  $topMostTimer = New-Object System.Windows.Forms.Timer
1793
- $topMostTimer.Interval = 1000
2020
+ $topMostTimer.Interval = 150
1794
2021
  $topMostTimer.add_Tick({ Update-HudVisibilityForFullscreen })
1795
2022
  $form.add_FormClosed({
1796
2023
  if ($canMove) {
@@ -1800,6 +2027,7 @@ $form.add_FormClosed({
1800
2027
  $timer.Dispose()
1801
2028
  $topMostTimer.Stop()
1802
2029
  $topMostTimer.Dispose()
2030
+ Stop-FullscreenEventHooks
1803
2031
  Stop-SnapshotFetch
1804
2032
  foreach ($box in @($codexIconLabel, $claudeIconLabel)) {
1805
2033
  if ($box.Image) {
@@ -1841,14 +2069,13 @@ if ($script:initialHudSnapshot) {
1841
2069
  }
1842
2070
  Update-HudFromSnapshot $script:latestSnapshot
1843
2071
  Start-SnapshotFetch
1844
- if ($Mode -eq "statusline" -or $ClickThrough) {
1845
- $style = [AiBatteryNative]::GetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE)
1846
- $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW -bor [AiBatteryNative]::WS_EX_NOACTIVATE
1847
- if ($ClickThrough) {
1848
- $style = $style -bor [AiBatteryNative]::WS_EX_TRANSPARENT
1849
- }
1850
- [AiBatteryNative]::SetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE, $style) | Out-Null
2072
+ $style = [AiBatteryNative]::GetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE)
2073
+ $style = $style -bor [AiBatteryNative]::WS_EX_TOOLWINDOW -bor [AiBatteryNative]::WS_EX_NOACTIVATE
2074
+ if ($ClickThrough) {
2075
+ $style = $style -bor [AiBatteryNative]::WS_EX_TRANSPARENT
1851
2076
  }
2077
+ [AiBatteryNative]::SetWindowLong($form.Handle, [AiBatteryNative]::GWL_EXSTYLE, $style) | Out-Null
1852
2078
  $timer.Start()
2079
+ Start-FullscreenEventHooks
1853
2080
  $topMostTimer.Start()
1854
2081
  [System.Windows.Forms.Application]::Run($form)