pi-sdk-web 0.3.12 → 0.4.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,451 @@
1
+ """
2
+ pi-web server
3
+
4
+ Bridges a browser (via WebSocket) to a Pi RPC subprocess.
5
+
6
+ Run standalone:
7
+ python3 server.py <session_id> <cwd> [--port <port>]
8
+
9
+ The pii script will eventually invoke this server after resolving the session
10
+ name to a session id + cwd.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import logging
18
+ import os
19
+ import socket
20
+ import threading
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ logging.basicConfig(level=logging.DEBUG)
26
+
27
+ from rpc_client import RpcClient, RpcCommands
28
+ from websocket import WebSocketConnection, WebSocketServer
29
+
30
+ DEFAULT_PORT = 4080
31
+ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
32
+
33
+
34
+ class Peripheral:
35
+ """Holds the Pi RPC client and manages WebSocket clients."""
36
+
37
+ def __init__(self, session_id: str, cwd: str):
38
+ self.session_id = session_id
39
+ self.cwd = cwd
40
+ self.client = RpcClient(session_id, cwd, on_event=self._on_rpc_event, on_exit=self._on_rpc_exit)
41
+ self.commands = RpcCommands(self.client)
42
+ self._clients: set[WebSocketConnection] = set()
43
+ self._clients_lock = threading.Lock()
44
+ self._exit_error: Optional[str] = None
45
+ self._version = self._get_pi_version()
46
+
47
+ # ------------------------------------------------------------------
48
+ # Lifecycle
49
+ # ------------------------------------------------------------------
50
+
51
+ def start(self) -> None:
52
+ self.client.start()
53
+ self.client.wait_ready()
54
+
55
+ def stop(self) -> None:
56
+ self.client.stop()
57
+
58
+ # ------------------------------------------------------------------
59
+ # WebSocket client management
60
+ # ------------------------------------------------------------------
61
+
62
+ def add_client(self, conn: WebSocketConnection) -> None:
63
+ with self._clients_lock:
64
+ self._clients.add(conn)
65
+ # Send initial state to the new client
66
+ self._send_initial_state(conn)
67
+
68
+ def remove_client(self, conn: WebSocketConnection) -> None:
69
+ with self._clients_lock:
70
+ self._clients.discard(conn)
71
+
72
+ def _broadcast(self, obj: object) -> None:
73
+ message = json.dumps(obj, ensure_ascii=False)
74
+ dead: list[WebSocketConnection] = []
75
+ with self._clients_lock:
76
+ for conn in list(self._clients):
77
+ try:
78
+ conn.send_text(message)
79
+ except Exception:
80
+ dead.append(conn)
81
+ for conn in dead:
82
+ self.remove_client(conn)
83
+
84
+ # ------------------------------------------------------------------
85
+ # RPC event -> broadcast
86
+ # ------------------------------------------------------------------
87
+
88
+ # Events after which footer stats should be refreshed (TUI does this too)
89
+ _STATS_REFRESH_EVENTS = {
90
+ "agent_settled",
91
+ "turn_end",
92
+ "tool_execution_end",
93
+ "compaction_end",
94
+ "entry_appended",
95
+ "session_info_changed",
96
+ "thinking_level_changed",
97
+ }
98
+
99
+ def _on_rpc_event(self, data: dict) -> None:
100
+ self._broadcast(data)
101
+ if data.get("type") in self._STATS_REFRESH_EVENTS:
102
+ self._schedule_stats_refresh()
103
+
104
+ def _schedule_stats_refresh(self) -> None:
105
+ """Fetch and broadcast latest session stats in a background thread.
106
+
107
+ This must not run in the RPC read-loop thread, because get_session_stats
108
+ is a synchronous command that would deadlock if issued from there.
109
+ """
110
+ def run() -> None:
111
+ try:
112
+ stats = self.commands.get_session_stats().get("data", {})
113
+ self._broadcast({"type": "stats", "data": stats})
114
+ except Exception:
115
+ pass
116
+ threading.Thread(target=run, daemon=True).start()
117
+
118
+ def _on_rpc_exit(self, code: int | None) -> None:
119
+ logging.warning("Pi RPC process exited with code=%s stderr=%s", code, self.client._stderr_lines[-5:])
120
+ self._exit_error = f"Pi RPC process exited (code={code})"
121
+ self._broadcast({"type": "pi_error", "error": self._exit_error})
122
+
123
+ # ------------------------------------------------------------------
124
+ # Initial state for new clients
125
+ # ------------------------------------------------------------------
126
+
127
+ def _send_initial_state(self, conn: WebSocketConnection) -> None:
128
+ """Send history + state to a newly connected browser client."""
129
+ try:
130
+ conn.send_json({"type": "state", "data": self._build_state()})
131
+ self._send_history(conn)
132
+ except RuntimeError as e:
133
+ conn.send_json({"type": "error", "error": str(e)})
134
+
135
+ def _build_state(self) -> dict:
136
+ """Build the full session state dict (model/thinking/stats/version/...)."""
137
+ state = self.commands.get_state()
138
+ data = state.get("data", {})
139
+ data["cwd"] = self._format_cwd_for_footer(self.cwd)
140
+ data["gitBranch"] = self._get_git_branch()
141
+ data["sessionStats"] = self.commands.get_session_stats().get("data", {})
142
+ data["version"] = self._version
143
+ data["commands"] = self.commands.get_commands()
144
+ return data
145
+
146
+ def _broadcast_state(self) -> None:
147
+ """Broadcast the full state after mutations (model/thinking/session name)."""
148
+ try:
149
+ self._broadcast({"type": "state", "data": self._build_state()})
150
+ except Exception:
151
+ pass
152
+
153
+ def _get_pi_version(self) -> str:
154
+ """Get the installed pi version once."""
155
+ try:
156
+ import subprocess
157
+
158
+ result = subprocess.run(
159
+ ["pi", "--version"],
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=5,
163
+ )
164
+ if result.returncode == 0:
165
+ return result.stdout.strip()
166
+ except Exception:
167
+ pass
168
+ return ""
169
+
170
+ def _format_cwd_for_footer(self, cwd: str) -> str:
171
+ """Show home directory as ~ like the TUI footer."""
172
+ home = os.path.expanduser("~")
173
+ try:
174
+ rel = os.path.relpath(cwd, home)
175
+ if rel == ".":
176
+ return "~"
177
+ if not rel.startswith(".."):
178
+ return f"~/{rel}"
179
+ except Exception:
180
+ pass
181
+ return cwd
182
+
183
+ def _get_git_branch(self) -> str | None:
184
+ """Return the current git branch of the session cwd, if any."""
185
+ try:
186
+ import subprocess
187
+
188
+ result = subprocess.run(
189
+ ["git", "branch", "--show-current"],
190
+ cwd=self.cwd,
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=3,
194
+ )
195
+ if result.returncode == 0:
196
+ branch = result.stdout.strip()
197
+ return branch or None
198
+ except Exception:
199
+ pass
200
+ return None
201
+
202
+ def _send_history(self, conn: WebSocketConnection) -> None:
203
+ try:
204
+ entries = self.commands.get_entries()
205
+ conn.send_json({"type": "history", "data": entries})
206
+ except RuntimeError as e:
207
+ conn.send_json({"type": "error", "error": str(e)})
208
+
209
+ # ------------------------------------------------------------------
210
+ # Handle incoming WebSocket messages (browser -> Pi)
211
+ # ------------------------------------------------------------------
212
+
213
+ def handle_client_message(self, conn: WebSocketConnection, raw: str) -> None:
214
+ logging.debug("WS message: %s", raw[:200])
215
+ try:
216
+ data = json.loads(raw)
217
+ except json.JSONDecodeError:
218
+ conn.send_json({"type": "error", "error": "Invalid JSON"})
219
+ return
220
+
221
+ cmd_type = data.get("type")
222
+ if not cmd_type:
223
+ conn.send_json({"type": "error", "error": "Missing 'type'"})
224
+ return
225
+
226
+ try:
227
+ self._dispatch_command(cmd_type, data)
228
+ except RuntimeError as e:
229
+ conn.send_json({"type": "error", "error": str(e)})
230
+
231
+ def _dispatch_command(self, cmd_type: str, data: dict) -> None:
232
+ if cmd_type == "prompt":
233
+ message = data.get("message", "")
234
+ if not message:
235
+ raise RuntimeError("Missing 'message'")
236
+ self.commands.prompt(message)
237
+ elif cmd_type == "abort":
238
+ self.commands.abort()
239
+ elif cmd_type == "get_state":
240
+ pass # broadcast via client init; ignore
241
+ elif cmd_type == "get_stats":
242
+ stats = self.commands.get_session_stats().get("data", {})
243
+ self._broadcast({"type": "stats", "data": stats})
244
+ elif cmd_type == "bash":
245
+ command = data.get("command", "")
246
+ if not command:
247
+ raise RuntimeError("Missing 'command'")
248
+ result = self.commands.bash(command)
249
+ self._broadcast({"type": "bash_result", "command": command, "data": result})
250
+ elif cmd_type == "cycle_model":
251
+ self.commands.cycle_model()
252
+ self._broadcast_state()
253
+ elif cmd_type == "set_model":
254
+ provider = data.get("provider")
255
+ model_id = data.get("modelId")
256
+ if not provider or not model_id:
257
+ raise RuntimeError("Missing 'provider' or 'modelId'")
258
+ self.commands.set_model(provider, model_id)
259
+ self._broadcast_state()
260
+ elif cmd_type == "get_available_models":
261
+ models = self.commands.get_available_models()
262
+ self._broadcast({"type": "models", "data": models})
263
+ elif cmd_type == "cycle_thinking_level":
264
+ self.commands.cycle_thinking_level()
265
+ self._broadcast_state()
266
+ elif cmd_type == "set_thinking_level":
267
+ level = data.get("level")
268
+ if not level:
269
+ raise RuntimeError("Missing 'level'")
270
+ self.commands.set_thinking_level(level)
271
+ self._broadcast_state()
272
+ elif cmd_type == "get_available_thinking_levels":
273
+ levels = self.commands.get_available_thinking_levels()
274
+ self._broadcast({"type": "thinking_levels", "data": levels})
275
+ elif cmd_type == "compact":
276
+ custom_instructions = data.get("customInstructions")
277
+ self.commands.compact(custom_instructions)
278
+ elif cmd_type == "set_session_name":
279
+ name = data.get("name", "")
280
+ if not name:
281
+ raise RuntimeError("Missing 'name'")
282
+ self.commands.set_session_name(name)
283
+ self._broadcast_state()
284
+ elif cmd_type == "extension_ui_response":
285
+ response_id = data.get("id")
286
+ if not response_id:
287
+ raise RuntimeError("Missing 'id'")
288
+ # Pass through all remaining fields (value/confirmed/cancelled)
289
+ extra = {k: v for k, v in data.items() if k not in ("type", "id")}
290
+ self.commands.extension_ui_response(response_id, **extra)
291
+ else:
292
+ raise RuntimeError(f"Unsupported command: {cmd_type}")
293
+
294
+
295
+ class ServerContext:
296
+ """Shared state accessible from HTTP request handlers."""
297
+
298
+ def __init__(self, peripheral: Peripheral):
299
+ self.peripheral = peripheral
300
+ self.ws = WebSocketServer(self._on_ws_connection)
301
+
302
+ def _on_ws_connection(self, conn: WebSocketConnection, request_headers: dict) -> None:
303
+ pass
304
+
305
+
306
+ class HTTPHandler(BaseHTTPRequestHandler):
307
+ """Serves static files and upgrades WebSocket connections."""
308
+
309
+ context: ServerContext = None # type: ignore
310
+
311
+ def log_message(self, format: str, *args) -> None:
312
+ # Quiet by default
313
+ pass
314
+
315
+ def finish(self) -> None:
316
+ # For WebSocket upgrades we hand the raw socket to a WebSocketConnection
317
+ # and must NOT let BaseHTTPRequestHandler close it.
318
+ if getattr(self, "_ws_upgraded", False):
319
+ return
320
+ super().finish()
321
+
322
+ def _send_file(self, path: Path, content_type: str) -> None:
323
+ try:
324
+ data = path.read_bytes()
325
+ self.send_response(200)
326
+ self.send_header("Content-Type", content_type)
327
+ self.send_header("Content-Length", str(len(data)))
328
+ self.end_headers()
329
+ self.wfile.write(data)
330
+ except OSError:
331
+ self.send_error(404)
332
+
333
+ def do_GET(self) -> None: # noqa: N802
334
+ # WebSocket upgrade
335
+ if self.headers.get("Upgrade", "").lower() == "websocket":
336
+ self._handle_ws_upgrade()
337
+ return
338
+
339
+ path = self.path.split("?")[0]
340
+ if path == "/":
341
+ path = "/index.html"
342
+
343
+ # Resolve static path safely
344
+ rel = path.lstrip("/")
345
+ file_path = (STATIC_DIR / rel).resolve()
346
+ if not str(file_path).startswith(str(STATIC_DIR.resolve())):
347
+ self.send_error(403)
348
+ return
349
+
350
+ if not file_path.exists():
351
+ self.send_error(404)
352
+ return
353
+
354
+ ext = file_path.suffix.lower()
355
+ content_type = {
356
+ ".html": "text/html; charset=utf-8",
357
+ ".css": "text/css; charset=utf-8",
358
+ ".js": "application/javascript; charset=utf-8",
359
+ ".json": "application/json; charset=utf-8",
360
+ ".svg": "image/svg+xml",
361
+ ".png": "image/png",
362
+ ".jpg": "image/jpeg",
363
+ ".ico": "image/x-icon",
364
+ }.get(ext, "application/octet-stream")
365
+ self._send_file(file_path, content_type)
366
+
367
+ def _handle_ws_upgrade(self) -> None:
368
+ ctx = self.server.ws_context
369
+ key = self.headers.get("Sec-WebSocket-Key")
370
+
371
+ import base64
372
+ import hashlib
373
+
374
+ accept = base64.b64encode(
375
+ hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
376
+ ).decode()
377
+
378
+ self.send_response(101)
379
+ self.send_header("Upgrade", "websocket")
380
+ self.send_header("Connection", "Upgrade")
381
+ self.send_header("Sec-WebSocket-Accept", accept)
382
+ self.end_headers()
383
+
384
+ # Take over the socket. We detach the underlying fd so that when the
385
+ # HTTP handler/server finishes it cannot close the live WebSocket.
386
+ fd = self.connection.detach()
387
+ sock = socket.socket(fileno=fd)
388
+ self._ws_upgraded = True
389
+ self.close_connection = True
390
+
391
+ peripheral = ctx.peripheral
392
+
393
+ def on_message(msg: str) -> None:
394
+ peripheral.handle_client_message(conn, msg)
395
+
396
+ def on_close() -> None:
397
+ peripheral.remove_client(conn)
398
+
399
+ conn = WebSocketConnection(sock, on_message, on_close)
400
+ ctx.clients.append(conn)
401
+ peripheral.add_client(conn)
402
+ conn.start_reading()
403
+
404
+
405
+ class PiWebHTTPServer(ThreadingHTTPServer):
406
+ daemon_threads = True
407
+
408
+ def __init__(self, addr: tuple, handler_cls, peripheral: Peripheral):
409
+ super().__init__(addr, handler_cls)
410
+ self.ws_context = ServerContext(peripheral)
411
+ self.ws_context.clients = []
412
+ handler_cls.context = self.ws_context
413
+
414
+
415
+ def main() -> None:
416
+ parser = argparse.ArgumentParser(description="pi-web server")
417
+ parser.add_argument("session_id", help="Pi session id")
418
+ parser.add_argument("cwd", help="Session working directory")
419
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind (default: 4080)")
420
+ args = parser.parse_args()
421
+
422
+ if not (1 <= args.port <= 65535):
423
+ print(f"Invalid port: {args.port}", file=os.sys.stderr)
424
+ os.sys.exit(1)
425
+
426
+ if not os.path.isdir(args.cwd):
427
+ print(f"Working directory not found: {args.cwd}", file=os.sys.stderr)
428
+ os.sys.exit(1)
429
+
430
+ peripheral = Peripheral(args.session_id, args.cwd)
431
+
432
+ try:
433
+ httpd = PiWebHTTPServer(("127.0.0.1", args.port), HTTPHandler, peripheral)
434
+ except OSError as e:
435
+ print(f"Failed to bind 127.0.0.1:{args.port}: {e}", file=os.sys.stderr)
436
+ os.sys.exit(1)
437
+
438
+ peripheral.start()
439
+ print(f"server at http://127.0.0.1:{args.port}/", flush=True)
440
+
441
+ try:
442
+ httpd.serve_forever()
443
+ except KeyboardInterrupt:
444
+ pass
445
+ finally:
446
+ peripheral.stop()
447
+ httpd.server_close()
448
+
449
+
450
+ if __name__ == "__main__":
451
+ main()
@@ -0,0 +1,204 @@
1
+ """
2
+ Simple WebSocket server implementation using only the Python standard library.
3
+
4
+ Supports:
5
+ - HTTP handshake (RFC 6455)
6
+ - Text and binary frames
7
+ - Ping/Pong keepalive
8
+ - Fragmented message reassembly
9
+ - Client close handling
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import socket
17
+ import struct
18
+ import threading
19
+ from typing import Callable, Optional
20
+
21
+ MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
22
+
23
+ OP_CONT = 0x0
24
+ OP_TEXT = 0x1
25
+ OP_BINARY = 0x2
26
+ OP_CLOSE = 0x8
27
+ OP_PING = 0x9
28
+ OP_PONG = 0xA
29
+
30
+
31
+ class WebSocketError(Exception):
32
+ pass
33
+
34
+
35
+ class WebSocketConnection:
36
+ """A single WebSocket client connection."""
37
+
38
+ def __init__(
39
+ self,
40
+ sock: socket.socket,
41
+ on_message: Callable[[str], None],
42
+ on_close: Callable[[], None],
43
+ ) -> None:
44
+ self.sock = sock
45
+ self.on_message = on_message
46
+ self.on_close = on_close
47
+ self._lock = threading.Lock()
48
+ self._closed = False
49
+ self._reader_thread: Optional[threading.Thread] = None
50
+
51
+ # ------------------------------------------------------------------
52
+ # Reading
53
+ # ------------------------------------------------------------------
54
+
55
+ def start_reading(self) -> None:
56
+ self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
57
+ self._reader_thread.start()
58
+
59
+ def _read_exact(self, n: int) -> bytes:
60
+ buf = b""
61
+ while len(buf) < n:
62
+ chunk = self.sock.recv(n - len(buf))
63
+ if not chunk:
64
+ raise WebSocketError("connection closed")
65
+ buf += chunk
66
+ return buf
67
+
68
+ def _read_loop(self) -> None:
69
+ try:
70
+ while not self._closed:
71
+ self._read_frame()
72
+ except (WebSocketError, socket.error, OSError):
73
+ pass
74
+ finally:
75
+ self.close()
76
+ if self.on_close:
77
+ try:
78
+ self.on_close()
79
+ except Exception:
80
+ pass
81
+
82
+ def _read_frame(self) -> None:
83
+ header = self._read_exact(2)
84
+ fin = (header[0] >> 7) & 0x01
85
+ opcode = header[0] & 0x0F
86
+ masked = (header[1] >> 7) & 0x01
87
+ length = header[1] & 0x7F
88
+
89
+ if length == 126:
90
+ length = struct.unpack(">H", self._read_exact(2))[0]
91
+ elif length == 127:
92
+ length = struct.unpack(">Q", self._read_exact(8))[0]
93
+
94
+ mask_key = self._read_exact(4) if masked else None
95
+ payload = self._read_exact(length)
96
+ if mask_key:
97
+ payload = bytes(
98
+ b ^ mask_key[i % 4] for i, b in enumerate(payload)
99
+ )
100
+
101
+ if opcode == OP_TEXT:
102
+ self.on_message(payload.decode("utf-8", errors="replace"))
103
+ elif opcode == OP_BINARY:
104
+ self.on_message(payload.decode("utf-8", errors="replace"))
105
+ elif opcode == OP_PING:
106
+ self._send_frame(OP_PONG, payload)
107
+ elif opcode == OP_PONG:
108
+ pass
109
+ elif opcode == OP_CLOSE:
110
+ self.close()
111
+ elif opcode == OP_CONT:
112
+ # For simplicity, treat continuation frames as pass-through text.
113
+ self.on_message(payload.decode("utf-8", errors="replace"))
114
+ else:
115
+ raise WebSocketError(f"unsupported opcode {opcode}")
116
+
117
+ # ------------------------------------------------------------------
118
+ # Writing
119
+ # ------------------------------------------------------------------
120
+
121
+ def _send_frame(self, opcode: int, payload: bytes) -> None:
122
+ if self._closed:
123
+ return
124
+ with self._lock:
125
+ header = bytearray()
126
+ header.append(0x80 | opcode)
127
+ length = len(payload)
128
+ if length < 126:
129
+ header.append(length)
130
+ elif length < 65536:
131
+ header.append(126)
132
+ header.extend(struct.pack(">H", length))
133
+ else:
134
+ header.append(127)
135
+ header.extend(struct.pack(">Q", length))
136
+ try:
137
+ self.sock.sendall(bytes(header) + payload)
138
+ except (socket.error, OSError):
139
+ self.close()
140
+
141
+ def send_text(self, message: str) -> None:
142
+ self._send_frame(OP_TEXT, message.encode("utf-8"))
143
+
144
+ def send_json(self, obj: object) -> None:
145
+ import json
146
+
147
+ self.send_text(json.dumps(obj, ensure_ascii=False))
148
+
149
+ # ------------------------------------------------------------------
150
+ # Close
151
+ # ------------------------------------------------------------------
152
+
153
+ def close(self) -> None:
154
+ if self._closed:
155
+ return
156
+ self._closed = True
157
+ try:
158
+ self.sock.close()
159
+ except OSError:
160
+ pass
161
+
162
+
163
+ class WebSocketServer:
164
+ """Minimal WebSocket server that handles one handshake per incoming connection."""
165
+
166
+ def __init__(
167
+ self,
168
+ handle_connection: Callable[[WebSocketConnection, dict], None],
169
+ ) -> None:
170
+ self.handle_connection = handle_connection
171
+
172
+ def upgrade(self, sock: socket.socket, request: bytes) -> Optional[WebSocketConnection]:
173
+ """Perform the WebSocket handshake and return a connection, or None on failure."""
174
+ try:
175
+ key = self._extract_key(request)
176
+ if not key:
177
+ sock.close()
178
+ return None
179
+
180
+ accept = base64.b64encode(
181
+ hashlib.sha1((key + MAGIC).encode()).digest()
182
+ ).decode()
183
+
184
+ response = (
185
+ "HTTP/1.1 101 Switching Protocols\r\n"
186
+ "Upgrade: websocket\r\n"
187
+ "Connection: Upgrade\r\n"
188
+ f"Sec-WebSocket-Accept: {accept}\r\n"
189
+ "\r\n"
190
+ )
191
+ sock.sendall(response.encode())
192
+ except (socket.error, OSError):
193
+ return None
194
+
195
+ conn = WebSocketConnection(sock, lambda msg: None, lambda: None)
196
+ return conn
197
+
198
+ @staticmethod
199
+ def _extract_key(request: bytes) -> Optional[str]:
200
+ text = request.decode("utf-8", errors="replace")
201
+ for line in text.split("\r\n"):
202
+ if line.lower().startswith("sec-websocket-key:"):
203
+ return line.split(":", 1)[1].strip()
204
+ return None
package/dist/server.js CHANGED
@@ -418,6 +418,16 @@ export class PiWebServer {
418
418
  // Used by the sidebar Tools refresh button
419
419
  this.broadcastState();
420
420
  break;
421
+ case "set_theme": {
422
+ // Browser theme switch (dark/light): swap the extension ANSI theme.
423
+ // CSS switches instantly on the client; the server theme only
424
+ // affects extension setStatus/setWidget generated after this point
425
+ // (same as TUI - status text is fixed at setStatus time).
426
+ const name = data.name === "light" ? "light" : "dark";
427
+ this.uiContext.setWebTheme(name);
428
+ this.broadcast({ type: "theme_set", data: { name } });
429
+ break;
430
+ }
421
431
  case "bash": {
422
432
  const command = typeof data.command === "string" ? data.command : "";
423
433
  if (!command)