ai-code-switcher 0.1.20__tar.gz → 0.1.22__tar.gz

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.
Files changed (53) hide show
  1. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/PKG-INFO +7 -2
  2. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/README.md +6 -1
  3. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/pyproject.toml +1 -1
  4. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/PKG-INFO +7 -2
  5. ai_code_switcher-0.1.22/src/code_ai/__init__.py +1 -0
  6. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/cli.py +33 -10
  7. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/app.py +8 -2
  8. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/apps.py +0 -8
  9. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/bridge.py +45 -1
  10. ai_code_switcher-0.1.22/src/code_ai/desktop/ui/app.js +375 -0
  11. ai_code_switcher-0.1.22/src/code_ai/desktop/ui/index.html +67 -0
  12. ai_code_switcher-0.1.22/src/code_ai/desktop/ui/style.css +515 -0
  13. ai_code_switcher-0.1.22/tests/test_cli_upgrade.py +123 -0
  14. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_app_import.py +1 -0
  15. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_apps.py +4 -2
  16. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_bridge.py +67 -2
  17. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_windows.py +7 -7
  18. ai_code_switcher-0.1.20/src/code_ai/__init__.py +0 -1
  19. ai_code_switcher-0.1.20/src/code_ai/desktop/ui/app.js +0 -183
  20. ai_code_switcher-0.1.20/src/code_ai/desktop/ui/index.html +0 -50
  21. ai_code_switcher-0.1.20/src/code_ai/desktop/ui/style.css +0 -79
  22. ai_code_switcher-0.1.20/tests/test_cli_upgrade.py +0 -6
  23. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/setup.cfg +0 -0
  24. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/SOURCES.txt +0 -0
  25. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/dependency_links.txt +0 -0
  26. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/entry_points.txt +0 -0
  27. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/requires.txt +0 -0
  28. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/ai_code_switcher.egg-info/top_level.txt +0 -0
  29. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/buddy.py +0 -0
  30. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/config.py +0 -0
  31. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/__init__.py +0 -0
  32. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/config.py +0 -0
  33. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/env.py +0 -0
  34. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/platforms/__init__.py +0 -0
  35. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/platforms/base.py +0 -0
  36. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/platforms/macos.py +0 -0
  37. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/platforms/windows.py +0 -0
  38. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/ui/icon.icns +0 -0
  39. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/ui/icon.ico +0 -0
  40. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/desktop/ui/icon.png +0 -0
  41. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/launcher.py +0 -0
  42. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/models.py +0 -0
  43. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/src/code_ai/profiles.py +0 -0
  44. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_backend.py +0 -0
  45. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_base.py +0 -0
  46. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_cli.py +0 -0
  47. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_config.py +0 -0
  48. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_env.py +0 -0
  49. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_icons.py +0 -0
  50. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_desktop_macos.py +0 -0
  51. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_integration.py +0 -0
  52. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_launcher.py +0 -0
  53. {ai_code_switcher-0.1.20 → ai_code_switcher-0.1.22}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -94,6 +94,11 @@ The supported AI CLIs are:
94
94
  - OpenAI Codex: `@openai/codex`
95
95
  - Grok: `@xai-official/grok`
96
96
 
97
+ `code-ai upgrade` also refreshes the Grok binary in each login profile's
98
+ credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
99
+ updating only the global npm package can leave a login profile running an old
100
+ version. Restart running Grok sessions after upgrading.
101
+
97
102
  ## Profiles and configuration
98
103
 
99
104
  Profiles are stored in `~/.code-ai/config.yaml`. Use API mode when the profile
@@ -169,7 +174,7 @@ code-ai roll-claude-buddy claude-api --type legendary
169
174
  ## Desktop launcher
170
175
 
171
176
  The optional desktop launcher provides a graphical interface for detected
172
- Claude, ChatGPT, and Codex desktop apps. It is supported on Windows and macOS.
177
+ Claude and Codex desktop apps. It is supported on Windows and macOS.
173
178
 
174
179
  ```bash
175
180
  python -m pip install "ai-code-switcher[desktop]"
@@ -81,6 +81,11 @@ The supported AI CLIs are:
81
81
  - OpenAI Codex: `@openai/codex`
82
82
  - Grok: `@xai-official/grok`
83
83
 
84
+ `code-ai upgrade` also refreshes the Grok binary in each login profile's
85
+ credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
86
+ updating only the global npm package can leave a login profile running an old
87
+ version. Restart running Grok sessions after upgrading.
88
+
84
89
  ## Profiles and configuration
85
90
 
86
91
  Profiles are stored in `~/.code-ai/config.yaml`. Use API mode when the profile
@@ -156,7 +161,7 @@ code-ai roll-claude-buddy claude-api --type legendary
156
161
  ## Desktop launcher
157
162
 
158
163
  The optional desktop launcher provides a graphical interface for detected
159
- Claude, ChatGPT, and Codex desktop apps. It is supported on Windows and macOS.
164
+ Claude and Codex desktop apps. It is supported on Windows and macOS.
160
165
 
161
166
  ```bash
162
167
  python -m pip install "ai-code-switcher[desktop]"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-code-switcher"
7
- version = "0.1.20"
7
+ version = "0.1.22"
8
8
  description = "Switch AI coding tool profiles and launch the correct CLI"
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-code-switcher
3
- Version: 0.1.20
3
+ Version: 0.1.22
4
4
  Summary: Switch AI coding tool profiles and launch the correct CLI
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -94,6 +94,11 @@ The supported AI CLIs are:
94
94
  - OpenAI Codex: `@openai/codex`
95
95
  - Grok: `@xai-official/grok`
96
96
 
97
+ `code-ai upgrade` also refreshes the Grok binary in each login profile's
98
+ credentials directory. Grok stores a separate binary under `GROK_HOME/bin`, so
99
+ updating only the global npm package can leave a login profile running an old
100
+ version. Restart running Grok sessions after upgrading.
101
+
97
102
  ## Profiles and configuration
98
103
 
99
104
  Profiles are stored in `~/.code-ai/config.yaml`. Use API mode when the profile
@@ -169,7 +174,7 @@ code-ai roll-claude-buddy claude-api --type legendary
169
174
  ## Desktop launcher
170
175
 
171
176
  The optional desktop launcher provides a graphical interface for detected
172
- Claude, ChatGPT, and Codex desktop apps. It is supported on Windows and macOS.
177
+ Claude and Codex desktop apps. It is supported on Windows and macOS.
173
178
 
174
179
  ```bash
175
180
  python -m pip install "ai-code-switcher[desktop]"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.22"
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import sys
2
3
  import subprocess
3
4
  from typing import Optional, List
@@ -291,19 +292,41 @@ UPGRADE_PACKAGES = [
291
292
  ]
292
293
 
293
294
 
295
+ def _run_npm(args, env):
296
+ # On Windows, npm is a .cmd file, need to use shell=True or npm.cmd
297
+ if sys.platform == "win32":
298
+ return subprocess.run("npm " + " ".join(args), shell=True, env=env)
299
+ return subprocess.run(["npm"] + args, env=env)
300
+
301
+
294
302
  def upgrade():
295
303
  typer.echo("Upgrading claude, codex, grok CLI...")
296
304
 
297
- # On Windows, npm is a .cmd file, need to use shell=True or npm.cmd
298
- if sys.platform == "win32":
299
- # Use shell=True on Windows for better compatibility
300
- cmd = "npm install -g " + " ".join(UPGRADE_PACKAGES)
301
- result = subprocess.run(cmd, shell=True)
302
- else:
303
- result = subprocess.run(
304
- ["npm", "install", "-g"] + UPGRADE_PACKAGES,
305
- )
306
- sys.exit(result.returncode)
305
+ env = os.environ.copy()
306
+ env.pop("GROK_HOME", None)
307
+ result = _run_npm(["install", "-g"] + UPGRADE_PACKAGES, env)
308
+ if result.returncode:
309
+ sys.exit(result.returncode)
310
+
311
+ # Grok's npm launcher prefers GROK_HOME/bin even when that binary is old.
312
+ # Re-run its installer in each login home after updating the global package.
313
+ refreshed = {os.path.normcase(os.path.realpath(os.path.expanduser("~/.grok")))}
314
+ for name, profile in load_config().get("profiles", {}).items():
315
+ if (profile.get("type") != "grok" or profile.get("mode") != "login"
316
+ or not profile.get("credentials_path")):
317
+ continue
318
+ home = os.path.expanduser(profile["credentials_path"])
319
+ normalized_home = os.path.normcase(os.path.realpath(home))
320
+ if normalized_home in refreshed:
321
+ continue
322
+ typer.echo(f"Refreshing Grok CLI for login profile '{name}'...")
323
+ profile_env = dict(env, GROK_HOME=home)
324
+ result = _run_npm(["rebuild", "-g", "@xai-official/grok"], profile_env)
325
+ if result.returncode:
326
+ typer.echo(f"Failed to refresh Grok CLI for profile '{name}'.", err=True)
327
+ sys.exit(result.returncode)
328
+ refreshed.add(normalized_home)
329
+ sys.exit(0)
307
330
 
308
331
 
309
332
  if __name__ == "__main__":
@@ -37,8 +37,14 @@ def run_gui():
37
37
  import webview # lazy: only needed to actually show the GUI
38
38
 
39
39
  bridge = LauncherBridge(backend, APP_REGISTRY)
40
+ # The page fits the window to its content once loaded (bridge.fit_window);
41
+ # this is just a close first guess so the window does not visibly jump.
40
42
  window = webview.create_window(
41
- "AI Launcher", url=_ui_url(), js_api=bridge, width=760, height=560,
43
+ "AI Launcher", url=_ui_url(), js_api=bridge, width=600, height=276,
42
44
  )
43
- bridge._attach_window(window, webview.OPEN_DIALOG)
45
+ try:
46
+ screen_height = int(webview.screens[0].height)
47
+ except Exception: # headless / unknown screen: fit_window skips clamping
48
+ screen_height = None
49
+ bridge._attach_window(window, webview.OPEN_DIALOG, screen_height=screen_height)
44
50
  webview.start(icon=_window_icon_path())
@@ -26,14 +26,6 @@ APP_REGISTRY: List[AppSpec] = [
26
26
  mac_bundle_name="Claude.app",
27
27
  mac_bundle_id="com.anthropic.claudefordesktop",
28
28
  ),
29
- AppSpec(
30
- id="chatgpt",
31
- display="ChatGPT",
32
- win_aumid="OpenAI.ChatGPT-Desktop_2p2nqsd0c76g0!ChatGPT",
33
- win_package_family="OpenAI.ChatGPT-Desktop_2p2nqsd0c76g0",
34
- mac_bundle_name="ChatGPT.app",
35
- mac_bundle_id="com.openai.chat",
36
- ),
37
29
  AppSpec(
38
30
  id="codex",
39
31
  display="Codex",
@@ -13,17 +13,23 @@ class LauncherBridge:
13
13
  native window objects as JS API members.
14
14
  """
15
15
 
16
+ # Keep the window clear of the taskbar/dock when it grows to fit content.
17
+ _SCREEN_MARGIN = 60
18
+ _MIN_HEIGHT = 200
19
+
16
20
  def __init__(self, backend, apps=APP_REGISTRY):
17
21
  self._backend = backend
18
22
  self._apps = apps
19
23
  self._window = None
20
24
  self._open_dialog = 20 # pywebview OPEN_DIALOG; overridden in app.py
25
+ self._screen_height = None
21
26
  self._status = {}
22
27
  self._refresh_detection()
23
28
 
24
- def _attach_window(self, window, open_dialog):
29
+ def _attach_window(self, window, open_dialog, screen_height=None):
25
30
  self._window = window
26
31
  self._open_dialog = open_dialog
32
+ self._screen_height = screen_height
27
33
 
28
34
  # ---- detection cache ----
29
35
  def _refresh_detection(self):
@@ -110,6 +116,44 @@ class LauncherBridge:
110
116
  cfg.save_desktop_config(data)
111
117
  return {"ok": True}
112
118
 
119
+ # ---- window ----
120
+ def fit_window(self, payload):
121
+ """Resize the window so the page content fits without scrolling.
122
+
123
+ The page reports its current viewport height (`inner`) and the height
124
+ its content wants (`wanted`), both in CSS px; the same delta is applied
125
+ to the outer window so the title bar / borders stay out of the maths.
126
+ Growth is clamped to the screen and the window is nudged up when it
127
+ would otherwise run under the taskbar.
128
+ """
129
+ if self._window is None:
130
+ return {"ok": False}
131
+ try:
132
+ inner = int(payload.get("inner", 0))
133
+ wanted = int(payload.get("wanted", 0))
134
+ except (AttributeError, TypeError, ValueError):
135
+ return {"ok": False}
136
+ if inner <= 0 or wanted <= 0:
137
+ return {"ok": False}
138
+ try:
139
+ width, height = self._window.width, self._window.height
140
+ new_height = height + (wanted - inner)
141
+ limit = None
142
+ if self._screen_height:
143
+ limit = int(self._screen_height) - self._SCREEN_MARGIN
144
+ new_height = min(new_height, limit)
145
+ new_height = max(self._MIN_HEIGHT, new_height)
146
+ if abs(new_height - height) <= 1:
147
+ return {"ok": True, "height": height}
148
+ if limit is not None:
149
+ x, y = self._window.x, self._window.y
150
+ if y + new_height > limit:
151
+ self._window.move(x, max(0, limit - new_height))
152
+ self._window.resize(width, new_height)
153
+ except Exception as exc:
154
+ return {"ok": False, "error": str(exc)}
155
+ return {"ok": True, "height": new_height}
156
+
113
157
  def pick_app_path(self, app_id):
114
158
  if self._window is None:
115
159
  return {"ok": False}
@@ -0,0 +1,375 @@
1
+ const api = () => window.pywebview.api;
2
+ let APPS = [];
3
+ let SETTINGS = { check_system_proxy: true, env_vars: {} };
4
+ const APP_SETTINGS = {}; // id -> { env_vars, path }
5
+ let READY = false;
6
+ let lastRefresh = 0;
7
+
8
+ const GEAR_SVG =
9
+ `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">` +
10
+ `<path fill="currentColor" d="M10.3 2.3h3.4l.4 2.2c.6.2 1.2.5 1.7.9l2.1-.8 1.7 2.9-1.7 1.4c.1.6.2 1.2.2 1.8s-.1 1.2-.2 1.8l1.7 1.4-1.7 2.9-2.1-.8c-.5.4-1.1.7-1.7.9l-.4 2.2h-3.4l-.4-2.2a7 7 0 0 1-1.7-.9l-2.1.8-1.7-2.9 1.7-1.4a7 7 0 0 1-.2-1.8c0-.6.1-1.2.2-1.8L4.4 7.5 6.1 4.6l2.1.8c.5-.4 1.1-.7 1.7-.9l.4-2.2ZM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/>` +
11
+ `</svg>`;
12
+
13
+ function esc(s) {
14
+ return String(s)
15
+ .replace(/&/g, "&amp;")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#x27;");
20
+ }
21
+
22
+ function findApp(id) { return APPS.find((x) => x.id === id); }
23
+
24
+ async function init() {
25
+ APPS = await api().list_apps();
26
+ renderApps();
27
+ await Promise.all([loadSettings(), ...APPS.map((a) => loadAppSettings(a.id))]);
28
+ READY = true;
29
+ lastRefresh = Date.now();
30
+ fitWindow();
31
+ }
32
+
33
+ // ---- window fit ----
34
+ // The window is a palette: it is always exactly as tall as its content. The
35
+ // page measures what it needs and the bridge applies the delta to the window.
36
+ function contentHeight() {
37
+ let h = document.getElementById("foot").offsetHeight;
38
+ for (const bay of document.querySelectorAll(".bay")) h += bay.offsetHeight;
39
+ return h;
40
+ }
41
+
42
+ function fitWindow(extra = 0) {
43
+ if (!window.pywebview || !api().fit_window) return;
44
+ const wanted = Math.ceil(contentHeight() + extra);
45
+ api().fit_window({ inner: window.innerHeight, wanted });
46
+ }
47
+
48
+ // ---- app bays ----
49
+ function renderApps() {
50
+ const list = document.getElementById("apps");
51
+ list.innerHTML = "";
52
+ for (const a of APPS) {
53
+ const id = esc(a.id);
54
+ const name = esc(a.display);
55
+ const bay = document.createElement("section");
56
+ bay.className = "bay bay--app";
57
+ bay.id = `bay-${id}`;
58
+ bay.innerHTML =
59
+ `<div class="bay-row">` +
60
+ `<div class="bay-lamp" aria-hidden="true"><span class="lamp"></span></div>` +
61
+ `<div class="bay-copy">` +
62
+ `<h2 class="bay-name">${name}</h2>` +
63
+ `<p class="bay-status" id="status-${id}"></p>` +
64
+ `</div>` +
65
+ `<div class="bay-actions">` +
66
+ `<button class="btn btn--key" type="button" id="btn-launch-${id}" data-mode="launch">启动</button>` +
67
+ `<button class="btn btn--stop" type="button" id="btn-stop-${id}" data-mode="stop">中止</button>` +
68
+ `<button class="btn btn--key" type="button" id="btn-config-${id}" data-mode="config">选择路径…</button>` +
69
+ `<button class="gear" type="button" id="gear-${id}" aria-expanded="false" ` +
70
+ `aria-controls="editor-${id}" aria-label="${name} 专有设置" title="${name} 专有设置">${GEAR_SVG}</button>` +
71
+ `</div>` +
72
+ `</div>` +
73
+ `<div class="editor" id="editor-${id}" inert>` +
74
+ `<div class="editor-inner"><div class="editor-body">` +
75
+ `<div class="field">` +
76
+ `<div class="field-head"><span class="field-label">应用路径</span>` +
77
+ `<span class="field-hint">默认自动检测,也可以手动指定可执行文件</span></div>` +
78
+ `<div class="path-row"><code class="path-value" id="path-${id}"></code>` +
79
+ `<button class="btn btn--small" type="button" id="pick-${id}">选择…</button></div>` +
80
+ `</div>` +
81
+ `<div class="field">` +
82
+ `<div class="field-head"><span class="field-label">专有环境变量</span>` +
83
+ `<span class="field-hint">只在启动 ${name} 时注入,与通用变量同名时以这里为准</span></div>` +
84
+ `<div class="env-list" id="env-${id}"></div>` +
85
+ `<button class="add-row" type="button" id="add-${id}">添加变量</button>` +
86
+ `</div>` +
87
+ `<div class="editor-actions">` +
88
+ `<button class="btn" type="button" id="cancel-${id}">取消</button>` +
89
+ `<button class="btn btn--solid" type="button" id="save-${id}">保存</button>` +
90
+ `</div>` +
91
+ `</div></div>` +
92
+ `</div>`;
93
+ list.appendChild(bay);
94
+ updateBay(a.id, a.found, a.running);
95
+ document.getElementById(`btn-launch-${a.id}`).onclick = () => onAction(a.id, "launch");
96
+ document.getElementById(`btn-stop-${a.id}`).onclick = () => onAction(a.id, "stop");
97
+ document.getElementById(`btn-config-${a.id}`).onclick = () => onAction(a.id, "config");
98
+ document.getElementById(`gear-${a.id}`).onclick = () => toggleEditor(a.id);
99
+ document.getElementById(`pick-${a.id}`).onclick = () => onAction(a.id, "config");
100
+ document.getElementById(`add-${a.id}`).onclick = () => addEnvRow(`env-${a.id}`);
101
+ document.getElementById(`cancel-${a.id}`).onclick = () => setEditorOpen(a.id, false);
102
+ document.getElementById(`save-${a.id}`).onclick = () => saveAppSettings(a.id);
103
+ }
104
+ }
105
+
106
+ const BAY_ERRORS = {}; // id -> { msg, timer }
107
+
108
+ // A failed launch/stop is reported in the bay's own status line, next to the
109
+ // key that was pressed, and clears itself after a few seconds.
110
+ function setBayError(id, msg) {
111
+ const cur = BAY_ERRORS[id];
112
+ if (cur) clearTimeout(cur.timer);
113
+ BAY_ERRORS[id] = msg ? {
114
+ msg,
115
+ timer: setTimeout(() => { delete BAY_ERRORS[id]; rerenderBay(id); }, 6000),
116
+ } : undefined;
117
+ rerenderBay(id);
118
+ }
119
+
120
+ function rerenderBay(id) {
121
+ const a = findApp(id);
122
+ if (a) updateBay(id, a.found, a.running);
123
+ }
124
+
125
+ function statusText(a) {
126
+ const s = APP_SETTINGS[a.id];
127
+ const n = s ? Object.keys(s.env_vars || {}).length : 0;
128
+ const vars = n ? ` · ${n} 个专有环境变量` : "";
129
+ if (!a.found) return "未检测到安装,选择应用路径后即可启动";
130
+ if (a.running) return "运行中" + vars;
131
+ return "未运行" + vars;
132
+ }
133
+
134
+ function updateBay(id, found, running) {
135
+ const a = findApp(id);
136
+ if (a) { a.found = found; a.running = running; }
137
+ const bay = document.getElementById(`bay-${id}`);
138
+ const status = document.getElementById(`status-${id}`);
139
+ const launch = document.getElementById(`btn-launch-${id}`);
140
+ const stop = document.getElementById(`btn-stop-${id}`);
141
+ const config = document.getElementById(`btn-config-${id}`);
142
+ if (!bay || !status || !launch || !stop || !config) return;
143
+ const err = BAY_ERRORS[id];
144
+ bay.classList.toggle("is-missing", !found);
145
+ bay.classList.toggle("is-running", !!(found && running));
146
+ bay.classList.toggle("has-error", !!err);
147
+ status.textContent = err ? err.msg : statusText(a || { id, found, running });
148
+ // Status can be stale (no background poll), so both keys stay live once the
149
+ // app is found; launch always stops-then-starts, hence "重启" while running.
150
+ launch.hidden = !found;
151
+ stop.hidden = !found;
152
+ config.hidden = found;
153
+ launch.textContent = running ? "重启" : "启动";
154
+ }
155
+
156
+ async function onAction(id, mode) {
157
+ if (mode === "config") {
158
+ const r = await api().pick_app_path(id);
159
+ if (r && r.ok) {
160
+ if (APP_SETTINGS[id]) APP_SETTINGS[id].path = r.path;
161
+ renderPath(id);
162
+ fitWindow(); // a long path may wrap
163
+ await refresh();
164
+ }
165
+ return;
166
+ }
167
+ let r = { ok: true };
168
+ if (mode === "launch") r = await api().launch_app(id);
169
+ else if (mode === "stop") r = await api().stop_app(id);
170
+ await refresh();
171
+ setBayError(id, r.ok ? "" : (r.error || "操作失败"));
172
+ }
173
+
174
+ async function refresh() {
175
+ lastRefresh = Date.now();
176
+ APPS = await api().list_apps();
177
+ for (const a of APPS) updateBay(a.id, a.found, a.running);
178
+ }
179
+
180
+ // No background status push (see bridge history); re-check when the window
181
+ // comes back to the front instead, which is when a stale lamp would be noticed.
182
+ function refreshOnReturn() {
183
+ if (!READY || document.hidden) return;
184
+ if (Date.now() - lastRefresh < 1500) return;
185
+ refresh();
186
+ }
187
+
188
+ // Confirmations surface in the footer status line rather than an overlay.
189
+ function toast(msg) {
190
+ const foot = document.getElementById("foot");
191
+ document.getElementById("toast").textContent = msg;
192
+ foot.classList.add("has-msg");
193
+ clearTimeout(toast._timer);
194
+ toast._timer = setTimeout(() => foot.classList.remove("has-msg"), 2200);
195
+ }
196
+
197
+ // ---- inline editors ----
198
+ function setEditorOpen(id, open) {
199
+ const editor = document.getElementById(`editor-${id}`);
200
+ const gear = document.getElementById(`gear-${id}`);
201
+ if (!editor || !gear) return;
202
+ if (open) {
203
+ // Re-render from the last saved state so a reopened editor never shows
204
+ // edits that were cancelled.
205
+ if (id === "common") renderCommon(); else renderAppEditor(id);
206
+ // Grow the window before the editor unfolds into the new space; the
207
+ // collapsed editor's scrollHeight is exactly the height it is about to take.
208
+ fitWindow(editor.firstElementChild.scrollHeight);
209
+ }
210
+ editor.classList.toggle("is-open", open);
211
+ editor.inert = !open;
212
+ gear.classList.toggle("is-active", open);
213
+ gear.setAttribute("aria-expanded", open ? "true" : "false");
214
+ if (open) {
215
+ // If the screen capped the window, keep the bay being edited in view.
216
+ const bay = editor.closest(".bay");
217
+ setTimeout(() => {
218
+ const panel = bay.closest(".panel");
219
+ const block = bay.offsetHeight > panel.clientHeight ? "start" : "nearest";
220
+ bay.scrollIntoView({ block, behavior: "smooth" });
221
+ }, 300);
222
+ } else {
223
+ // Shrink back once the fold animation has finished.
224
+ setTimeout(fitWindow, 300);
225
+ }
226
+ }
227
+
228
+ function toggleEditor(id) {
229
+ const editor = document.getElementById(`editor-${id}`);
230
+ setEditorOpen(id, !(editor && editor.classList.contains("is-open")));
231
+ }
232
+
233
+ function closeAllEditors() {
234
+ for (const ed of document.querySelectorAll(".editor.is-open")) {
235
+ setEditorOpen(ed.id.replace(/^editor-/, ""), false);
236
+ }
237
+ }
238
+
239
+ // ---- env rows ----
240
+ function renderEnv(containerId, vars) {
241
+ const c = document.getElementById(containerId);
242
+ c.innerHTML = "";
243
+ for (const [k, v] of Object.entries(vars || {})) addEnvRow(containerId, k, v, false);
244
+ }
245
+
246
+ function addEnvRow(containerId, k = "", v = "", focus = true) {
247
+ const c = document.getElementById(containerId);
248
+ const row = document.createElement("div");
249
+ row.className = "env-row";
250
+ row.innerHTML =
251
+ `<input class="k" placeholder="KEY" spellcheck="false" autocomplete="off" value="${esc(k)}">` +
252
+ `<span class="eq" aria-hidden="true">=</span>` +
253
+ `<input class="v" placeholder="VALUE" spellcheck="false" autocomplete="off" value="${esc(v)}">` +
254
+ `<button class="del" type="button" aria-label="删除这个变量" title="删除">` +
255
+ `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">` +
256
+ `<path fill="currentColor" d="M6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4 17.6 5 12 10.6 6.4 5Z"/>` +
257
+ `</svg></button>`;
258
+ row.querySelector(".del").onclick = () => { row.remove(); fitWindow(); };
259
+ c.appendChild(row);
260
+ if (focus) {
261
+ fitWindow();
262
+ if (!k) row.querySelector(".k").focus();
263
+ }
264
+ }
265
+
266
+ function collectEnv(containerId) {
267
+ const out = {};
268
+ for (const row of document.querySelectorAll(`#${containerId} .env-row`)) {
269
+ const k = row.querySelector(".k").value.trim();
270
+ const v = row.querySelector(".v").value;
271
+ if (k) out[k] = v;
272
+ }
273
+ return out;
274
+ }
275
+
276
+ // ---- common settings ----
277
+ async function loadSettings() {
278
+ SETTINGS = await api().get_settings();
279
+ renderCommon();
280
+ }
281
+
282
+ function renderCommon() {
283
+ document.getElementById("proxy").checked = !!SETTINGS.check_system_proxy;
284
+ renderEnv("common-env", SETTINGS.env_vars);
285
+ const n = Object.keys(SETTINGS.env_vars || {}).length;
286
+ document.getElementById("common-summary").textContent =
287
+ (SETTINGS.check_system_proxy ? "启动前检查系统代理" : "不检查系统代理") +
288
+ " · " + (n ? `${n} 个通用环境变量` : "没有通用环境变量");
289
+ }
290
+
291
+ async function saveSettings() {
292
+ const payload = {
293
+ check_system_proxy: document.getElementById("proxy").checked,
294
+ env_vars: collectEnv("common-env"),
295
+ };
296
+ await api().save_settings(payload);
297
+ SETTINGS = payload;
298
+ setEditorOpen("common", false);
299
+ renderCommon();
300
+ toast("已保存");
301
+ }
302
+
303
+ // ---- per-app settings ----
304
+ async function loadAppSettings(id) {
305
+ APP_SETTINGS[id] = await api().get_app_settings(id);
306
+ renderAppEditor(id);
307
+ rerenderBay(id);
308
+ }
309
+
310
+ function renderPath(id) {
311
+ const el = document.getElementById(`path-${id}`);
312
+ const s = APP_SETTINGS[id] || {};
313
+ el.textContent = s.path || "自动检测";
314
+ el.classList.toggle("is-auto", !s.path);
315
+ }
316
+
317
+ function renderAppEditor(id) {
318
+ const s = APP_SETTINGS[id] || { env_vars: {}, path: "" };
319
+ renderPath(id);
320
+ renderEnv(`env-${id}`, s.env_vars);
321
+ }
322
+
323
+ async function saveAppSettings(id) {
324
+ const env_vars = collectEnv(`env-${id}`);
325
+ await api().save_app_settings(id, { env_vars });
326
+ APP_SETTINGS[id] = { ...(APP_SETTINGS[id] || {}), env_vars };
327
+ setEditorOpen(id, false);
328
+ rerenderBay(id);
329
+ toast("已保存");
330
+ }
331
+
332
+ // ---- wiring ----
333
+ document.addEventListener("DOMContentLoaded", () => {
334
+ document.getElementById("gear-common").onclick = () => toggleEditor("common");
335
+ document.getElementById("cancel-common").onclick = () => setEditorOpen("common", false);
336
+ document.getElementById("add-common").onclick = () => addEnvRow("common-env");
337
+ document.getElementById("save-settings").onclick = saveSettings;
338
+ document.addEventListener("keydown", (e) => {
339
+ if (e.key === "Escape") closeAllEditors();
340
+ });
341
+ window.addEventListener("focus", refreshOnReturn);
342
+ document.addEventListener("visibilitychange", refreshOnReturn);
343
+ });
344
+
345
+ window.addEventListener("pywebviewready", init);
346
+
347
+ if (new URLSearchParams(location.search).has("preview")) {
348
+ document.documentElement.classList.add("preview");
349
+ APPS = [
350
+ { id: "claude", display: "Claude", found: true, running: true },
351
+ { id: "codex", display: "Codex", found: false, running: false },
352
+ ];
353
+ const appSettings = {
354
+ claude: { env_vars: { ANTHROPIC_BASE_URL: "https://api.example.com" }, path: "" },
355
+ codex: { env_vars: {}, path: "" },
356
+ };
357
+ window.pywebview = {
358
+ api: {
359
+ list_apps: async () => APPS,
360
+ get_settings: async () => ({
361
+ check_system_proxy: true,
362
+ env_vars: { HTTP_PROXY: "http://127.0.0.1:7890", HTTPS_PROXY: "http://127.0.0.1:7890" },
363
+ }),
364
+ save_settings: async () => ({ ok: true }),
365
+ get_app_settings: async (id) => appSettings[id],
366
+ save_app_settings: async () => ({ ok: true }),
367
+ pick_app_path: async () => ({ ok: false }),
368
+ fit_window: async () => ({ ok: true }),
369
+ launch_app: async (id) =>
370
+ id === "claude" ? { ok: true } : { ok: false, error: "系统代理未开启,已取消启动" },
371
+ stop_app: async () => ({ ok: true }),
372
+ },
373
+ };
374
+ document.addEventListener("DOMContentLoaded", init);
375
+ }