@wangyaoshen/remux 0.3.8-dev.bab6c95

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 (166) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +138 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +456 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  21. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  22. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  23. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  24. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  25. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  26. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  27. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  28. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  29. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  30. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  31. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  32. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  33. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  34. package/apps/macos/Package.swift +37 -0
  35. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  36. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  37. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  38. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  39. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  40. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  41. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  42. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  43. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  44. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  45. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  46. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  47. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  48. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  49. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  50. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  51. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  52. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  53. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  54. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  55. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  56. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  57. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  58. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  59. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  60. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  61. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  62. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  63. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  64. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  65. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  66. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  67. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  68. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  69. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  70. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  71. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  72. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  73. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  74. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  75. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  76. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  77. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  78. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  79. package/build.mjs +33 -0
  80. package/native/android/DecodeGoldenPayloads.kt +487 -0
  81. package/native/android/ProtocolModels.kt +188 -0
  82. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  83. package/native/ios/ProtocolModels.swift +200 -0
  84. package/package.json +45 -0
  85. package/packages/RemuxKit/Package.swift +27 -0
  86. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  87. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  88. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  89. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  90. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  91. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  92. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  93. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  94. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  95. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  96. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  97. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  98. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  99. package/playwright.config.ts +17 -0
  100. package/pnpm-lock.yaml +1588 -0
  101. package/pty-daemon.js +303 -0
  102. package/release-please-config.json +14 -0
  103. package/scripts/auto-deploy.sh +46 -0
  104. package/scripts/build-dmg.sh +121 -0
  105. package/scripts/build-ghostty-kit.sh +43 -0
  106. package/scripts/check-active-terminology.mjs +132 -0
  107. package/scripts/sync-ghostty-web.sh +28 -0
  108. package/server.js +7074 -0
  109. package/src/adapters/agent-events.ts +246 -0
  110. package/src/adapters/claude-code.ts +158 -0
  111. package/src/adapters/codex.ts +210 -0
  112. package/src/adapters/generic-shell.ts +58 -0
  113. package/src/adapters/index.ts +15 -0
  114. package/src/adapters/registry.ts +99 -0
  115. package/src/adapters/types.ts +41 -0
  116. package/src/auth.ts +174 -0
  117. package/src/e2ee.ts +236 -0
  118. package/src/git-service.ts +168 -0
  119. package/src/message-buffer.ts +137 -0
  120. package/src/pty-daemon.ts +357 -0
  121. package/src/push.ts +127 -0
  122. package/src/renderers.ts +455 -0
  123. package/src/server.ts +2407 -0
  124. package/src/service.ts +226 -0
  125. package/src/session.ts +978 -0
  126. package/src/store.ts +1422 -0
  127. package/src/team.ts +123 -0
  128. package/src/tunnel.ts +126 -0
  129. package/src/types.d.ts +50 -0
  130. package/src/vt-tracker.ts +188 -0
  131. package/src/workspace-head.ts +144 -0
  132. package/src/workspace.ts +153 -0
  133. package/src/ws-handler.ts +1526 -0
  134. package/start.ps1 +83 -0
  135. package/tests/adapters.test.js +171 -0
  136. package/tests/auth.test.js +243 -0
  137. package/tests/codex-adapter.test.js +535 -0
  138. package/tests/durable-stream.test.js +153 -0
  139. package/tests/e2e/app.spec.js +530 -0
  140. package/tests/e2ee.test.js +325 -0
  141. package/tests/message-buffer.test.js +245 -0
  142. package/tests/message-routing.test.js +305 -0
  143. package/tests/pty-daemon.test.js +346 -0
  144. package/tests/push.test.js +281 -0
  145. package/tests/renderers.test.js +391 -0
  146. package/tests/search-shell.test.js +499 -0
  147. package/tests/server.test.js +882 -0
  148. package/tests/service.test.js +267 -0
  149. package/tests/store.test.js +369 -0
  150. package/tests/tunnel.test.js +67 -0
  151. package/tests/workspace-head.test.js +116 -0
  152. package/tests/workspace.test.js +417 -0
  153. package/tsconfig.backend.json +11 -0
  154. package/tsconfig.json +15 -0
  155. package/tui/client/client_test.go +125 -0
  156. package/tui/client/connection.go +342 -0
  157. package/tui/client/host_manager.go +141 -0
  158. package/tui/config/cache.go +81 -0
  159. package/tui/config/config.go +53 -0
  160. package/tui/config/config_test.go +89 -0
  161. package/tui/go.mod +32 -0
  162. package/tui/go.sum +50 -0
  163. package/tui/main.go +261 -0
  164. package/tui/tests/integration_test.go +283 -0
  165. package/tui/ui/model.go +310 -0
  166. package/vitest.config.js +10 -0
package/src/server.ts ADDED
@@ -0,0 +1,2407 @@
1
+ /**
2
+ * Remux server -- ghostty-web terminal with session management.
3
+ * Adapted from coder/ghostty-web demo (MIT) + tsm session patterns.
4
+ */
5
+
6
+ import fs from "fs";
7
+ import http from "http";
8
+ import path from "path";
9
+ import { createRequire } from "module";
10
+ import { fileURLToPath } from "url";
11
+ import qrcode from "qrcode-terminal";
12
+ import { resolveAuth, generateToken, validateToken, addPasswordToken, passwordTokens, PASSWORD_PAGE } from "./auth.js";
13
+ import { initGhosttyVt } from "./vt-tracker.js";
14
+ import { getDb, closeDb } from "./store.js";
15
+ import { initPush } from "./push.js";
16
+ import {
17
+ sessionMap,
18
+ createSession,
19
+ createTab,
20
+ persistSessions,
21
+ restoreSessions,
22
+ PERSIST_INTERVAL_MS,
23
+ createRestoredTab,
24
+ findAliveDaemonSocket,
25
+ reattachToDaemon,
26
+ } from "./session.js";
27
+ import { setupWebSocket } from "./ws-handler.js";
28
+ import { AdapterRegistry, GenericShellAdapter, ClaudeCodeAdapter, CodexAdapter } from "./adapters/index.js";
29
+ import { initGitService } from "./git-service.js";
30
+ import {
31
+ parseTunnelArgs,
32
+ isCloudflaredAvailable,
33
+ startTunnel,
34
+ buildTunnelAccessUrl,
35
+ } from "./tunnel.js";
36
+ import type { ChildProcess } from "child_process";
37
+ import { handleServiceCommand } from "./service.js";
38
+
39
+ const __filename = fileURLToPath(import.meta.url);
40
+ const __dirname = path.dirname(__filename);
41
+
42
+ // ── Service subcommand (must run before any heavy init) ─────────
43
+ if (handleServiceCommand(process.argv)) {
44
+ process.exit(0);
45
+ }
46
+
47
+ const require = createRequire(import.meta.url);
48
+ const PKG = JSON.parse(
49
+ fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
50
+ );
51
+ const VERSION = PKG.version;
52
+ const PORT = process.env.PORT || 8767;
53
+
54
+ // ── Authentication ───────────────────────────────────────────────
55
+ const { TOKEN, PASSWORD } = resolveAuth(process.argv);
56
+
57
+ // ── Tunnel ───────────────────────────────────────────────────────
58
+ const { tunnelMode } = parseTunnelArgs(process.argv);
59
+ let tunnelProcess: ChildProcess | null = null;
60
+
61
+ // ── Locate ghostty-web assets ────────────────────────────────────
62
+
63
+ function findGhosttyWeb(): { distPath: string; wasmPath: string } {
64
+ const ghosttyWebMain = require.resolve("ghostty-web");
65
+ const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, "");
66
+ const distPath = path.join(ghosttyWebRoot, "dist");
67
+ const wasmPath = path.join(ghosttyWebRoot, "ghostty-vt.wasm");
68
+ if (
69
+ fs.existsSync(path.join(distPath, "ghostty-web.js")) &&
70
+ fs.existsSync(wasmPath)
71
+ ) {
72
+ return { distPath, wasmPath };
73
+ }
74
+ console.error("Error: ghostty-web package not found.");
75
+ process.exit(1);
76
+ }
77
+
78
+ const { distPath, wasmPath } = findGhosttyWeb();
79
+
80
+ // ── Startup: init WASM, restore sessions, create default ─────────
81
+
82
+ let startupDone = false;
83
+
84
+ async function startup(): Promise<void> {
85
+ // Initialize SQLite store (creates tables if needed)
86
+ getDb();
87
+
88
+ // Initialize Web Push (loads or generates VAPID keys)
89
+ initPush();
90
+
91
+ await initGhosttyVt(wasmPath);
92
+
93
+ // Try restoring saved sessions
94
+ const saved = restoreSessions();
95
+ if (saved && saved.sessions.length > 0) {
96
+ for (const s of saved.sessions) {
97
+ const session = createSession(s.name);
98
+ // Restore tabs — check for alive daemons first, then fall back to restored mode
99
+ for (const t of s.tabs) {
100
+ if (t.ended) continue;
101
+
102
+ // Check if a daemon is still alive for this tab
103
+ const daemonSocket = findAliveDaemonSocket(t.id);
104
+ const restoredTab = createRestoredTab(session, t);
105
+
106
+ if (daemonSocket) {
107
+ // Daemon is alive — try to reattach
108
+ reattachToDaemon(restoredTab, session, daemonSocket).catch(() => {
109
+ console.log(`[startup] daemon reattach failed for tab ${t.id}, staying in restored mode`);
110
+ });
111
+ }
112
+ // If no daemon found, tab stays in restored-readonly mode
113
+ // User will see scrollback + banner prompting to press Enter
114
+ }
115
+ // If all tabs were ended, create a fresh one
116
+ if (session.tabs.length === 0) createTab(session);
117
+ }
118
+ }
119
+
120
+ // If no sessions were restored, create an initial one.
121
+ // The name "default" is not privileged — it's just the bootstrap name.
122
+ if (sessionMap.size === 0) {
123
+ const s = createSession("default");
124
+ createTab(s);
125
+ }
126
+
127
+ // Persistence timer (8s, like cmux)
128
+ setInterval(persistSessions, PERSIST_INTERVAL_MS);
129
+
130
+ // Initialize git service (E11)
131
+ initGitService();
132
+
133
+ // Initialize adapter registry (E10)
134
+ initAdapters();
135
+
136
+ startupDone = true;
137
+ }
138
+
139
+ // ── Adapter Registry (E10) ──────────────────────────────────────
140
+ export const adapterRegistry = new AdapterRegistry();
141
+
142
+ function initAdapters(): void {
143
+ // E10-004: generic-shell adapter (always available)
144
+ adapterRegistry.register(new GenericShellAdapter());
145
+
146
+ // E10-005: claude-code adapter (passive, watches events.jsonl)
147
+ const claudeAdapter = new ClaudeCodeAdapter((event) => {
148
+ adapterRegistry.emit(event.adapterId, event.type, event.data);
149
+ });
150
+ adapterRegistry.register(claudeAdapter);
151
+
152
+ // E10-008: codex adapter (passive, watches events and terminal)
153
+ const codexAdapter = new CodexAdapter((event) => {
154
+ adapterRegistry.emit(event.adapterId, event.type, event.data);
155
+ });
156
+ adapterRegistry.register(codexAdapter);
157
+ }
158
+
159
+ startup().catch((e) => {
160
+ console.error("[startup] fatal:", e);
161
+ // Fallback: create default session without VT tracking
162
+ if (sessionMap.size === 0) {
163
+ const s = createSession("main");
164
+ createTab(s);
165
+ }
166
+ startupDone = true;
167
+ });
168
+
169
+ // ── HTML Template ────────────────────────────────────────────────
170
+
171
+ const HTML_TEMPLATE = `<!doctype html>
172
+ <html lang="en" data-theme="dark">
173
+ <head>
174
+ <meta charset="UTF-8" />
175
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
176
+ <title>Remux</title>
177
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⬛</text></svg>">
178
+ <style>
179
+ /* -- Theme Variables -- */
180
+ [data-theme="dark"] {
181
+ --bg: #1e1e1e;
182
+ --bg-sidebar: #252526;
183
+ --bg-tab-bar: #2d2d2d;
184
+ --bg-tab-active: #1e1e1e;
185
+ --bg-hover: #2a2d2e;
186
+ --bg-active: #37373d;
187
+ --border: #1a1a1a;
188
+ --text: #ccc;
189
+ --text-muted: #888;
190
+ --text-dim: #666;
191
+ --text-bright: #e5e5e5;
192
+ --text-on-active: #fff;
193
+ --accent: #007acc;
194
+ --dot-ok: #27c93f;
195
+ --dot-err: #ff5f56;
196
+ --dot-warn: #ffbd2e;
197
+ --compose-bg: #3a3a3a;
198
+ --compose-border: #555;
199
+ --tab-hover: #383838;
200
+ --view-switch-bg: #1a1a1a;
201
+ --inspect-meta-border: #333;
202
+ }
203
+
204
+ [data-theme="light"] {
205
+ --bg: #ffffff;
206
+ --bg-sidebar: #f3f3f3;
207
+ --bg-tab-bar: #e8e8e8;
208
+ --bg-tab-active: #ffffff;
209
+ --bg-hover: #e8e8e8;
210
+ --bg-active: #d6d6d6;
211
+ --border: #d4d4d4;
212
+ --text: #333333;
213
+ --text-muted: #666666;
214
+ --text-dim: #999999;
215
+ --text-bright: #1e1e1e;
216
+ --text-on-active: #000000;
217
+ --accent: #007acc;
218
+ --dot-ok: #16a34a;
219
+ --dot-err: #dc2626;
220
+ --dot-warn: #d97706;
221
+ --compose-bg: #e8e8e8;
222
+ --compose-border: #c0c0c0;
223
+ --tab-hover: #d6d6d6;
224
+ --view-switch-bg: #d4d4d4;
225
+ --inspect-meta-border: #d4d4d4;
226
+ }
227
+
228
+ * { margin: 0; padding: 0; box-sizing: border-box; }
229
+ html, body { height: 100%; overflow: hidden; overscroll-behavior: none; }
230
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
231
+ background: var(--bg); color: var(--text); height: 100vh; height: 100dvh; display: flex; }
232
+
233
+ /* -- Sidebar -- */
234
+ .sidebar { width: 220px; min-width: 220px; background: var(--bg-sidebar); border-right: 1px solid var(--border);
235
+ display: flex; flex-direction: column; flex-shrink: 0; transition: margin-left .2s; }
236
+ .sidebar.collapsed { margin-left: -220px; }
237
+ .sidebar-header { padding: 10px 12px; font-size: 11px; font-weight: 600; color: var(--text-muted);
238
+ text-transform: uppercase; letter-spacing: .5px; display: flex; align-items: center;
239
+ justify-content: space-between; }
240
+ .sidebar-header button { background: none; border: none; color: var(--text-dim); cursor: pointer;
241
+ font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 4px; }
242
+ .sidebar-header button:hover { color: var(--text-bright); background: var(--compose-bg); }
243
+
244
+ .session-list { flex: 1; overflow-y: auto; padding: 4px 6px; }
245
+ .session-item { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 4px;
246
+ font-size: 13px; cursor: pointer; color: var(--text); border: none; background: none;
247
+ width: 100%; text-align: left; font-family: inherit; min-height: 32px; }
248
+ .session-item:hover { background: var(--bg-hover); color: var(--text-bright); }
249
+ .session-item.active { background: var(--bg-active); color: var(--text-on-active); }
250
+ .session-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dot-ok); flex-shrink: 0; }
251
+ .session-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
252
+ .session-item .count { font-size: 10px; color: var(--compose-border); min-width: 16px; text-align: center; }
253
+ .session-item .del { opacity: 0; font-size: 14px; color: var(--text-dim); background: none; border: none;
254
+ cursor: pointer; padding: 0 4px; font-family: inherit; line-height: 1; border-radius: 3px; }
255
+ .session-item:hover .del { opacity: 1; }
256
+ .session-item .del:hover { color: var(--dot-err); background: var(--compose-bg); }
257
+
258
+ .sidebar-footer { padding: 8px 12px; border-top: 1px solid var(--border);
259
+ display: flex; flex-direction: column; gap: 6px; }
260
+ .sidebar-footer .version { font-size: 10px; color: var(--text-dim); }
261
+ .sidebar-footer .footer-row { display: flex; align-items: center; gap: 8px; }
262
+ .sidebar-footer .status { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
263
+ .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; }
264
+ .status-dot.connected { background: var(--dot-ok); }
265
+ .status-dot.disconnected { background: var(--dot-err); }
266
+ .status-dot.connecting { background: var(--dot-warn); animation: pulse 1s infinite; }
267
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
268
+
269
+ .role-indicator { font-size: 11px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
270
+ .role-indicator.active { color: var(--dot-ok); }
271
+ .role-indicator.observer { color: var(--dot-warn); }
272
+ .role-btn { background: none; border: 1px solid var(--border); border-radius: 4px;
273
+ color: var(--text-muted); font-size: 10px; padding: 2px 8px; cursor: pointer; font-family: inherit; }
274
+ .role-btn:hover { color: var(--text-bright); border-color: var(--text-muted); }
275
+
276
+ /* -- Theme toggle -- */
277
+ .theme-toggle { background: none; border: none; cursor: pointer; font-size: 16px;
278
+ color: var(--text-muted); padding: 4px 8px; border-radius: 4px; }
279
+ .theme-toggle:hover { color: var(--text-bright); background: var(--bg-hover); }
280
+
281
+ /* -- Main -- */
282
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
283
+
284
+ /* -- Tab bar (Chrome-style) -- */
285
+ .tab-bar { background: var(--bg-tab-bar); display: flex; align-items: flex-end; flex-shrink: 0;
286
+ min-height: 36px; padding: 0 0 0 0; position: relative; z-index: 101; }
287
+ .tab-toggle { padding: 8px 10px; background: none; border: none; color: var(--text-muted);
288
+ cursor: pointer; font-size: 16px; flex-shrink: 0; align-self: center; }
289
+ .tab-toggle:hover { color: var(--text-bright); }
290
+ .tab-list { display: flex; flex: 1; min-width: 0; align-items: flex-end; overflow-x: auto;
291
+ -webkit-overflow-scrolling: touch; scrollbar-width: none; }
292
+ .tab-list::-webkit-scrollbar { display: none; }
293
+
294
+ .tab { position: relative; display: flex; align-items: center; gap: 0;
295
+ padding: 6px 8px 6px 12px; font-size: 12px; color: var(--text-dim); background: var(--bg-tab-bar);
296
+ border: none; cursor: pointer; white-space: nowrap; font-family: inherit;
297
+ border-top: 2px solid transparent; margin-right: 1px; min-height: 32px; }
298
+ .tab:hover { color: var(--text-bright); background: var(--tab-hover); }
299
+ .tab.active { color: var(--text-on-active); background: var(--bg-tab-active); border-top-color: var(--accent);
300
+ border-radius: 6px 6px 0 0; }
301
+ .tab .title { pointer-events: none; }
302
+ .tab .close { display: flex; align-items: center; justify-content: center;
303
+ width: 18px; height: 18px; margin-left: 6px; font-size: 12px; color: var(--text-dim);
304
+ border-radius: 3px; border: none; background: none; cursor: pointer;
305
+ font-family: inherit; flex-shrink: 0; }
306
+ .tab .close:hover { color: var(--text-on-active); background: var(--compose-border); }
307
+ .tab:not(:hover) .close:not(:focus) { opacity: 0; }
308
+ .tab.active .close { opacity: 1; color: var(--text-muted); }
309
+
310
+ .tab .client-count { font-size: 9px; color: var(--text-muted); margin-left: 4px;
311
+ background: var(--bg-hover); border-radius: 8px; padding: 1px 5px; pointer-events: none; }
312
+
313
+ .tab-new { display: flex; align-items: center; justify-content: center;
314
+ width: 28px; height: 28px; margin: 0 4px; font-size: 18px; color: var(--text-dim);
315
+ background: none; border: none; cursor: pointer; border-radius: 4px;
316
+ flex-shrink: 0; align-self: center; }
317
+ .tab-new:hover { color: var(--text); background: var(--compose-bg); }
318
+
319
+ /* -- Terminal -- */
320
+ #terminal { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
321
+ #terminal canvas { display: block; position: absolute; top: 0; left: 0; }
322
+ #terminal.hidden { display: none; }
323
+
324
+ /* -- View switcher -- */
325
+ .view-switch { display: flex; gap: 1px; margin-left: auto; margin-right: 8px;
326
+ align-self: center; background: var(--view-switch-bg); border-radius: 4px; overflow: hidden; }
327
+ .view-switch button { padding: 4px 10px; font-size: 11px; font-family: inherit;
328
+ color: var(--text-muted); background: var(--bg-tab-bar); border: none; cursor: pointer; }
329
+ .view-switch button:hover { color: var(--text); }
330
+ .view-switch button.active { color: var(--text-on-active); background: var(--accent); }
331
+
332
+ /* -- Inspect -- */
333
+ #inspect { flex: 1; background: var(--bg); overflow: auto; display: none;
334
+ padding: 12px 16px; -webkit-overflow-scrolling: touch; }
335
+ #inspect.visible { display: block; }
336
+
337
+ /* -- Workspace -- */
338
+ #workspace { flex: 1; background: var(--bg); overflow: auto; display: none;
339
+ padding: 12px 16px; -webkit-overflow-scrolling: touch; }
340
+ #workspace.visible { display: block; }
341
+ .ws-section { margin-bottom: 16px; }
342
+ .ws-section-title { font-size: 12px; font-weight: 600; color: var(--text-muted);
343
+ text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px;
344
+ display: flex; align-items: center; justify-content: space-between; }
345
+ .ws-section-title button { background: none; border: 1px solid var(--border);
346
+ color: var(--text-muted); font-size: 11px; padding: 2px 8px; border-radius: 4px;
347
+ cursor: pointer; font-family: inherit; }
348
+ .ws-section-title button:hover { color: var(--text-bright); border-color: var(--text-muted); }
349
+ .ws-empty { font-size: 12px; color: var(--text-dim); padding: 8px 0; }
350
+ .ws-card { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
351
+ padding: 8px 12px; margin-bottom: 6px; }
352
+ .ws-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
353
+ .ws-card-title { font-size: 13px; color: var(--text-bright); font-weight: 500; flex: 1;
354
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
355
+ .ws-card-meta { font-size: 10px; color: var(--text-dim); }
356
+ .ws-card-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
357
+ .ws-badge { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 8px;
358
+ font-weight: 500; }
359
+ .ws-badge.running { background: #1a3a5c; color: #4da6ff; }
360
+ .ws-badge.completed { background: #1a3c1a; color: #4dff4d; }
361
+ .ws-badge.failed { background: #3c1a1a; color: #ff4d4d; }
362
+ .ws-badge.pending { background: #3c3a1a; color: #ffbd2e; }
363
+ .ws-badge.approved { background: #1a3c1a; color: #4dff4d; }
364
+ .ws-badge.rejected { background: #3c1a1a; color: #ff4d4d; }
365
+ .ws-badge.snapshot { background: #1a2a3c; color: #88bbdd; }
366
+ .ws-badge.command-card { background: #2a1a3c; color: #bb88dd; }
367
+ .ws-badge.note { background: #1a3c2a; color: #88ddbb; }
368
+ .ws-badge.diff { background: #2a2a1a; color: #ddbb55; }
369
+ .ws-badge.markdown { background: #1a2a2a; color: #55bbdd; }
370
+ .ws-badge.ansi { background: #2a1a2a; color: #dd88bb; }
371
+ .ws-card-actions { display: flex; gap: 4px; margin-top: 6px; }
372
+ .ws-card-actions button { background: none; border: 1px solid var(--border);
373
+ color: var(--text-muted); font-size: 11px; padding: 3px 10px; border-radius: 4px;
374
+ cursor: pointer; font-family: inherit; }
375
+ .ws-card-actions button:hover { color: var(--text-bright); border-color: var(--text-muted); }
376
+ .ws-card-actions button.approve { border-color: #27c93f; color: #27c93f; }
377
+ .ws-card-actions button.approve:hover { background: #27c93f22; }
378
+ .ws-card-actions button.reject { border-color: #ff5f56; color: #ff5f56; }
379
+ .ws-card-actions button.reject:hover { background: #ff5f5622; }
380
+ .ws-card .del-topic { opacity: 0; background: none; border: none; color: var(--text-dim);
381
+ cursor: pointer; font-size: 14px; padding: 0 4px; font-family: inherit; border-radius: 3px; }
382
+ .ws-card:hover .del-topic { opacity: 1; }
383
+ .ws-card .del-topic:hover { color: var(--dot-err); }
384
+
385
+ /* -- Search bar -- */
386
+ .ws-search { display: flex; gap: 8px; margin-bottom: 16px; }
387
+ .ws-search input { flex: 1; padding: 6px 10px; font-size: 13px; font-family: inherit;
388
+ background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 6px;
389
+ color: var(--text-bright); outline: none; }
390
+ .ws-search input:focus { border-color: var(--accent); }
391
+ .ws-search input::placeholder { color: var(--text-dim); }
392
+ .ws-search-results { margin-bottom: 12px; }
393
+ .ws-search-result { padding: 6px 10px; margin-bottom: 4px; background: var(--bg-sidebar);
394
+ border: 1px solid var(--border); border-radius: 4px; cursor: pointer; }
395
+ .ws-search-result:hover { border-color: var(--accent); }
396
+ .ws-search-result .sr-type { font-size: 10px; color: var(--text-dim); text-transform: uppercase; }
397
+ .ws-search-result .sr-title { font-size: 12px; color: var(--text-bright); }
398
+
399
+ /* -- Notes -- */
400
+ .ws-note { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
401
+ padding: 8px 12px; margin-bottom: 6px; position: relative; }
402
+ .ws-note.pinned { border-color: var(--accent); }
403
+ .ws-note-content { font-size: 12px; color: var(--text-bright); white-space: pre-wrap; word-break: break-word; }
404
+ .ws-note-actions { display: flex; gap: 4px; margin-top: 4px; }
405
+ .ws-note-actions button { background: none; border: none; color: var(--text-dim);
406
+ font-size: 11px; cursor: pointer; padding: 2px 6px; border-radius: 3px; font-family: inherit; }
407
+ .ws-note-actions button:hover { color: var(--text-bright); background: var(--bg-hover); }
408
+ .ws-note-input { display: flex; gap: 6px; margin-bottom: 8px; }
409
+ .ws-note-input input { flex: 1; padding: 6px 10px; font-size: 12px; font-family: inherit;
410
+ background: var(--compose-bg); border: 1px solid var(--compose-border); border-radius: 4px;
411
+ color: var(--text-bright); outline: none; }
412
+ .ws-note-input input:focus { border-color: var(--accent); }
413
+ .ws-note-input button { padding: 4px 12px; font-size: 12px; font-family: inherit;
414
+ background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; }
415
+
416
+ /* -- Commands -- */
417
+ .ws-cmd { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
418
+ padding: 6px 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
419
+ .ws-cmd-text { font-size: 12px; color: var(--text-bright); font-family: 'Menlo','Monaco',monospace;
420
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
421
+ .ws-cmd-exit { font-size: 11px; font-weight: 600; }
422
+ .ws-cmd-exit.ok { color: #27c93f; }
423
+ .ws-cmd-exit.err { color: #ff5f56; }
424
+ .ws-cmd-meta { font-size: 10px; color: var(--text-dim); white-space: nowrap; }
425
+
426
+ /* -- Handoff -- */
427
+ .ws-handoff { background: var(--bg-sidebar); border: 1px solid var(--border); border-radius: 6px;
428
+ padding: 12px; margin-bottom: 12px; display: none; }
429
+ .ws-handoff.visible { display: block; }
430
+ .ws-handoff-section { margin-bottom: 8px; }
431
+ .ws-handoff-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase;
432
+ letter-spacing: .5px; margin-bottom: 4px; }
433
+ .ws-handoff-list { font-size: 12px; color: var(--text-muted); padding-left: 12px; }
434
+ .ws-handoff-list li { margin-bottom: 2px; }
435
+
436
+ /* -- Rich content rendering (diff, markdown, ANSI) -- */
437
+ .ws-card-content { margin-top: 6px; font-size: 12px; max-height: 200px; overflow: auto;
438
+ border-top: 1px solid var(--border); padding-top: 6px; }
439
+ .ws-card-content.expanded { max-height: none; }
440
+ .ws-card-toggle { font-size: 11px; color: var(--text-dim); background: none; border: none;
441
+ cursor: pointer; padding: 2px 6px; font-family: inherit; border-radius: 3px; }
442
+ .ws-card-toggle:hover { color: var(--text-bright); background: var(--bg-hover); }
443
+
444
+ /* Diff */
445
+ .diff-container { font-family: 'Menlo','Monaco','Courier New',monospace; font-size: 11px;
446
+ line-height: 1.5; overflow-x: auto; }
447
+ .diff-container > div { padding: 0 8px; white-space: pre; }
448
+ .diff-add { background: #1a3a1a; color: #4eff4e; }
449
+ .diff-del { background: #3a1a1a; color: #ff4e4e; }
450
+ .diff-hunk { color: #6a9eff; font-style: italic; }
451
+ .diff-header { color: #888; font-style: italic; }
452
+ .diff-ctx { color: var(--text-muted); }
453
+ .diff-line-num { display: inline-block; width: 32px; text-align: right; margin-right: 8px;
454
+ color: var(--text-dim); user-select: none; }
455
+
456
+ /* Markdown */
457
+ .rendered-md { font-size: 13px; line-height: 1.6; color: var(--text-bright); }
458
+ .rendered-md h1 { font-size: 18px; margin: 0.5em 0 0.3em; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
459
+ .rendered-md h2 { font-size: 15px; margin: 0.5em 0 0.3em; }
460
+ .rendered-md h3 { font-size: 13px; margin: 0.5em 0 0.3em; font-weight: 600; }
461
+ .rendered-md p { margin: 0.4em 0; }
462
+ .rendered-md code { background: #2a2a2a; padding: 2px 6px; border-radius: 3px;
463
+ font-family: 'Menlo','Monaco',monospace; font-size: 11px; }
464
+ .rendered-md pre { background: #1e1e1e; padding: 12px; border-radius: 6px;
465
+ overflow-x: auto; margin: 0.4em 0; }
466
+ .rendered-md pre code { background: none; padding: 0; font-size: 11px; }
467
+ .rendered-md blockquote { border-left: 3px solid #555; padding-left: 12px; color: #aaa;
468
+ margin: 0.4em 0; }
469
+ .rendered-md ul, .rendered-md ol { padding-left: 20px; margin: 0.3em 0; }
470
+ .rendered-md li { margin: 0.15em 0; }
471
+ .rendered-md a { color: var(--accent); text-decoration: none; }
472
+ .rendered-md a:hover { text-decoration: underline; }
473
+ .rendered-md hr { border: none; border-top: 1px solid var(--border); margin: 0.5em 0; }
474
+ .rendered-md strong { color: var(--text-on-active); }
475
+
476
+ /* ANSI */
477
+ .ansi-bold { font-weight: bold; }
478
+ .ansi-dim { opacity: 0.6; }
479
+ .ansi-italic { font-style: italic; }
480
+ .ansi-underline { text-decoration: underline; }
481
+
482
+ /* Light theme overrides */
483
+ [data-theme="light"] .diff-add { background: #e6ffec; color: #1a7f37; }
484
+ [data-theme="light"] .diff-del { background: #ffebe9; color: #cf222e; }
485
+ [data-theme="light"] .diff-hunk { color: #0969da; }
486
+ [data-theme="light"] .diff-header { color: #6e7781; }
487
+ [data-theme="light"] .diff-ctx { color: #57606a; }
488
+ [data-theme="light"] .rendered-md code { background: #eee; }
489
+ [data-theme="light"] .rendered-md pre { background: #f6f8fa; }
490
+ [data-theme="light"] .rendered-md blockquote { border-left-color: #ccc; color: #666; }
491
+
492
+ #inspect-content { font-family: 'Menlo','Monaco','Courier New',monospace; font-size: 13px;
493
+ line-height: 1.5; color: var(--text-bright); white-space: pre-wrap; word-break: break-all;
494
+ tab-size: 8; user-select: text; -webkit-user-select: text; }
495
+ #inspect-content mark { background: #ffbd2e; color: #1e1e1e; border-radius: 2px; }
496
+ #inspect-header { font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 11px;
497
+ color: var(--text-dim); padding: 8px 0; border-bottom: 1px solid var(--inspect-meta-border); margin-bottom: 8px;
498
+ display: flex; flex-direction: column; gap: 8px; }
499
+ #inspect-meta { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
500
+ #inspect-meta span { white-space: nowrap; }
501
+ #inspect-meta .inspect-btn { padding: 2px 10px; font-size: 11px; font-family: inherit;
502
+ color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
503
+ border-radius: 4px; cursor: pointer; white-space: nowrap; }
504
+ #inspect-meta .inspect-btn:hover { background: var(--compose-border); }
505
+ #inspect-search { display: flex; gap: 8px; align-items: center; }
506
+ #inspect-search input { padding: 4px 8px; font-size: 12px; font-family: inherit;
507
+ background: var(--bg); border: 1px solid var(--compose-border); border-radius: 4px;
508
+ color: var(--text); outline: none; flex: 1; max-width: 260px; }
509
+ #inspect-search input:focus { border-color: var(--accent); }
510
+ #inspect-search .match-count { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
511
+
512
+ /* -- Compose bar -- */
513
+ .compose-bar { display: none; background: var(--bg-sidebar); border-top: 1px solid var(--border);
514
+ padding: 5px 8px; gap: 5px; flex-shrink: 0; overflow-x: auto; flex-wrap: wrap;
515
+ -webkit-overflow-scrolling: touch; }
516
+ .compose-bar button { padding: 8px 12px; font-size: 14px;
517
+ font-family: 'Menlo','Monaco',monospace; color: var(--text-bright); background: var(--compose-bg);
518
+ border: 1px solid var(--compose-border); border-radius: 5px; cursor: pointer; white-space: nowrap;
519
+ -webkit-tap-highlight-color: transparent; touch-action: manipulation;
520
+ min-width: 40px; text-align: center; user-select: none; }
521
+ .compose-bar button:active { background: var(--compose-border); }
522
+ .compose-bar button.active { background: #4a6a9a; border-color: #6a9ade; }
523
+ @media (hover: none) and (pointer: coarse) { .compose-bar { display: flex; } }
524
+
525
+ /* -- Tab rename input -- */
526
+ .tab .rename-input { background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
527
+ color: var(--text-bright); font-size: 12px; font-family: inherit; padding: 1px 4px;
528
+ outline: none; width: 80px; }
529
+
530
+ /* -- Devices section -- */
531
+ .devices-section { border-top: 1px solid var(--border); }
532
+ .devices-header { padding: 8px 12px; font-size: 11px; font-weight: 600; color: var(--text-muted);
533
+ text-transform: uppercase; letter-spacing: .5px; cursor: pointer; display: flex;
534
+ align-items: center; justify-content: space-between; user-select: none; }
535
+ .devices-header:hover { color: var(--text-bright); }
536
+ .devices-toggle { font-size: 8px; transition: transform .2s; }
537
+ .devices-toggle.collapsed { transform: rotate(-90deg); }
538
+ .devices-list { padding: 2px 6px; max-height: 200px; overflow-y: auto; }
539
+ .devices-list.collapsed { display: none; }
540
+ .device-item { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 4px;
541
+ font-size: 12px; color: var(--text); }
542
+ .device-item:hover { background: var(--bg-hover); }
543
+ .device-item .device-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
544
+ .device-dot.trusted { background: var(--dot-ok); }
545
+ .device-dot.untrusted { background: var(--dot-warn); }
546
+ .device-dot.blocked { background: var(--dot-err); }
547
+ .device-item .device-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
548
+ .device-item .device-self { font-size: 9px; color: var(--accent); margin-left: 2px; }
549
+ .device-item .device-actions { display: flex; gap: 2px; opacity: 0; }
550
+ .device-item:hover .device-actions { opacity: 1; }
551
+ .device-actions button { background: none; border: none; color: var(--text-dim); cursor: pointer;
552
+ font-size: 11px; padding: 1px 4px; border-radius: 3px; font-family: inherit; }
553
+ .device-actions button:hover { color: var(--text-bright); background: var(--compose-bg); }
554
+ .devices-actions { padding: 4px 12px 8px; }
555
+ .pair-btn { width: 100%; padding: 5px 8px; font-size: 11px; font-family: inherit;
556
+ color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
557
+ border-radius: 4px; cursor: pointer; margin-bottom: 4px; }
558
+ .pair-btn:hover { background: var(--compose-border); }
559
+ .pair-code-display { text-align: center; padding: 6px; }
560
+ .pair-code { font-family: 'Menlo','Monaco',monospace; font-size: 24px; font-weight: bold;
561
+ color: var(--accent); letter-spacing: 4px; }
562
+ .pair-expires { display: block; font-size: 10px; color: var(--text-dim); margin-top: 2px; }
563
+ .pair-input-area { display: flex; gap: 4px; }
564
+ .pair-input-area input { flex: 1; min-width: 0; padding: 5px 8px; font-size: 13px; font-family: 'Menlo','Monaco',monospace;
565
+ background: var(--bg); border: 1px solid var(--compose-border); border-radius: 4px;
566
+ color: var(--text); outline: none; text-align: center; letter-spacing: 2px; }
567
+ .pair-input-area input:focus { border-color: var(--accent); }
568
+ .pair-input-area .pair-btn { flex-shrink: 0; width: auto; }
569
+
570
+ /* -- Push notification section -- */
571
+ .push-section { padding: 4px 12px 8px; border-top: 1px solid var(--border); }
572
+ .push-toggle { width: 100%; padding: 5px 8px; font-size: 11px; font-family: inherit;
573
+ color: var(--text-bright); background: var(--compose-bg); border: 1px solid var(--compose-border);
574
+ border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 6px;
575
+ justify-content: center; }
576
+ .push-toggle:hover { background: var(--compose-border); }
577
+ .push-toggle.subscribed { background: var(--accent); border-color: var(--accent); }
578
+ .push-toggle .push-icon { font-size: 14px; }
579
+ .push-test-btn { width: 100%; padding: 4px 8px; font-size: 10px; font-family: inherit;
580
+ color: var(--text-muted); background: none; border: 1px solid var(--border);
581
+ border-radius: 4px; cursor: pointer; margin-top: 4px; }
582
+ .push-test-btn:hover { color: var(--text-bright); border-color: var(--compose-border); }
583
+
584
+ /* -- Mobile -- */
585
+ @media (max-width: 768px) {
586
+ .sidebar { position: fixed; left: 0; top: 0; bottom: 0; z-index: 100;
587
+ width: 260px; margin-left: 0; transform: translateX(-100%); box-shadow: none;
588
+ transition: transform .2s ease, box-shadow .2s ease; overflow-y: auto; }
589
+ .sidebar.open { transform: translateX(0); box-shadow: 4px 0 20px rgba(0,0,0,.5); }
590
+ .sidebar-overlay { display: none; position: fixed; inset: 0;
591
+ background: rgba(0,0,0,.4); z-index: 99; pointer-events: none; }
592
+ .sidebar-overlay.visible { display: block; pointer-events: auto; }
593
+ .main { margin-left: 0 !important; width: 100vw; min-width: 0; }
594
+ .tab-bar { overflow-x: auto; }
595
+ .session-item { min-height: 44px; } /* touch-friendly */
596
+ .tab { min-height: 36px; }
597
+ }
598
+ </style>
599
+ </head>
600
+ <body>
601
+ <div class="sidebar-overlay" id="sidebar-overlay"></div>
602
+ <aside class="sidebar" id="sidebar">
603
+ <div class="sidebar-header">
604
+ <span>Sessions</span>
605
+ <button id="btn-new-session" title="New session">+</button>
606
+ </div>
607
+ <div class="session-list" id="session-list"></div>
608
+
609
+ <!-- Devices section (collapsible) -->
610
+ <div class="devices-section" id="devices-section">
611
+ <div class="devices-header" id="devices-header">
612
+ <span>Devices</span>
613
+ <span class="devices-toggle" id="devices-toggle">&#9660;</span>
614
+ </div>
615
+ <div class="devices-list" id="devices-list"></div>
616
+ <div class="devices-actions" id="devices-actions" style="display:none">
617
+ <button class="pair-btn" id="btn-pair">Generate Pair Code</button>
618
+ <div class="pair-code-display" id="pair-code-display" style="display:none">
619
+ <span class="pair-code" id="pair-code-value"></span>
620
+ <span class="pair-expires" id="pair-expires"></span>
621
+ </div>
622
+ <div class="pair-input-area" id="pair-input-area" style="display:none">
623
+ <input type="text" id="pair-code-input" placeholder="Enter 6-digit code" maxlength="6" />
624
+ <button class="pair-btn" id="btn-submit-pair">Pair</button>
625
+ </div>
626
+ </div>
627
+ </div>
628
+
629
+ <!-- Push notifications -->
630
+ <div class="push-section" id="push-section" style="display:none">
631
+ <button class="push-toggle" id="btn-push-toggle">
632
+ <span class="push-icon">&#128276;</span>
633
+ <span id="push-label">Enable Notifications</span>
634
+ </button>
635
+ <button class="push-test-btn" id="btn-push-test" style="display:none">Send Test</button>
636
+ </div>
637
+
638
+ <div class="sidebar-footer">
639
+ <div class="role-indicator" id="role-indicator">
640
+ <span id="role-dot"></span>
641
+ <span id="role-text"></span>
642
+ <button class="role-btn" id="btn-role" style="display:none"></button>
643
+ </div>
644
+ <button id="btn-theme" class="theme-toggle" title="Toggle theme">&#9728;</button>
645
+ <div class="status">
646
+ <div class="status-dot connecting" id="status-dot"></div>
647
+ <span id="status-text">...</span>
648
+ </div>
649
+ <div class="version">v${VERSION}</div>
650
+ </div>
651
+ </aside>
652
+ <div class="main">
653
+ <div class="tab-bar">
654
+ <button class="tab-toggle" id="btn-sidebar" title="Toggle sidebar">&#9776;</button>
655
+ <div class="tab-list" id="tab-list"></div>
656
+ <button class="tab-new" id="btn-new-tab" title="New tab">+</button>
657
+ <div class="view-switch">
658
+ <button id="btn-live" class="active">Live</button>
659
+ <button id="btn-inspect">Inspect</button>
660
+ <button id="btn-workspace">Workspace</button>
661
+ </div>
662
+ </div>
663
+ <div id="terminal"></div>
664
+ <div id="inspect">
665
+ <div id="inspect-header">
666
+ <div id="inspect-meta"></div>
667
+ <div id="inspect-search">
668
+ <input type="text" id="inspect-search-input" placeholder="Search..." />
669
+ <span class="match-count" id="inspect-match-count"></span>
670
+ </div>
671
+ </div>
672
+ <pre id="inspect-content"></pre>
673
+ </div>
674
+ <div id="workspace">
675
+ <div class="ws-search">
676
+ <input type="text" id="ws-search-input" placeholder="Search topics, artifacts, runs..." />
677
+ </div>
678
+ <div id="ws-search-results" class="ws-search-results"></div>
679
+ <div id="ws-handoff" class="ws-handoff"></div>
680
+ <div class="ws-section" id="ws-notes-section">
681
+ <div class="ws-section-title">
682
+ <span>Notes</span>
683
+ <button id="btn-handoff">Handoff</button>
684
+ </div>
685
+ <div class="ws-note-input">
686
+ <input type="text" id="ws-note-input" placeholder="Add a note..." />
687
+ <button id="btn-add-note">Add</button>
688
+ </div>
689
+ <div id="ws-notes"></div>
690
+ </div>
691
+ <div class="ws-section">
692
+ <div class="ws-section-title">
693
+ <span>Pending Approvals</span>
694
+ </div>
695
+ <div id="ws-approvals"></div>
696
+ </div>
697
+ <div class="ws-section">
698
+ <div class="ws-section-title">
699
+ <span>Topics</span>
700
+ <button id="btn-new-topic">+ New</button>
701
+ </div>
702
+ <div id="ws-topics"></div>
703
+ </div>
704
+ <div class="ws-section">
705
+ <div class="ws-section-title">
706
+ <span>Active Runs</span>
707
+ </div>
708
+ <div id="ws-runs"></div>
709
+ </div>
710
+ <div class="ws-section">
711
+ <div class="ws-section-title">
712
+ <span>Recent Artifacts</span>
713
+ <button id="btn-capture-snapshot">Capture Snapshot</button>
714
+ </div>
715
+ <div id="ws-artifacts"></div>
716
+ </div>
717
+ <div class="ws-section">
718
+ <div class="ws-section-title">
719
+ <span>Commands</span>
720
+ </div>
721
+ <div id="ws-commands"></div>
722
+ </div>
723
+ </div>
724
+ <div class="compose-bar" id="compose-bar">
725
+ <button data-seq="esc">Esc</button>
726
+ <button data-seq="tab">Tab</button>
727
+ <button data-mod="ctrl" id="btn-ctrl">Ctrl</button>
728
+ <button data-seq="up">&#8593;</button>
729
+ <button data-seq="down">&#8595;</button>
730
+ <button data-seq="left">&#8592;</button>
731
+ <button data-seq="right">&#8594;</button>
732
+ <button data-ch="|">|</button>
733
+ <button data-ch="~">~</button>
734
+ <button data-ch="/">/ </button>
735
+ <button data-seq="ctrl-c">C-c</button>
736
+ <button data-seq="ctrl-d">C-d</button>
737
+ <button data-seq="ctrl-z">C-z</button>
738
+ <button data-seq="pgup">PgUp</button>
739
+ <button data-seq="pgdn">PgDn</button>
740
+ <button data-seq="home">Home</button>
741
+ <button data-seq="end">End</button>
742
+ </div>
743
+ </div>
744
+
745
+ <script type="module">
746
+ import { init, Terminal, FitAddon } from '/dist/ghostty-web.js';
747
+ await init();
748
+
749
+ // -- Terminal color themes (ghostty-web ITheme) --
750
+ const THEMES = {
751
+ dark: {
752
+ // ghostty-web default dark
753
+ background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff',
754
+ cursorAccent: '#1e1e1e', selectionBackground: '#264f78', selectionForeground: '#ffffff',
755
+ black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
756
+ blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5',
757
+ brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b',
758
+ brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6',
759
+ brightCyan: '#29b8db', brightWhite: '#ffffff',
760
+ },
761
+ light: {
762
+ // Ghostty-style light
763
+ background: '#ffffff', foreground: '#1d1f21', cursor: '#1d1f21',
764
+ cursorAccent: '#ffffff', selectionBackground: '#b4d5fe', selectionForeground: '#1d1f21',
765
+ black: '#1d1f21', red: '#c82829', green: '#718c00', yellow: '#eab700',
766
+ blue: '#4271ae', magenta: '#8959a8', cyan: '#3e999f', white: '#d6d6d6',
767
+ brightBlack: '#969896', brightRed: '#cc6666', brightGreen: '#b5bd68',
768
+ brightYellow: '#f0c674', brightBlue: '#81a2be', brightMagenta: '#b294bb',
769
+ brightCyan: '#8abeb7', brightWhite: '#ffffff',
770
+ },
771
+ };
772
+
773
+ const initTheme = localStorage.getItem('remux-theme') ||
774
+ (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
775
+
776
+ let term, fitAddon, fitObserver = null, fitSettleTimer = null;
777
+ function createTerminal(themeMode) {
778
+ const container = document.getElementById('terminal');
779
+ if (fitObserver) { fitObserver.disconnect(); fitObserver = null; }
780
+ if (fitSettleTimer) { clearTimeout(fitSettleTimer); fitSettleTimer = null; }
781
+ if (term) { term.dispose(); container.innerHTML = ''; }
782
+ term = window._remuxTerm = new Terminal({ cols: 80, rows: 24,
783
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
784
+ fontSize: 14, cursorBlink: true,
785
+ theme: THEMES[themeMode] || THEMES.dark,
786
+ scrollback: 10000 });
787
+ fitAddon = new FitAddon();
788
+ term.loadAddon(fitAddon);
789
+ term.open(container);
790
+ fitObserver = new ResizeObserver(() => safeFit());
791
+ fitObserver.observe(container);
792
+ safeFit();
793
+ fitSettleTimer = setTimeout(() => {
794
+ fitSettleTimer = null;
795
+ safeFit();
796
+ }, 250);
797
+ if (document.fonts && document.fonts.ready) {
798
+ document.fonts.ready.then(() => safeFit()).catch(() => {});
799
+ }
800
+ return term;
801
+ }
802
+
803
+ // -- IME composition guard --
804
+ // Defer fit()/resize during active IME composition to avoid layout thrash.
805
+ // Note: ghostty-web binds composition listeners on the container, and
806
+ // browser-native composition events bubble from textarea to container,
807
+ // so no forwarding patch is needed. Just guard fit() during composition.
808
+ let _isComposing = false;
809
+ let _pendingFit = false;
810
+ let fitDebounceTimer = null;
811
+ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
812
+ function safeFit() {
813
+ if (_isComposing) { _pendingFit = true; return; }
814
+ if (fitAddon) fitAddon.fit();
815
+ }
816
+ function syncTouchViewportHeight() {
817
+ if (!window.visualViewport || !isTouchDevice || _isComposing) return;
818
+ const vh = window.visualViewport.height;
819
+ if (vh > 0) document.body.style.height = vh + 'px';
820
+ clearTimeout(fitDebounceTimer);
821
+ fitDebounceTimer = setTimeout(safeFit, 100);
822
+ }
823
+ function stabilizeFit() {
824
+ safeFit();
825
+ if (fitSettleTimer) clearTimeout(fitSettleTimer);
826
+ fitSettleTimer = setTimeout(() => {
827
+ fitSettleTimer = null;
828
+ safeFit();
829
+ }, 250);
830
+ }
831
+ window.addEventListener('resize', safeFit);
832
+ const _termContainer = document.getElementById('terminal');
833
+ _termContainer.addEventListener('compositionstart', () => { _isComposing = true; });
834
+ _termContainer.addEventListener('compositionend', () => {
835
+ _isComposing = false;
836
+ if (_pendingFit) { _pendingFit = false; stabilizeFit(); }
837
+ });
838
+ createTerminal(initTheme);
839
+
840
+ let sessions = [], currentSession = null, currentTabId = null, ws = null, ctrlActive = false;
841
+ let myClientId = null, myRole = null, clientsList = [];
842
+
843
+ // -- Predictive echo via DOM overlay (see #80 Phase 2) --
844
+ // Shows predicted characters as transparent HTML spans over the canvas.
845
+ // Does NOT inject any ANSI escape sequences into the terminal — the overlay
846
+ // is purely visual and the terminal data path is never modified.
847
+ // Adapted from VS Code TypeAheadAddon concept, using DOM overlay instead of
848
+ // xterm.js decorations (which ghostty-web lacks).
849
+ const _peOverlay = document.createElement('div');
850
+ _peOverlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:1;overflow:hidden;';
851
+ _termContainer.appendChild(_peOverlay);
852
+ const _pePreds = [];
853
+ function _peCellSize() {
854
+ // Use ghostty-web renderer's exact font metrics when available
855
+ if (term.renderer) return { w: term.renderer.charWidth, h: term.renderer.charHeight };
856
+ const cvs = _termContainer.querySelector('canvas');
857
+ if (!cvs) return { w: 8, h: 16 };
858
+ return { w: cvs.offsetWidth / term.cols, h: cvs.offsetHeight / term.rows };
859
+ }
860
+ function peOnInput(data) {
861
+ if (_isComposing) return;
862
+ if (term.buffer && term.buffer.active && term.buffer.active.type === 'alternate') return;
863
+ if (myRole && myRole !== 'active') return;
864
+ for (let i = 0; i < data.length; i++) {
865
+ const c = data.charCodeAt(i);
866
+ if (c >= 0x20 && c <= 0x7e && _pePreds.length < 32) {
867
+ const buf = term.buffer && term.buffer.active;
868
+ const cx = (buf ? buf.cursorX : 0) + _pePreds.length;
869
+ const cy = buf ? buf.cursorY : 0;
870
+ const cell = _peCellSize();
871
+ const span = document.createElement('span');
872
+ span.textContent = data[i];
873
+ span.style.cssText = 'position:absolute;display:inline-block;color:var(--text,#d4d4d4);opacity:0.6;'
874
+ + 'font-family:Menlo,Monaco,Courier New,monospace;'
875
+ + 'left:' + (cx * cell.w) + 'px;top:' + (cy * cell.h) + 'px;'
876
+ + 'width:' + cell.w + 'px;height:' + cell.h + 'px;'
877
+ + 'font-size:14px;line-height:' + cell.h + 'px;text-align:center;';
878
+ _peOverlay.appendChild(span);
879
+ _pePreds.push({ ch: data[i], span, ts: Date.now() });
880
+ } else if (c < 0x20 || c === 0x7f) {
881
+ peClearAll();
882
+ }
883
+ // Non-ASCII: skip prediction, don't clear
884
+ }
885
+ }
886
+ function peOnServerData(data) {
887
+ // Match predictions against server echo, remove confirmed overlay spans.
888
+ // NEVER modify or consume data — always pass full data to term.write().
889
+ if (_pePreds.length === 0) return;
890
+ for (let i = 0; i < data.length && _pePreds.length > 0; i++) {
891
+ if (data.charCodeAt(i) === 0x1b) { peClearAll(); return; }
892
+ if (data[i] === _pePreds[0].ch) {
893
+ const p = _pePreds.shift();
894
+ p.span.remove();
895
+ } else { peClearAll(); return; }
896
+ }
897
+ }
898
+ function peClearAll() {
899
+ for (const p of _pePreds) p.span.remove();
900
+ _pePreds.length = 0;
901
+ }
902
+ const $ = id => document.getElementById(id);
903
+ const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
904
+ const setStatus = (s, t) => { $('status-dot').className = 'status-dot ' + s; $('status-text').textContent = t; };
905
+
906
+ // -- Theme switching --
907
+ function setTheme(mode) {
908
+ document.documentElement.setAttribute('data-theme', mode);
909
+ localStorage.setItem('remux-theme', mode);
910
+ $('btn-theme').innerHTML = mode === 'dark' ? '&#9728;' : '&#9790;';
911
+ // Recreate terminal with new theme (ghostty-web doesn't support runtime theme change)
912
+ createTerminal(mode);
913
+ peClearAll();
914
+ // Rebind terminal I/O
915
+ term.onData(data => {
916
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
917
+ if (ctrlActive) {
918
+ ctrlActive = false; $('btn-ctrl').classList.remove('active');
919
+ const ch = data.toLowerCase().charCodeAt(0);
920
+ if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
921
+ }
922
+ peOnInput(data);
923
+ sendTermData(data);
924
+ });
925
+ term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
926
+ // Re-attach to current tab to get snapshot
927
+ if (currentTabId != null) {
928
+ sendCtrl({ type: 'attach_tab', tabId: currentTabId, cols: term.cols, rows: term.rows });
929
+ }
930
+ }
931
+ // Apply initial theme CSS (terminal already created with correct theme)
932
+ document.documentElement.setAttribute('data-theme', initTheme);
933
+ $('btn-theme').innerHTML = initTheme === 'dark' ? '&#9728;' : '&#9790;';
934
+ $('btn-theme').addEventListener('click', () => {
935
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
936
+ setTheme(current === 'dark' ? 'light' : 'dark');
937
+ });
938
+
939
+ // -- Sidebar --
940
+ const sidebar = $('sidebar'), overlay = $('sidebar-overlay');
941
+ function toggleSidebar() {
942
+ if (window.innerWidth <= 768) {
943
+ sidebar.classList.remove('collapsed');
944
+ sidebar.classList.toggle('open');
945
+ overlay.classList.toggle('visible', sidebar.classList.contains('open'));
946
+ } else { sidebar.classList.toggle('collapsed'); }
947
+ setTimeout(stabilizeFit, 250);
948
+ }
949
+ function closeSidebarMobile() {
950
+ if (window.innerWidth <= 768) { sidebar.classList.remove('open'); overlay.classList.remove('visible'); }
951
+ }
952
+ $('btn-sidebar').addEventListener('pointerdown', e => { e.preventDefault(); toggleSidebar(); });
953
+ overlay.addEventListener('pointerdown', closeSidebarMobile);
954
+
955
+ // -- Render sessions --
956
+ function renderSessions() {
957
+ const list = $('session-list'); list.innerHTML = '';
958
+ sessions.forEach(s => {
959
+ const el = document.createElement('button');
960
+ el.className = 'session-item' + (s.name === currentSession ? ' active' : '');
961
+ const live = s.tabs.filter(t => !t.ended).length;
962
+ el.innerHTML = '<span class="dot"></span><span class="name">' + esc(s.name)
963
+ + '</span><span class="count">' + live + '</span>'
964
+ + '<button class="del" data-del="' + esc(s.name) + '">\u00d7</button>';
965
+ el.addEventListener('pointerdown', e => {
966
+ if (e.target.dataset.del) {
967
+ e.stopPropagation(); e.preventDefault();
968
+ if (!confirm('Delete session "' + e.target.dataset.del + '"? All tabs will be closed.')) return;
969
+ sendCtrl({ type: 'delete_session', name: e.target.dataset.del });
970
+ // if deleting current, switch to another or create fresh
971
+ if (e.target.dataset.del === currentSession) {
972
+ const other = sessions.find(x => x.name !== currentSession);
973
+ if (other) {
974
+ selectSession(other.name);
975
+ } else {
976
+ // Last session deleted — re-bootstrap via attach_first
977
+ currentSession = null;
978
+ sendCtrl({ type: 'attach_first', cols: term.cols, rows: term.rows });
979
+ }
980
+ }
981
+ return;
982
+ }
983
+ e.preventDefault();
984
+ selectSession(s.name);
985
+ closeSidebarMobile();
986
+ });
987
+ list.appendChild(el);
988
+ });
989
+ }
990
+
991
+ // -- Render tabs (Chrome-style) --
992
+ function renderTabs() {
993
+ const list = $('tab-list'); list.innerHTML = '';
994
+ const sess = sessions.find(s => s.name === currentSession);
995
+ if (!sess) return;
996
+ sess.tabs.forEach(t => {
997
+ const el = document.createElement('button');
998
+ el.className = 'tab' + (t.id === currentTabId ? ' active' : '');
999
+ const clientCount = t.clients || 0;
1000
+ const countBadge = clientCount > 1 ? '<span class="client-count">' + clientCount + '</span>' : '';
1001
+ el.innerHTML = '<span class="title">' + esc(t.title) + '</span>' + countBadge
1002
+ + '<button class="close" data-close="' + t.id + '">\u00d7</button>';
1003
+ el.addEventListener('pointerdown', e => {
1004
+ const closeId = e.target.dataset.close ?? e.target.closest('[data-close]')?.dataset.close;
1005
+ if (closeId != null) {
1006
+ e.stopPropagation(); e.preventDefault();
1007
+ closeTab(Number(closeId));
1008
+ return;
1009
+ }
1010
+ e.preventDefault();
1011
+ if (t.id !== currentTabId) attachTab(t.id);
1012
+ });
1013
+ list.appendChild(el);
1014
+ });
1015
+ }
1016
+
1017
+ // -- Render role indicator --
1018
+ function renderRole() {
1019
+ const indicator = $('role-indicator');
1020
+ const dot = $('role-dot');
1021
+ const text = $('role-text');
1022
+ const btn = $('btn-role');
1023
+ if (!indicator || !myRole) return;
1024
+ indicator.className = 'role-indicator ' + myRole;
1025
+ if (myRole === 'active') {
1026
+ dot.textContent = '\u25cf';
1027
+ text.textContent = 'Active';
1028
+ btn.textContent = 'Release';
1029
+ btn.style.display = 'inline-block';
1030
+ // Auto-focus terminal when becoming active so keystrokes reach xterm
1031
+ if (currentView === 'live') setTimeout(() => term.focus(), 50);
1032
+ } else {
1033
+ dot.textContent = '\u25cb';
1034
+ text.textContent = 'Observer';
1035
+ btn.textContent = 'Take control';
1036
+ btn.style.display = 'inline-block';
1037
+ }
1038
+ }
1039
+ $('btn-role').addEventListener('click', () => {
1040
+ if (myRole === 'active') sendCtrl({ type: 'release_control' });
1041
+ else sendCtrl({ type: 'request_control' });
1042
+ });
1043
+
1044
+ function selectSession(name) {
1045
+ currentSession = name;
1046
+ const sess = sessions.find(s => s.name === name);
1047
+ if (sess && sess.tabs.length > 0) attachTab(sess.tabs[0].id);
1048
+ renderSessions(); renderTabs();
1049
+ }
1050
+
1051
+ function attachTab(tabId) {
1052
+ currentTabId = tabId;
1053
+ term.reset(); // full reset to avoid duplicate content
1054
+ sendCtrl({ type: 'attach_tab', tabId, cols: term.cols, rows: term.rows });
1055
+ stabilizeFit();
1056
+ renderTabs(); renderSessions();
1057
+ }
1058
+
1059
+ function closeTab(tabId) {
1060
+ const sess = sessions.find(s => s.name === currentSession);
1061
+ if (!sess) return;
1062
+ // if closing active tab, switch to neighbor first
1063
+ if (tabId === currentTabId) {
1064
+ const idx = sess.tabs.findIndex(t => t.id === tabId);
1065
+ const next = sess.tabs[idx + 1] || sess.tabs[idx - 1];
1066
+ if (next) attachTab(next.id);
1067
+ }
1068
+ sendCtrl({ type: 'close_tab', tabId });
1069
+ }
1070
+
1071
+ $('btn-new-tab').addEventListener('pointerdown', e => {
1072
+ e.preventDefault();
1073
+ sendCtrl({ type: 'new_tab', session: currentSession, cols: term.cols, rows: term.rows });
1074
+ });
1075
+ $('btn-new-session').addEventListener('pointerdown', e => {
1076
+ e.preventDefault();
1077
+ const name = prompt('Session name:');
1078
+ if (name && name.trim()) sendCtrl({ type: 'new_session', name: name.trim(), cols: term.cols, rows: term.rows });
1079
+ });
1080
+
1081
+ // -- E2EE client (Web Crypto API) --
1082
+ // Adapted from Signal Protocol X25519+AES-GCM pattern
1083
+ const e2ee = {
1084
+ established: false,
1085
+ sendCounter: 0n,
1086
+ recvCounter: -1n,
1087
+ localKeyPair: null, // { publicKey: CryptoKey, privateKey: CryptoKey, rawPublic: Uint8Array }
1088
+ sharedKey: null, // CryptoKey (AES-GCM)
1089
+ available: !!(crypto && crypto.subtle),
1090
+
1091
+ async init() {
1092
+ if (!this.available) return;
1093
+ try {
1094
+ const kp = await crypto.subtle.generateKey('X25519', false, ['deriveBits']);
1095
+ const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
1096
+ this.localKeyPair = { publicKey: kp.publicKey, privateKey: kp.privateKey, rawPublic: rawPub };
1097
+ } catch (e) {
1098
+ console.warn('[e2ee] X25519 not available:', e);
1099
+ this.available = false;
1100
+ }
1101
+ },
1102
+
1103
+ getPublicKeyB64() {
1104
+ if (!this.localKeyPair) return null;
1105
+ return btoa(String.fromCharCode(...this.localKeyPair.rawPublic));
1106
+ },
1107
+
1108
+ async completeHandshake(peerPubKeyB64) {
1109
+ if (!this.localKeyPair) return;
1110
+ try {
1111
+ const peerRaw = Uint8Array.from(atob(peerPubKeyB64), c => c.charCodeAt(0));
1112
+ const peerKey = await crypto.subtle.importKey('raw', peerRaw, 'X25519', false, []);
1113
+ // ECDH: derive raw shared bits
1114
+ const rawBits = await crypto.subtle.deriveBits(
1115
+ { name: 'X25519', public: peerKey },
1116
+ this.localKeyPair.privateKey,
1117
+ 256
1118
+ );
1119
+ // HKDF-SHA256 to derive AES-256-GCM key
1120
+ const hkdfKey = await crypto.subtle.importKey('raw', rawBits, 'HKDF', false, ['deriveBits']);
1121
+ const salt = new TextEncoder().encode('remux-e2ee-v1');
1122
+ const info = new TextEncoder().encode('aes-256-gcm');
1123
+ const derived = await crypto.subtle.deriveBits(
1124
+ { name: 'HKDF', hash: 'SHA-256', salt, info },
1125
+ hkdfKey,
1126
+ 256
1127
+ );
1128
+ this.sharedKey = await crypto.subtle.importKey('raw', derived, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
1129
+ this.established = true;
1130
+ this.sendCounter = 0n;
1131
+ this.recvCounter = -1n;
1132
+ console.log('[e2ee] handshake complete');
1133
+ } catch (e) {
1134
+ console.error('[e2ee] handshake failed:', e);
1135
+ this.available = false;
1136
+ }
1137
+ },
1138
+
1139
+ async encryptMessage(plaintext) {
1140
+ if (!this.sharedKey) throw new Error('E2EE not established');
1141
+ const plaintextBuf = new TextEncoder().encode(plaintext);
1142
+ // IV: 4 random bytes + 8 byte counter (big-endian)
1143
+ const iv = new Uint8Array(12);
1144
+ crypto.getRandomValues(iv.subarray(0, 4));
1145
+ const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
1146
+ counterView.setBigUint64(0, this.sendCounter, false);
1147
+ this.sendCounter++;
1148
+ const encrypted = await crypto.subtle.encrypt(
1149
+ { name: 'AES-GCM', iv, tagLength: 128 },
1150
+ this.sharedKey,
1151
+ plaintextBuf
1152
+ );
1153
+ // AES-GCM returns ciphertext + tag concatenated
1154
+ const result = new Uint8Array(12 + encrypted.byteLength);
1155
+ result.set(iv, 0);
1156
+ result.set(new Uint8Array(encrypted), 12);
1157
+ return btoa(String.fromCharCode(...result));
1158
+ },
1159
+
1160
+ async decryptMessage(encryptedB64) {
1161
+ if (!this.sharedKey) throw new Error('E2EE not established');
1162
+ const packed = Uint8Array.from(atob(encryptedB64), c => c.charCodeAt(0));
1163
+ if (packed.length < 28) throw new Error('E2EE message too short'); // 12 iv + 16 tag minimum
1164
+ const iv = packed.subarray(0, 12);
1165
+ const ciphertextWithTag = packed.subarray(12);
1166
+ // Anti-replay: check counter is monotonically increasing
1167
+ const counterView = new DataView(iv.buffer, iv.byteOffset + 4, 8);
1168
+ const counter = counterView.getBigUint64(0, false);
1169
+ if (counter <= this.recvCounter) throw new Error('E2EE replay detected');
1170
+ const decrypted = await crypto.subtle.decrypt(
1171
+ { name: 'AES-GCM', iv, tagLength: 128 },
1172
+ this.sharedKey,
1173
+ ciphertextWithTag
1174
+ );
1175
+ this.recvCounter = counter;
1176
+ return new TextDecoder().decode(decrypted);
1177
+ }
1178
+ };
1179
+
1180
+ // -- WebSocket with exponential backoff + heartbeat --
1181
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1182
+ const urlToken = new URLSearchParams(location.search).get('token');
1183
+
1184
+ // Exponential backoff: 1s -> 2s -> 4s -> 8s -> 16s -> 30s max
1185
+ let backoffMs = 1000;
1186
+ const BACKOFF_MAX = 30000;
1187
+ let reconnectTimer = null;
1188
+
1189
+ // Heartbeat: if no message received for 45s, consider connection dead
1190
+ const HEARTBEAT_TIMEOUT = 45000;
1191
+ let lastMessageAt = Date.now();
1192
+ let heartbeatChecker = null;
1193
+
1194
+ // Track last received message timestamp for session recovery
1195
+ let lastReceivedTimestamp = 0;
1196
+ let isResuming = false;
1197
+
1198
+ function startHeartbeat() {
1199
+ lastMessageAt = Date.now();
1200
+ if (heartbeatChecker) clearInterval(heartbeatChecker);
1201
+ heartbeatChecker = setInterval(() => {
1202
+ if (Date.now() - lastMessageAt > HEARTBEAT_TIMEOUT) {
1203
+ console.log('[remux] heartbeat timeout, reconnecting');
1204
+ if (ws) ws.close();
1205
+ }
1206
+ }, 5000);
1207
+ }
1208
+
1209
+ function stopHeartbeat() {
1210
+ if (heartbeatChecker) { clearInterval(heartbeatChecker); heartbeatChecker = null; }
1211
+ }
1212
+
1213
+ function scheduleReconnect() {
1214
+ if (reconnectTimer) return;
1215
+ const delay = backoffMs;
1216
+ let remaining = Math.ceil(delay / 1000);
1217
+ setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
1218
+ const countdown = setInterval(() => {
1219
+ remaining--;
1220
+ if (remaining > 0) setStatus('disconnected', 'Reconnecting in ' + remaining + 's...');
1221
+ }, 1000);
1222
+ reconnectTimer = setTimeout(() => {
1223
+ clearInterval(countdown);
1224
+ reconnectTimer = null;
1225
+ backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX);
1226
+ connect();
1227
+ }, delay);
1228
+ }
1229
+
1230
+ function connect() {
1231
+ setStatus('connecting', 'Connecting...');
1232
+ e2ee.established = false;
1233
+ e2ee.sharedKey = null;
1234
+ ws = new WebSocket(proto + '//' + location.host + '/ws');
1235
+ ws.onopen = async () => {
1236
+ backoffMs = 1000; // reset backoff on successful connection
1237
+ startHeartbeat();
1238
+ if (urlToken) {
1239
+ // Use persistent device ID from localStorage so each browser context
1240
+ // is a distinct device even with identical User-Agent
1241
+ if (!localStorage.getItem('remux-device-id')) {
1242
+ localStorage.setItem('remux-device-id', Math.random().toString(36).slice(2, 10) + Date.now().toString(36));
1243
+ }
1244
+ ws.send(JSON.stringify({ type: 'auth', token: urlToken, deviceId: localStorage.getItem('remux-device-id') }));
1245
+ }
1246
+ // Initiate E2EE handshake if Web Crypto API is available
1247
+ if (e2ee.available) {
1248
+ await e2ee.init();
1249
+ const pubKey = e2ee.getPublicKeyB64();
1250
+ if (pubKey) {
1251
+ ws.send(JSON.stringify({ v: 1, type: 'e2ee_init', payload: { publicKey: pubKey } }));
1252
+ }
1253
+ }
1254
+ // Session recovery: if we have a previous timestamp, request buffered messages
1255
+ const deviceId = localStorage.getItem('remux-device-id');
1256
+ if (lastReceivedTimestamp > 0 && deviceId) {
1257
+ isResuming = true;
1258
+ setStatus('connecting', 'Resuming session...');
1259
+ sendCtrl({ type: 'resume', deviceId: deviceId, lastTimestamp: lastReceivedTimestamp });
1260
+ }
1261
+ // Let server pick the session if we have none (bootstrap flow)
1262
+ sendCtrl({ type: 'attach_first', session: currentSession || undefined, cols: term.cols, rows: term.rows });
1263
+ // Request device list (works with or without auth)
1264
+ sendCtrl({ type: 'list_devices' });
1265
+ // Request VAPID key for push notifications
1266
+ sendCtrl({ type: 'get_vapid_key' });
1267
+ };
1268
+ ws.onmessage = e => {
1269
+ lastMessageAt = Date.now();
1270
+ if (typeof e.data === 'string' && e.data[0] === '{') {
1271
+ try {
1272
+ const parsed = JSON.parse(e.data);
1273
+ // Handle both envelope (v:1) and legacy messages
1274
+ // Unwrap envelope: spread payload first, then override type with the
1275
+ // envelope's type to prevent payload.type (e.g. artifact type "snapshot")
1276
+ // from colliding with the message type (e.g. "snapshot_captured")
1277
+ const msg = parsed.v === 1 ? { ...(parsed.payload || {}), type: parsed.type } : parsed;
1278
+ // Server heartbeat — just keep connection alive (lastMessageAt already updated)
1279
+ if (msg.type === 'ping') return;
1280
+ // E2EE handshake: server responds with its public key
1281
+ if (msg.type === 'e2ee_init') {
1282
+ if (msg.publicKey && e2ee.available && e2ee.localKeyPair) {
1283
+ e2ee.completeHandshake(msg.publicKey);
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (msg.type === 'e2ee_ready') {
1288
+ console.log('[e2ee] server confirmed E2EE established');
1289
+ return;
1290
+ }
1291
+ // E2EE encrypted message (terminal output from server)
1292
+ if (msg.type === 'e2ee_msg') {
1293
+ if (e2ee.established && msg.data) {
1294
+ e2ee.decryptMessage(msg.data).then(decrypted => {
1295
+ term.write(decrypted);
1296
+ }).catch(err => console.error('[e2ee] decrypt failed:', err));
1297
+ }
1298
+ return;
1299
+ }
1300
+ // Session recovery complete
1301
+ if (msg.type === 'resume_complete') {
1302
+ isResuming = false;
1303
+ if (msg.replayed > 0) {
1304
+ console.log('[remux] session recovered: ' + msg.replayed + ' buffered messages replayed');
1305
+ }
1306
+ return;
1307
+ }
1308
+ // Track timestamp for session recovery on reconnect
1309
+ lastReceivedTimestamp = Date.now();
1310
+ if (msg.type === 'auth_ok') {
1311
+ if (msg.deviceId) myDeviceId = msg.deviceId;
1312
+ // Request device list and workspace data after auth
1313
+ sendCtrl({ type: 'list_devices' });
1314
+ sendCtrl({ type: 'list_notes' });
1315
+ return;
1316
+ }
1317
+ if (msg.type === 'bootstrap') {
1318
+ sessions = msg.sessions || [];
1319
+ clientsList = msg.clients || [];
1320
+ renderSessions(); renderTabs(); renderRole(); stabilizeFit();
1321
+ return;
1322
+ }
1323
+ if (msg.type === 'auth_error') { setStatus('disconnected', 'Auth failed'); ws.close(); return; }
1324
+ // Generic server error — show to user (e.g. pair code trust errors)
1325
+ if (msg.type === 'error') {
1326
+ console.warn('[remux] server error:', msg.reason || 'unknown');
1327
+ alert('Error: ' + (msg.reason || 'unknown error'));
1328
+ return;
1329
+ }
1330
+ if (msg.type === 'device_list') {
1331
+ devicesList = msg.devices || [];
1332
+ renderDevices(); return;
1333
+ }
1334
+ if (msg.type === 'pair_code') {
1335
+ const display = $('pair-code-display');
1336
+ if (display) {
1337
+ display.style.display = 'block';
1338
+ $('pair-code-value').textContent = msg.code;
1339
+ const remaining = Math.max(0, Math.ceil((msg.expiresAt - Date.now()) / 1000));
1340
+ $('pair-expires').textContent = 'Expires in ' + Math.ceil(remaining / 60) + ' min';
1341
+ setTimeout(() => { display.style.display = 'none'; }, remaining * 1000);
1342
+ }
1343
+ return;
1344
+ }
1345
+ if (msg.type === 'pair_result') {
1346
+ if (msg.success) {
1347
+ $('pair-code-input').value = '';
1348
+ sendCtrl({ type: 'list_devices' });
1349
+ } else {
1350
+ alert('Pairing failed: ' + (msg.reason || 'invalid code'));
1351
+ }
1352
+ return;
1353
+ }
1354
+ if (msg.type === 'vapid_key') {
1355
+ pushVapidKey = msg.publicKey;
1356
+ showPushSection();
1357
+ // Check current push status
1358
+ sendCtrl({ type: 'get_push_status' });
1359
+ return;
1360
+ }
1361
+ if (msg.type === 'push_subscribed') {
1362
+ pushSubscribed = msg.success;
1363
+ updatePushUI();
1364
+ return;
1365
+ }
1366
+ if (msg.type === 'push_unsubscribed') {
1367
+ pushSubscribed = false;
1368
+ updatePushUI();
1369
+ return;
1370
+ }
1371
+ if (msg.type === 'push_status') {
1372
+ pushSubscribed = msg.subscribed;
1373
+ updatePushUI();
1374
+ return;
1375
+ }
1376
+ if (msg.type === 'push_test_result') {
1377
+ // Brief visual feedback
1378
+ const testBtn = $('btn-push-test');
1379
+ if (testBtn) {
1380
+ testBtn.textContent = msg.sent ? 'Sent!' : 'Failed';
1381
+ setTimeout(() => { testBtn.textContent = 'Send Test'; }, 2000);
1382
+ }
1383
+ return;
1384
+ }
1385
+ if (msg.type === 'state') {
1386
+ sessions = msg.sessions || [];
1387
+ clientsList = msg.clients || [];
1388
+ // Re-derive own role from authoritative server state
1389
+ if (myClientId) {
1390
+ const me = clientsList.find(c => c.clientId === myClientId);
1391
+ if (me) myRole = me.role;
1392
+ }
1393
+ renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
1394
+ }
1395
+ if (msg.type === 'attached') {
1396
+ currentTabId = msg.tabId; currentSession = msg.session;
1397
+ if (msg.clientId) myClientId = msg.clientId;
1398
+ if (msg.role) myRole = msg.role;
1399
+ setStatus('connected', msg.session); renderSessions(); renderTabs(); renderRole(); stabilizeFit(); return;
1400
+ }
1401
+ if (msg.type === 'role_changed') {
1402
+ if (msg.clientId === myClientId) myRole = msg.role;
1403
+ renderRole(); return;
1404
+ }
1405
+ if (msg.type === 'inspect_result') {
1406
+ window._inspectText = msg.text || '(empty)';
1407
+ const m = msg.meta || {};
1408
+ $('inspect-meta').innerHTML =
1409
+ '<span>' + esc(m.session) + ' / ' + esc(m.tabTitle || 'Tab ' + m.tabId) + '</span>' +
1410
+ '<span>' + (m.cols || '?') + 'x' + (m.rows || '?') + '</span>' +
1411
+ '<span>' + new Date(m.timestamp || Date.now()).toLocaleTimeString() + '</span>' +
1412
+ '<button class="inspect-btn" id="btn-copy-inspect">Copy</button>';
1413
+ $('btn-copy-inspect').addEventListener('click', () => {
1414
+ navigator.clipboard.writeText(window._inspectText).then(() => {
1415
+ $('btn-copy-inspect').textContent = 'Copied!';
1416
+ setTimeout(() => { const el = $('btn-copy-inspect'); if (el) el.textContent = 'Copy'; }, 1500);
1417
+ });
1418
+ });
1419
+ // Apply search highlight if active
1420
+ applyInspectSearch();
1421
+ return;
1422
+ }
1423
+ // Workspace message handlers
1424
+ if (msg.type === 'topic_list') { wsTopics = msg.topics || []; renderWorkspaceTopics(); return; }
1425
+ if (msg.type === 'topic_created') {
1426
+ // Optimistic render: add topic directly
1427
+ if (msg.id && msg.title) wsTopics.unshift({ id: msg.id, sessionName: msg.sessionName, title: msg.title, createdAt: msg.createdAt, updatedAt: msg.updatedAt });
1428
+ renderWorkspaceTopics();
1429
+ return;
1430
+ }
1431
+ if (msg.type === 'topic_deleted') { refreshWorkspace(); return; }
1432
+ if (msg.type === 'run_list') { wsRuns = msg.runs || []; renderWorkspaceRuns(); return; }
1433
+ if (msg.type === 'run_created' || msg.type === 'run_updated') { if (currentView === 'workspace') refreshWorkspace(); return; }
1434
+ if (msg.type === 'artifact_list') { wsArtifacts = msg.artifacts || []; renderWorkspaceArtifacts(); return; }
1435
+ if (msg.type === 'snapshot_captured') {
1436
+ // Optimistic render: add artifact directly (with server-rendered HTML)
1437
+ if (msg.id) wsArtifacts.unshift({ id: msg.id, type: 'snapshot', title: msg.title || 'Snapshot', content: msg.content, contentType: msg.contentType || 'plain', renderedHtml: msg.renderedHtml, createdAt: msg.createdAt || Date.now() });
1438
+ renderWorkspaceArtifacts();
1439
+ return;
1440
+ }
1441
+ if (msg.type === 'approval_list') { wsApprovals = msg.approvals || []; renderWorkspaceApprovals(); return; }
1442
+ if (msg.type === 'approval_created') { if (currentView === 'workspace') refreshWorkspace(); return; }
1443
+ if (msg.type === 'approval_resolved') { if (currentView === 'workspace') refreshWorkspace(); return; }
1444
+ // Search results
1445
+ if (msg.type === 'search_results') { renderSearchResults(msg.results || []); return; }
1446
+ // Handoff bundle
1447
+ if (msg.type === 'handoff_bundle') { renderHandoffBundle(msg); return; }
1448
+ // Notes
1449
+ if (msg.type === 'note_list') { wsNotes = msg.notes || []; renderNotes(); return; }
1450
+ if (msg.type === 'note_created') {
1451
+ // Optimistic render: add note directly without waiting for list refresh
1452
+ if (msg.id && msg.content) wsNotes.unshift({ id: msg.id, content: msg.content, pinned: msg.pinned || false, createdAt: msg.createdAt, updatedAt: msg.updatedAt });
1453
+ renderNotes();
1454
+ return;
1455
+ }
1456
+ if (msg.type === 'note_updated' || msg.type === 'note_deleted' || msg.type === 'note_pinned') { sendCtrl({ type: 'list_notes' }); return; }
1457
+ // Commands
1458
+ if (msg.type === 'command_list') { wsCommands = msg.commands || []; renderCommands(); return; }
1459
+ // Unrecognized enveloped control message — discard, never write to terminal
1460
+ if (parsed.v === 1) {
1461
+ console.warn('[remux] unhandled message type:', msg.type);
1462
+ return;
1463
+ }
1464
+ // Non-enveloped JSON (e.g. PTY output that looks like JSON) — fall through to term.write
1465
+ } catch {}
1466
+ }
1467
+ peOnServerData(e.data);
1468
+ term.write(e.data);
1469
+ };
1470
+ ws.onclose = () => { stopHeartbeat(); peClearAll(); scheduleReconnect(); };
1471
+ ws.onerror = () => setStatus('disconnected', 'Error');
1472
+ }
1473
+ connect();
1474
+ function sendCtrl(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }
1475
+ // Send terminal data, encrypting if E2EE is established
1476
+ function sendTermData(data) {
1477
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1478
+ if (e2ee.established) {
1479
+ e2ee.encryptMessage(data).then(encrypted => {
1480
+ if (ws && ws.readyState === WebSocket.OPEN) {
1481
+ ws.send(JSON.stringify({ v: 1, type: 'e2ee_msg', payload: { data: encrypted } }));
1482
+ }
1483
+ }).catch(err => {
1484
+ console.error('[e2ee] encrypt failed, sending plaintext:', err);
1485
+ ws.send(data);
1486
+ });
1487
+ } else {
1488
+ ws.send(data);
1489
+ }
1490
+ }
1491
+
1492
+ // -- Terminal I/O --
1493
+ term.onData(data => {
1494
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1495
+ if (ctrlActive) {
1496
+ ctrlActive = false; $('btn-ctrl').classList.remove('active');
1497
+ const ch = data.toLowerCase().charCodeAt(0);
1498
+ if (ch >= 0x61 && ch <= 0x7a) { sendTermData(String.fromCharCode(ch - 0x60)); return; }
1499
+ }
1500
+ peOnInput(data);
1501
+ sendTermData(data);
1502
+ });
1503
+ term.onResize(({ cols, rows }) => sendCtrl({ type: 'resize', cols, rows }));
1504
+
1505
+ // -- Compose bar --
1506
+ const SEQ = {
1507
+ esc: '\\x1b', tab: '\\t',
1508
+ up: '\\x1b[A', down: '\\x1b[B', left: '\\x1b[D', right: '\\x1b[C',
1509
+ 'ctrl-c': '\\x03', 'ctrl-d': '\\x04', 'ctrl-z': '\\x1a',
1510
+ pgup: '\\x1b[5~', pgdn: '\\x1b[6~', home: '\\x1b[H', end: '\\x1b[F',
1511
+ };
1512
+ $('compose-bar').addEventListener('pointerdown', e => {
1513
+ const btn = e.target.closest('button'); if (!btn) return;
1514
+ e.preventDefault();
1515
+ if (btn.dataset.mod === 'ctrl') { ctrlActive = !ctrlActive; btn.classList.toggle('active', ctrlActive); return; }
1516
+ const d = SEQ[btn.dataset.seq] || btn.dataset.ch;
1517
+ if (d) sendTermData(d);
1518
+ term.focus();
1519
+ });
1520
+
1521
+ // -- Inspect view --
1522
+ let currentView = 'live', inspectTimer = null, wsRefreshTimer = null;
1523
+ function setView(mode) {
1524
+ currentView = mode;
1525
+ $('btn-live').classList.toggle('active', mode === 'live');
1526
+ $('btn-inspect').classList.toggle('active', mode === 'inspect');
1527
+ $('btn-workspace').classList.toggle('active', mode === 'workspace');
1528
+ $('terminal').classList.toggle('hidden', mode !== 'live');
1529
+ $('inspect').classList.toggle('visible', mode === 'inspect');
1530
+ $('workspace').classList.toggle('visible', mode === 'workspace');
1531
+ // Inspect auto-refresh
1532
+ if (inspectTimer) { clearInterval(inspectTimer); inspectTimer = null; }
1533
+ if (mode === 'inspect') {
1534
+ sendCtrl({ type: 'inspect' });
1535
+ inspectTimer = setInterval(() => sendCtrl({ type: 'inspect' }), 3000);
1536
+ }
1537
+ // Workspace auto-refresh
1538
+ if (wsRefreshTimer) { clearInterval(wsRefreshTimer); wsRefreshTimer = null; }
1539
+ if (mode === 'workspace') {
1540
+ refreshWorkspace();
1541
+ wsRefreshTimer = setInterval(refreshWorkspace, 5000);
1542
+ }
1543
+ if (mode === 'live') { term.focus(); stabilizeFit(); }
1544
+ }
1545
+ $('btn-live').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('live'); });
1546
+ $('btn-inspect').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('inspect'); });
1547
+ $('btn-workspace').addEventListener('pointerdown', e => { e.preventDefault(); closeSidebarMobile(); setView('workspace'); });
1548
+
1549
+ // -- Inspect search --
1550
+ function applyInspectSearch() {
1551
+ const query = ($('inspect-search-input') || {}).value || '';
1552
+ const text = window._inspectText || '';
1553
+ if (!query) {
1554
+ $('inspect-content').textContent = text;
1555
+ $('inspect-match-count').textContent = '';
1556
+ return;
1557
+ }
1558
+ // Simple case-insensitive text search with <mark> highlighting
1559
+ // Work on raw text to avoid HTML entity issues, then escape each fragment
1560
+ const esc = t => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1561
+ const q = query.toLowerCase();
1562
+ const lower = text.toLowerCase();
1563
+ let result = '', count = 0, pos = 0;
1564
+ while (pos < text.length) {
1565
+ const idx = lower.indexOf(q, pos);
1566
+ if (idx === -1) { result += esc(text.slice(pos)); break; }
1567
+ result += esc(text.slice(pos, idx)) + '<mark>' + esc(text.slice(idx, idx + q.length)) + '</mark>';
1568
+ count++; pos = idx + q.length;
1569
+ }
1570
+ $('inspect-content').innerHTML = result;
1571
+ $('inspect-match-count').textContent = count > 0 ? count + ' match' + (count !== 1 ? 'es' : '') : 'No matches';
1572
+ }
1573
+ $('inspect-search-input').addEventListener('input', applyInspectSearch);
1574
+
1575
+ // -- Devices section --
1576
+ let devicesList = [], myDeviceId = null, devicesCollapsed = false;
1577
+
1578
+ function renderDevices() {
1579
+ const list = $('devices-list');
1580
+ const actions = $('devices-actions');
1581
+ if (!list) return;
1582
+ list.innerHTML = '';
1583
+ devicesList.forEach(d => {
1584
+ const el = document.createElement('div');
1585
+ el.className = 'device-item';
1586
+ const isSelf = d.id === myDeviceId;
1587
+ el.innerHTML = '<span class="device-dot ' + esc(d.trust) + '"></span>'
1588
+ + '<span class="device-name">' + esc(d.name) + (isSelf ? ' <span class="device-self">(you)</span>' : '') + '</span>'
1589
+ + '<span class="device-actions">'
1590
+ + (d.trust !== 'trusted' ? '<button data-trust="' + d.id + '" title="Trust">&#10003;</button>' : '')
1591
+ + (d.trust !== 'blocked' ? '<button data-block="' + d.id + '" title="Block">&#10007;</button>' : '')
1592
+ + '<button data-rename-dev="' + d.id + '" title="Rename">&#9998;</button>'
1593
+ + (!isSelf ? '<button data-revoke="' + d.id + '" title="Revoke">&#128465;</button>' : '')
1594
+ + '</span>';
1595
+ el.addEventListener('click', e => {
1596
+ const btn = e.target.closest('button');
1597
+ if (!btn) return;
1598
+ if (btn.dataset.trust) sendCtrl({ type: 'trust_device', deviceId: btn.dataset.trust });
1599
+ if (btn.dataset.block) sendCtrl({ type: 'block_device', deviceId: btn.dataset.block });
1600
+ if (btn.dataset.renameDev) {
1601
+ const newName = prompt('Device name:', d.name);
1602
+ if (newName && newName.trim()) sendCtrl({ type: 'rename_device', deviceId: btn.dataset.renameDev, name: newName.trim() });
1603
+ }
1604
+ if (btn.dataset.revoke) {
1605
+ if (confirm('Revoke device "' + d.name + '"?')) sendCtrl({ type: 'revoke_device', deviceId: btn.dataset.revoke });
1606
+ }
1607
+ });
1608
+ list.appendChild(el);
1609
+ });
1610
+
1611
+ // Show actions only for trusted devices; untrusted see pair input instead
1612
+ const isTrusted = devicesList.find(d => d.id === myDeviceId && d.trust === 'trusted');
1613
+ if (actions) {
1614
+ actions.style.display = 'block';
1615
+ const btnPair = $('btn-pair');
1616
+ if (btnPair) {
1617
+ btnPair.disabled = !isTrusted;
1618
+ btnPair.title = isTrusted ? '' : 'Only trusted devices can generate pair codes';
1619
+ }
1620
+ // Show pair input for untrusted devices
1621
+ const pairInput = $('pair-input-area');
1622
+ if (pairInput) pairInput.style.display = isTrusted ? 'none' : 'flex';
1623
+ }
1624
+ }
1625
+
1626
+ $('devices-header').addEventListener('click', () => {
1627
+ devicesCollapsed = !devicesCollapsed;
1628
+ $('devices-list').classList.toggle('collapsed', devicesCollapsed);
1629
+ $('devices-toggle').classList.toggle('collapsed', devicesCollapsed);
1630
+ if ($('devices-actions')) $('devices-actions').style.display = devicesCollapsed ? 'none' : '';
1631
+ });
1632
+
1633
+ $('btn-pair').addEventListener('click', () => {
1634
+ sendCtrl({ type: 'generate_pair_code' });
1635
+ });
1636
+
1637
+ $('btn-submit-pair').addEventListener('click', () => {
1638
+ const code = $('pair-code-input').value.trim();
1639
+ if (!/^\d{6}$/.test(code)) { alert('Please enter a 6-digit pair code'); return; }
1640
+ sendCtrl({ type: 'pair', code });
1641
+ });
1642
+
1643
+ $('pair-code-input').addEventListener('keydown', e => {
1644
+ if (e.key === 'Enter') { e.preventDefault(); $('btn-submit-pair').click(); }
1645
+ });
1646
+
1647
+ // -- Push notifications --
1648
+ let pushSubscribed = false;
1649
+ let pushVapidKey = null;
1650
+
1651
+ function showPushSection() {
1652
+ // Show only if browser supports push + service workers
1653
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
1654
+ $('push-section').style.display = 'block';
1655
+ }
1656
+
1657
+ function updatePushUI() {
1658
+ const btn = $('btn-push-toggle');
1659
+ const label = $('push-label');
1660
+ const testBtn = $('btn-push-test');
1661
+ if (pushSubscribed) {
1662
+ btn.classList.add('subscribed');
1663
+ label.textContent = 'Notifications On';
1664
+ testBtn.style.display = 'block';
1665
+ } else {
1666
+ btn.classList.remove('subscribed');
1667
+ label.textContent = 'Enable Notifications';
1668
+ testBtn.style.display = 'none';
1669
+ }
1670
+ }
1671
+
1672
+ function urlBase64ToUint8Array(base64String) {
1673
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
1674
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
1675
+ const rawData = atob(base64);
1676
+ const outputArray = new Uint8Array(rawData.length);
1677
+ for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
1678
+ return outputArray;
1679
+ }
1680
+
1681
+ async function subscribePush() {
1682
+ if (!pushVapidKey) return;
1683
+ try {
1684
+ const reg = await navigator.serviceWorker.register('/sw.js');
1685
+ await navigator.serviceWorker.ready;
1686
+ const sub = await reg.pushManager.subscribe({
1687
+ userVisibleOnly: true,
1688
+ applicationServerKey: urlBase64ToUint8Array(pushVapidKey),
1689
+ });
1690
+ const subJson = sub.toJSON();
1691
+ sendCtrl({
1692
+ type: 'subscribe_push',
1693
+ subscription: {
1694
+ endpoint: subJson.endpoint,
1695
+ keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth },
1696
+ },
1697
+ });
1698
+ } catch (err) {
1699
+ console.error('[push] subscribe failed:', err);
1700
+ if (Notification.permission === 'denied') {
1701
+ $('push-label').textContent = 'Permission Denied';
1702
+ } else {
1703
+ $('push-label').textContent = 'Not Available';
1704
+ }
1705
+ }
1706
+ }
1707
+
1708
+ async function unsubscribePush() {
1709
+ try {
1710
+ const reg = await navigator.serviceWorker.getRegistration();
1711
+ if (reg) {
1712
+ const sub = await reg.pushManager.getSubscription();
1713
+ if (sub) await sub.unsubscribe();
1714
+ }
1715
+ sendCtrl({ type: 'unsubscribe_push' });
1716
+ } catch (err) {
1717
+ console.error('[push] unsubscribe failed:', err);
1718
+ }
1719
+ }
1720
+
1721
+ $('btn-push-toggle').addEventListener('click', async () => {
1722
+ if (pushSubscribed) {
1723
+ await unsubscribePush();
1724
+ pushSubscribed = false;
1725
+ } else {
1726
+ await subscribePush();
1727
+ }
1728
+ updatePushUI();
1729
+ });
1730
+
1731
+ $('btn-push-test').addEventListener('click', () => {
1732
+ sendCtrl({ type: 'test_push' });
1733
+ });
1734
+
1735
+ // -- Workspace view --
1736
+ let wsTopics = [], wsRuns = [], wsArtifacts = [], wsApprovals = [];
1737
+ let wsNotes = [], wsCommands = [];
1738
+
1739
+ function refreshWorkspace() {
1740
+ if (!currentSession) return; // Wait until bootstrap resolves a session
1741
+ sendCtrl({ type: 'list_topics', sessionName: currentSession });
1742
+ sendCtrl({ type: 'list_runs' });
1743
+ sendCtrl({ type: 'list_artifacts', sessionName: currentSession });
1744
+ sendCtrl({ type: 'list_approvals' });
1745
+ sendCtrl({ type: 'list_notes' }); // Notes are global workspace memory, not session-scoped
1746
+ sendCtrl({ type: 'list_commands' });
1747
+ }
1748
+
1749
+ function timeAgo(ts) {
1750
+ const s = Math.floor((Date.now() - ts) / 1000);
1751
+ if (s < 60) return s + 's ago';
1752
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
1753
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
1754
+ return Math.floor(s / 86400) + 'd ago';
1755
+ }
1756
+
1757
+ function renderWorkspaceApprovals() {
1758
+ const el = $('ws-approvals');
1759
+ if (!el) return;
1760
+ const pending = wsApprovals.filter(a => a.status === 'pending');
1761
+ if (pending.length === 0) { el.innerHTML = '<div class="ws-empty">No pending approvals</div>'; return; }
1762
+ el.innerHTML = pending.map(a =>
1763
+ '<div class="ws-card">' +
1764
+ '<div class="ws-card-header">' +
1765
+ '<span class="ws-badge pending">pending</span>' +
1766
+ '<span class="ws-card-title">' + esc(a.title) + '</span>' +
1767
+ '<span class="ws-card-meta">' + timeAgo(a.createdAt) + '</span>' +
1768
+ '</div>' +
1769
+ (a.description ? '<div class="ws-card-desc">' + esc(a.description) + '</div>' : '') +
1770
+ '<div class="ws-card-actions">' +
1771
+ '<button class="approve" data-approve-id="' + a.id + '">Approve</button>' +
1772
+ '<button class="reject" data-reject-id="' + a.id + '">Reject</button>' +
1773
+ '</div>' +
1774
+ '</div>'
1775
+ ).join('');
1776
+ el.querySelectorAll('[data-approve-id]').forEach(btn => {
1777
+ btn.addEventListener('click', () => sendCtrl({ type: 'resolve_approval', approvalId: btn.dataset.approveId, status: 'approved' }));
1778
+ });
1779
+ el.querySelectorAll('[data-reject-id]').forEach(btn => {
1780
+ btn.addEventListener('click', () => sendCtrl({ type: 'resolve_approval', approvalId: btn.dataset.rejectId, status: 'rejected' }));
1781
+ });
1782
+ }
1783
+
1784
+ function renderWorkspaceTopics() {
1785
+ const el = $('ws-topics');
1786
+ if (!el) return;
1787
+ if (wsTopics.length === 0) { el.innerHTML = '<div class="ws-empty">No topics yet</div>'; return; }
1788
+ el.innerHTML = wsTopics.map(t =>
1789
+ '<div class="ws-card">' +
1790
+ '<div class="ws-card-header">' +
1791
+ '<span class="ws-card-title">' + esc(t.title) + '</span>' +
1792
+ '<span class="ws-card-meta">' + timeAgo(t.createdAt) + '</span>' +
1793
+ '<button class="del-topic" data-del-topic="' + t.id + '" title="Delete">&times;</button>' +
1794
+ '</div>' +
1795
+ '<div class="ws-card-meta">' + esc(t.sessionName) + '</div>' +
1796
+ '</div>'
1797
+ ).join('');
1798
+ el.querySelectorAll('[data-del-topic]').forEach(btn => {
1799
+ btn.addEventListener('click', () => {
1800
+ sendCtrl({ type: 'delete_topic', topicId: btn.dataset.delTopic });
1801
+ setTimeout(refreshWorkspace, 200);
1802
+ });
1803
+ });
1804
+ }
1805
+
1806
+ function renderWorkspaceRuns() {
1807
+ const el = $('ws-runs');
1808
+ if (!el) return;
1809
+ const active = wsRuns.filter(r => r.status === 'running');
1810
+ const recent = wsRuns.filter(r => r.status !== 'running').slice(-5).reverse();
1811
+ const all = [...active, ...recent];
1812
+ if (all.length === 0) { el.innerHTML = '<div class="ws-empty">No runs</div>'; return; }
1813
+ el.innerHTML = all.map(r =>
1814
+ '<div class="ws-card">' +
1815
+ '<div class="ws-card-header">' +
1816
+ '<span class="ws-badge ' + r.status + '">' + r.status + '</span>' +
1817
+ '<span class="ws-card-title">' + esc(r.command || '(no command)') + '</span>' +
1818
+ '<span class="ws-card-meta">' + timeAgo(r.startedAt) + '</span>' +
1819
+ '</div>' +
1820
+ (r.exitCode !== null ? '<div class="ws-card-meta">Exit: ' + r.exitCode + '</div>' : '') +
1821
+ '</div>'
1822
+ ).join('');
1823
+ }
1824
+
1825
+ // Track which artifact IDs are expanded (persists across re-renders)
1826
+ const _expandedArtifacts = new Set();
1827
+
1828
+ function renderWorkspaceArtifacts() {
1829
+ const el = $('ws-artifacts');
1830
+ if (!el) return;
1831
+ // Artifacts are already filtered by session_name on the server side
1832
+ const recent = wsArtifacts.slice(-10).reverse();
1833
+ if (recent.length === 0) { el.innerHTML = '<div class="ws-empty">No artifacts</div>'; return; }
1834
+ el.innerHTML = recent.map((a) => {
1835
+ var hasContent = a.content && a.content.trim();
1836
+ var ct = a.contentType || 'plain';
1837
+ var badge = (ct !== 'plain') ? ' <span class="ws-badge ' + esc(ct) + '">' + esc(ct) + '</span>' : '';
1838
+ var rendered = a.renderedHtml || (hasContent ? '<pre style="margin:0;font-size:11px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word">' + esc(a.content) + '</pre>' : '');
1839
+ var isExpanded = _expandedArtifacts.has(a.id);
1840
+ return '<div class="ws-card">' +
1841
+ '<div class="ws-card-header">' +
1842
+ '<span class="ws-badge ' + esc(a.type) + '">' + esc(a.type) + '</span>' +
1843
+ badge +
1844
+ '<span class="ws-card-title">' + esc(a.title) + '</span>' +
1845
+ '<span class="ws-card-meta">' + timeAgo(a.createdAt) + '</span>' +
1846
+ (hasContent ? '<button class="ws-card-toggle" data-toggle-art="' + esc(a.id) + '">' + (isExpanded ? 'Hide' : 'Show') + '</button>' : '') +
1847
+ '</div>' +
1848
+ (hasContent ? '<div class="ws-card-content" data-art-content="' + esc(a.id) + '" style="display:' + (isExpanded ? 'block' : 'none') + '">' + rendered + '</div>' : '') +
1849
+ '</div>';
1850
+ }).join('');
1851
+ // Wire up toggle buttons
1852
+ el.querySelectorAll('[data-toggle-art]').forEach(function(btn) {
1853
+ btn.addEventListener('click', function() {
1854
+ var artId = btn.getAttribute('data-toggle-art');
1855
+ var contentEl = el.querySelector('[data-art-content="' + artId + '"]');
1856
+ if (!contentEl) return;
1857
+ var visible = contentEl.style.display !== 'none';
1858
+ contentEl.style.display = visible ? 'none' : 'block';
1859
+ btn.textContent = visible ? 'Show' : 'Hide';
1860
+ if (visible) _expandedArtifacts.delete(artId); else _expandedArtifacts.add(artId);
1861
+ });
1862
+ });
1863
+ }
1864
+
1865
+ $('btn-new-topic').addEventListener('click', () => {
1866
+ const title = prompt('Topic title:');
1867
+ if (title && title.trim()) {
1868
+ sendCtrl({ type: 'create_topic', sessionName: currentSession, title: title.trim() });
1869
+ // Optimistic render in topic_created handler, no delayed refresh needed
1870
+ }
1871
+ });
1872
+
1873
+ $('btn-capture-snapshot').addEventListener('click', () => {
1874
+ sendCtrl({ type: 'capture_snapshot' });
1875
+ // Optimistic render in snapshot_captured handler, no delayed refresh needed
1876
+ });
1877
+
1878
+ // -- Search --
1879
+ let searchDebounce = null;
1880
+ $('ws-search-input').addEventListener('input', () => {
1881
+ clearTimeout(searchDebounce);
1882
+ const q = $('ws-search-input').value.trim();
1883
+ if (!q) { $('ws-search-results').innerHTML = ''; return; }
1884
+ searchDebounce = setTimeout(() => sendCtrl({ type: 'search', query: q }), 200);
1885
+ });
1886
+
1887
+ function renderSearchResults(results) {
1888
+ const el = $('ws-search-results');
1889
+ if (!el) return;
1890
+ if (results.length === 0) {
1891
+ const q = ($('ws-search-input') || {}).value || '';
1892
+ el.innerHTML = q ? '<div class="ws-empty">No results</div>' : '';
1893
+ return;
1894
+ }
1895
+ const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1896
+ el.innerHTML = results.map(r =>
1897
+ '<div class="ws-search-result">' +
1898
+ '<span class="sr-type">' + esc(r.entityType) + '</span> ' +
1899
+ '<span class="sr-title">' + esc(r.title) + '</span>' +
1900
+ '</div>'
1901
+ ).join('');
1902
+ }
1903
+
1904
+ // -- Handoff --
1905
+ $('btn-handoff').addEventListener('click', () => {
1906
+ const el = $('ws-handoff');
1907
+ if (el.classList.contains('visible')) { el.classList.remove('visible'); return; }
1908
+ sendCtrl({ type: 'get_handoff' });
1909
+ });
1910
+
1911
+ function renderHandoffBundle(bundle) {
1912
+ const el = $('ws-handoff');
1913
+ if (!el) return;
1914
+ el.classList.add('visible');
1915
+ const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1916
+ let html = '<div class="ws-handoff-section"><div class="ws-handoff-label">Sessions</div>';
1917
+ html += '<ul class="ws-handoff-list">';
1918
+ (bundle.sessions || []).forEach(s => {
1919
+ html += '<li>' + esc(s.name) + ' (' + s.activeTabs + ' active tabs)</li>';
1920
+ });
1921
+ html += '</ul></div>';
1922
+ if ((bundle.activeTopics || []).length > 0) {
1923
+ html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Active Topics (24h)</div>';
1924
+ html += '<ul class="ws-handoff-list">';
1925
+ bundle.activeTopics.forEach(t => { html += '<li>' + esc(t.title) + '</li>'; });
1926
+ html += '</ul></div>';
1927
+ }
1928
+ if ((bundle.pendingApprovals || []).length > 0) {
1929
+ html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Pending Approvals</div>';
1930
+ html += '<ul class="ws-handoff-list">';
1931
+ bundle.pendingApprovals.forEach(a => { html += '<li>' + esc(a.title) + '</li>'; });
1932
+ html += '</ul></div>';
1933
+ }
1934
+ html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Recent Runs (' + (bundle.recentRuns || []).length + ')</div></div>';
1935
+ html += '<div class="ws-handoff-section"><div class="ws-handoff-label">Key Artifacts (' + (bundle.keyArtifacts || []).length + ')</div></div>';
1936
+ el.innerHTML = html;
1937
+ }
1938
+
1939
+ // -- Notes --
1940
+ function renderNotes() {
1941
+ const el = $('ws-notes');
1942
+ if (!el) return;
1943
+ if (wsNotes.length === 0) { el.innerHTML = '<div class="ws-empty">No notes yet</div>'; return; }
1944
+ const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1945
+ el.innerHTML = wsNotes.map(n =>
1946
+ '<div class="ws-note' + (n.pinned ? ' pinned' : '') + '">' +
1947
+ '<div class="ws-note-content">' + esc(n.content) + '</div>' +
1948
+ '<div class="ws-note-actions">' +
1949
+ '<button data-pin-note="' + n.id + '">' + (n.pinned ? 'Unpin' : 'Pin') + '</button>' +
1950
+ '<button data-del-note="' + n.id + '">Delete</button>' +
1951
+ '</div>' +
1952
+ '</div>'
1953
+ ).join('');
1954
+ el.querySelectorAll('[data-pin-note]').forEach(btn => {
1955
+ btn.addEventListener('click', () => sendCtrl({ type: 'pin_note', noteId: btn.dataset.pinNote }));
1956
+ });
1957
+ el.querySelectorAll('[data-del-note]').forEach(btn => {
1958
+ btn.addEventListener('click', () => sendCtrl({ type: 'delete_note', noteId: btn.dataset.delNote }));
1959
+ });
1960
+ }
1961
+
1962
+ $('btn-add-note').addEventListener('click', () => {
1963
+ const input = $('ws-note-input');
1964
+ const content = input.value.trim();
1965
+ if (!content) return;
1966
+ sendCtrl({ type: 'create_note', content });
1967
+ input.value = '';
1968
+ // Feedback: show saving indicator, revert if no response in 3s
1969
+ const el = $('ws-notes');
1970
+ const prevHtml = el.innerHTML;
1971
+ el.innerHTML = '<div class="ws-empty">Saving...</div>';
1972
+ setTimeout(() => {
1973
+ if (el.innerHTML.includes('Saving...')) {
1974
+ el.innerHTML = '<div class="ws-empty" style="color:var(--text-dim)">Note may not have saved — check server logs</div>';
1975
+ }
1976
+ }, 3000);
1977
+ });
1978
+ $('ws-note-input').addEventListener('keydown', e => {
1979
+ if (e.key === 'Enter') { e.preventDefault(); $('btn-add-note').click(); }
1980
+ });
1981
+
1982
+ // -- Commands --
1983
+ function formatDuration(startedAt, endedAt) {
1984
+ if (!endedAt) return 'running';
1985
+ const ms = endedAt - startedAt;
1986
+ if (ms < 1000) return ms + 'ms';
1987
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
1988
+ return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
1989
+ }
1990
+
1991
+ function renderCommands() {
1992
+ const el = $('ws-commands');
1993
+ if (!el) return;
1994
+ if (wsCommands.length === 0) { el.innerHTML = '<div class="ws-empty">No commands detected (requires shell integration)</div>'; return; }
1995
+ const esc = t => (t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1996
+ el.innerHTML = wsCommands.slice(0, 20).map(c => {
1997
+ const exitClass = c.exitCode === null ? '' : (c.exitCode === 0 ? 'ok' : 'err');
1998
+ const exitSymbol = c.exitCode === null ? '' : (c.exitCode === 0 ? '&#10003;' : '&#10007; ' + c.exitCode);
1999
+ return '<div class="ws-cmd">' +
2000
+ '<span class="ws-cmd-text">' + esc(c.command || '(unknown)') + '</span>' +
2001
+ (exitSymbol ? '<span class="ws-cmd-exit ' + exitClass + '">' + exitSymbol + '</span>' : '') +
2002
+ '<span class="ws-cmd-meta">' + formatDuration(c.startedAt, c.endedAt) + '</span>' +
2003
+ (c.cwd ? '<span class="ws-cmd-meta">' + esc(c.cwd) + '</span>' : '') +
2004
+ '</div>';
2005
+ }).join('');
2006
+ }
2007
+
2008
+ // -- Tab rename (double-click) --
2009
+ $('tab-list').addEventListener('dblclick', e => {
2010
+ const tabEl = e.target.closest('.tab');
2011
+ if (!tabEl) return;
2012
+ const titleSpan = tabEl.querySelector('.title');
2013
+ if (!titleSpan) return;
2014
+ // Find tab id from close button
2015
+ const closeBtn = tabEl.querySelector('.close');
2016
+ if (!closeBtn) return;
2017
+ const tabId = Number(closeBtn.dataset.close);
2018
+ const oldTitle = titleSpan.textContent;
2019
+
2020
+ const input = document.createElement('input');
2021
+ input.className = 'rename-input';
2022
+ input.value = oldTitle;
2023
+ input.setAttribute('maxlength', '32');
2024
+ titleSpan.replaceWith(input);
2025
+ input.focus();
2026
+ input.select();
2027
+
2028
+ function commit() {
2029
+ const newTitle = input.value.trim() || oldTitle;
2030
+ // Send rename to server
2031
+ if (newTitle !== oldTitle) sendCtrl({ type: 'rename_tab', tabId, title: newTitle });
2032
+ // Restore span immediately
2033
+ const span = document.createElement('span');
2034
+ span.className = 'title';
2035
+ span.textContent = newTitle;
2036
+ input.replaceWith(span);
2037
+ }
2038
+ function cancel() {
2039
+ const span = document.createElement('span');
2040
+ span.className = 'title';
2041
+ span.textContent = oldTitle;
2042
+ input.replaceWith(span);
2043
+ }
2044
+ input.addEventListener('keydown', ev => {
2045
+ if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
2046
+ if (ev.key === 'Escape') { ev.preventDefault(); cancel(); }
2047
+ });
2048
+ input.addEventListener('blur', commit);
2049
+ });
2050
+
2051
+ // -- Mobile virtual keyboard handling --
2052
+ // Only apply visualViewport height adjustments on touch devices.
2053
+ // Ignore viewport sync during IME composition so candidate UI can't
2054
+ // temporarily collapse the terminal area.
2055
+ if (window.visualViewport && isTouchDevice) {
2056
+ window.visualViewport.addEventListener('resize', syncTouchViewportHeight);
2057
+ window.visualViewport.addEventListener('scroll', () => window.scrollTo(0, 0));
2058
+ }
2059
+ // iOS Safari: touching terminal area focuses hidden textarea for input
2060
+ document.getElementById('terminal').addEventListener('touchend', () => { if (currentView === 'live') term.focus(); });
2061
+
2062
+ // -- IME diagnostic (active when ?debug=1) --
2063
+ if (new URLSearchParams(location.search).has('debug')) {
2064
+ const _d = window._imeDiag = { events: [], t0: Date.now() };
2065
+ function _ilog(type, detail) {
2066
+ const e = { t: Date.now() - _d.t0, type, ...detail };
2067
+ _d.events.push(e);
2068
+ if (_d.events.length > 300) _d.events.shift();
2069
+ try { localStorage.setItem('_imeDiag', JSON.stringify(_d.events.slice(-50))); } catch {}
2070
+ }
2071
+ const _ta = document.querySelector('textarea');
2072
+ if (_ta) {
2073
+ ['compositionstart','compositionupdate','compositionend'].forEach(n =>
2074
+ _ta.addEventListener(n, e => _ilog(n, { data: e.data }), true));
2075
+ _ta.addEventListener('input', e => _ilog('input', {
2076
+ data: e.data?.substring(0,20), inputType: e.inputType, isComposing: e.isComposing
2077
+ }), true);
2078
+ _ta.addEventListener('keydown', e => {
2079
+ if (e.isComposing || e.keyCode === 229)
2080
+ _ilog('keydown-ime', { key: e.key, code: e.code, kc: e.keyCode });
2081
+ }, true);
2082
+ }
2083
+ new ResizeObserver(entries => entries.forEach(e => {
2084
+ const n = e.target.id || e.target.tagName;
2085
+ _ilog('resize', { el: n, h: Math.round(e.contentRect.height) });
2086
+ })).observe(document.body);
2087
+ if (window.visualViewport) window.visualViewport.addEventListener('resize', () =>
2088
+ _ilog('vv-resize', { vh: Math.round(window.visualViewport.height), bh: document.body.offsetHeight }));
2089
+ new MutationObserver(() => _ilog('body-style', {
2090
+ h: document.body.style.height, oh: document.body.offsetHeight
2091
+ })).observe(document.body, { attributes: true, attributeFilter: ['style'] });
2092
+ window.addEventListener('error', e => _ilog('js-error', { msg: e.message, line: e.lineno }));
2093
+ // Track textarea style/size changes (IME may resize it)
2094
+ if (_ta) {
2095
+ new ResizeObserver(() => {
2096
+ _ilog('ta-resize', { w: _ta.offsetWidth, h: _ta.offsetHeight, vis: getComputedStyle(_ta).visibility, op: getComputedStyle(_ta).opacity });
2097
+ }).observe(_ta);
2098
+ }
2099
+ // Track canvas visibility
2100
+ const _cvs = document.querySelector('#terminal canvas');
2101
+ if (_cvs) {
2102
+ new ResizeObserver(() => {
2103
+ _ilog('canvas-resize', { w: _cvs.width, h: _cvs.height, display: getComputedStyle(_cvs).display });
2104
+ }).observe(_cvs);
2105
+ }
2106
+ // Floating debug overlay — stays visible even when page goes "blank"
2107
+ const _dbg = document.createElement('div');
2108
+ _dbg.id = 'ime-debug';
2109
+ _dbg.style.cssText = 'position:fixed;bottom:0;right:0;z-index:999999;background:rgba(0,0,0,0.85);color:#0f0;font:10px monospace;padding:4px 8px;max-width:60vw;max-height:40vh;overflow:auto;pointer-events:none;white-space:pre-wrap;';
2110
+ document.documentElement.appendChild(_dbg);
2111
+
2112
+ // Continuous polling — captures state even when no events fire
2113
+ let _prevState = '';
2114
+ setInterval(() => {
2115
+ const _ta2 = document.querySelector('textarea');
2116
+ const _cvs2 = document.querySelector('#terminal canvas');
2117
+ const _term2 = document.getElementById('terminal');
2118
+ const _main2 = document.querySelector('.main');
2119
+ const _sidebar2 = document.querySelector('.sidebar');
2120
+ const state = JSON.stringify({
2121
+ body: { h: document.body.offsetHeight, w: document.body.offsetWidth, styleH: document.body.style.height, vis: document.body.style.visibility, disp: document.body.style.display },
2122
+ main: _main2 ? { h: _main2.offsetHeight, vis: getComputedStyle(_main2).visibility, op: getComputedStyle(_main2).opacity, disp: getComputedStyle(_main2).display } : null,
2123
+ term: _term2 ? { h: _term2.offsetHeight, w: _term2.offsetWidth, vis: getComputedStyle(_term2).visibility, disp: getComputedStyle(_term2).display, op: getComputedStyle(_term2).opacity } : null,
2124
+ cvs: _cvs2 ? { w: _cvs2.width, h: _cvs2.height, styleW: _cvs2.style.width, styleH: _cvs2.style.height, vis: getComputedStyle(_cvs2).visibility, disp: getComputedStyle(_cvs2).display } : null,
2125
+ ta: _ta2 ? { w: _ta2.offsetWidth, h: _ta2.offsetHeight, styleW: _ta2.style.width, styleH: _ta2.style.height, pos: getComputedStyle(_ta2).position, vis: getComputedStyle(_ta2).visibility, op: getComputedStyle(_ta2).opacity, zIdx: getComputedStyle(_ta2).zIndex, bg: getComputedStyle(_ta2).background?.substring(0,40) } : null,
2126
+ sidebar: _sidebar2 ? { h: _sidebar2.offsetHeight, vis: getComputedStyle(_sidebar2).visibility } : null,
2127
+ vv: window.visualViewport ? { h: Math.round(window.visualViewport.height), w: Math.round(window.visualViewport.width) } : null
2128
+ });
2129
+ if (state !== _prevState) {
2130
+ _prevState = state;
2131
+ _ilog('poll', JSON.parse(state));
2132
+ }
2133
+ // Always update overlay with latest events
2134
+ const last = _d.events.slice(-12);
2135
+ _dbg.textContent = last.map(e => {
2136
+ const {t, type, ...r} = e;
2137
+ return t + 'ms ' + type + ': ' + JSON.stringify(r).substring(0, 120);
2138
+ }).join('\\n');
2139
+ }, 200);
2140
+ console.log('[remux] IME diagnostic v2 active — polling every 200ms');
2141
+ }
2142
+ </script>
2143
+ </body>
2144
+ </html>`;
2145
+
2146
+ // ── Service Worker for Push Notifications ───────────────────────
2147
+
2148
+ const SW_SCRIPT = `self.addEventListener('push', function(event) {
2149
+ if (!event.data) return;
2150
+ try {
2151
+ var data = event.data.json();
2152
+ event.waitUntil(
2153
+ self.registration.showNotification(data.title || 'Remux', {
2154
+ body: data.body || '',
2155
+ icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">></text></svg>',
2156
+ badge: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">></text></svg>',
2157
+ tag: 'remux-' + (data.tag || 'default'),
2158
+ renotify: true,
2159
+ })
2160
+ );
2161
+ } catch (e) {
2162
+ // Fallback for non-JSON payloads
2163
+ event.waitUntil(
2164
+ self.registration.showNotification('Remux', { body: event.data.text() })
2165
+ );
2166
+ }
2167
+ });
2168
+
2169
+ self.addEventListener('notificationclick', function(event) {
2170
+ event.notification.close();
2171
+ event.waitUntil(
2172
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
2173
+ for (var i = 0; i < clientList.length; i++) {
2174
+ if (clientList[i].url.includes(self.location.origin) && 'focus' in clientList[i]) {
2175
+ return clientList[i].focus();
2176
+ }
2177
+ }
2178
+ if (clients.openWindow) {
2179
+ return clients.openWindow('/');
2180
+ }
2181
+ })
2182
+ );
2183
+ });
2184
+ `;
2185
+
2186
+ // ── MIME ──────────────────────────────────────────────────────────
2187
+
2188
+ const MIME: Record<string, string> = {
2189
+ ".html": "text/html",
2190
+ ".js": "application/javascript",
2191
+ ".wasm": "application/wasm",
2192
+ ".css": "text/css",
2193
+ ".json": "application/json",
2194
+ };
2195
+
2196
+ // ── HTTP Server ──────────────────────────────────────────────────
2197
+
2198
+ const httpServer = http.createServer((req, res) => {
2199
+ const url = new URL(req.url!, `http://${req.headers.host}`);
2200
+
2201
+ // Handle password form submission
2202
+ if (url.pathname === "/auth" && req.method === "POST") {
2203
+ let body = "";
2204
+ req.on("data", (chunk: Buffer) => (body += chunk));
2205
+ req.on("end", () => {
2206
+ const params = new URLSearchParams(body);
2207
+ const submitted = params.get("password");
2208
+ if (PASSWORD && submitted === PASSWORD) {
2209
+ // Generate a session token and redirect with it
2210
+ const sessionToken = generateToken();
2211
+ addPasswordToken(sessionToken);
2212
+ res.writeHead(302, { Location: `/?token=${sessionToken}` });
2213
+ res.end();
2214
+ } else {
2215
+ res.writeHead(302, { Location: "/?error=1" });
2216
+ res.end();
2217
+ }
2218
+ });
2219
+ return;
2220
+ }
2221
+
2222
+ if (url.pathname === "/" || url.pathname === "/index.html") {
2223
+ const urlToken = url.searchParams.get("token");
2224
+ const isAuthed =
2225
+ (!TOKEN && !PASSWORD) || // no auth configured (impossible after auto-gen, but safe)
2226
+ (urlToken != null && validateToken(urlToken, TOKEN));
2227
+
2228
+ if (!isAuthed) {
2229
+ // If password mode is active and no valid token, show login page
2230
+ if (PASSWORD) {
2231
+ res.writeHead(200, { "Content-Type": "text/html" });
2232
+ res.end(PASSWORD_PAGE);
2233
+ return;
2234
+ }
2235
+ res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" });
2236
+ res.end('<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Remux</title><link rel="icon" href="data:image/svg+xml,<svg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 100 100\'><text y=\'.9em\' font-size=\'90\'>⬛</text></svg>"><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#1e1e1e;color:#ccc}div{text-align:center;max-width:400px;padding:2rem}h1{font-size:1.5rem;margin:0 0 1rem}p{color:#888;line-height:1.6}code{background:#333;padding:2px 6px;border-radius:3px;font-size:0.9em}</style></head><body><div><h1>Remux</h1><p>Access requires a valid token.</p><p>Add <code>?token=YOUR_TOKEN</code> to the URL.</p></div></body></html>');
2237
+ return;
2238
+ }
2239
+ res.writeHead(200, { "Content-Type": "text/html" });
2240
+ res.end(HTML_TEMPLATE);
2241
+ return;
2242
+ }
2243
+
2244
+ if (url.pathname.startsWith("/dist/")) {
2245
+ const resolved = path.resolve(distPath, url.pathname.slice(6));
2246
+ const rel = path.relative(distPath, resolved);
2247
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
2248
+ res.writeHead(403);
2249
+ res.end("Forbidden");
2250
+ return;
2251
+ }
2252
+ return serveFile(resolved, res);
2253
+ }
2254
+
2255
+ if (url.pathname === "/ghostty-vt.wasm") {
2256
+ return serveFile(wasmPath, res);
2257
+ }
2258
+
2259
+ // Service worker for push notifications
2260
+ if (url.pathname === "/sw.js") {
2261
+ res.writeHead(200, {
2262
+ "Content-Type": "application/javascript",
2263
+ "Service-Worker-Allowed": "/",
2264
+ });
2265
+ res.end(SW_SCRIPT);
2266
+ return;
2267
+ }
2268
+
2269
+ res.writeHead(404);
2270
+ res.end("Not Found");
2271
+ });
2272
+
2273
+ function serveFile(filePath: string, res: http.ServerResponse): void {
2274
+ const ext = path.extname(filePath);
2275
+ fs.readFile(filePath, (err, data) => {
2276
+ if (err) {
2277
+ res.writeHead(404);
2278
+ res.end("Not Found");
2279
+ return;
2280
+ }
2281
+ res.writeHead(200, {
2282
+ "Content-Type": MIME[ext] || "application/octet-stream",
2283
+ });
2284
+ res.end(data);
2285
+ });
2286
+ }
2287
+
2288
+ // ── WebSocket Server ─────────────────────────────────────────────
2289
+
2290
+ const wss = setupWebSocket(httpServer, TOKEN, PASSWORD);
2291
+
2292
+ // E10-006: broadcast adapter events to all authenticated WebSocket clients
2293
+ adapterRegistry.onEvent((event) => {
2294
+ const envelope = JSON.stringify({
2295
+ v: 1,
2296
+ type: "adapter_event",
2297
+ domain: "semantic",
2298
+ emittedAt: event.timestamp,
2299
+ source: "server",
2300
+ payload: event,
2301
+ });
2302
+ for (const client of wss.clients) {
2303
+ if (client.readyState === 1) {
2304
+ // Only send to authenticated clients (they have _remuxAuthed flag)
2305
+ if ((client as any)._remuxAuthed) {
2306
+ client.send(envelope);
2307
+ }
2308
+ }
2309
+ }
2310
+ });
2311
+
2312
+ // ── Start ────────────────────────────────────────────────────────
2313
+
2314
+ httpServer.listen(PORT, () => {
2315
+ let url = `http://localhost:${PORT}`;
2316
+ if (TOKEN) url += `?token=${TOKEN}`;
2317
+
2318
+ console.log(`\n Remux running at ${url}\n`);
2319
+
2320
+ if (PASSWORD) {
2321
+ console.log(` Password authentication enabled`);
2322
+ console.log(` Login page: http://localhost:${PORT}\n`);
2323
+ } else if (TOKEN) {
2324
+ console.log(` Token: ${TOKEN}\n`);
2325
+ }
2326
+
2327
+ // Print QR code for local URL
2328
+ qrcode.generate(url, { small: true }, (code: string) => {
2329
+ console.log(code);
2330
+ });
2331
+
2332
+ // -- Tunnel: launch async after server is listening --
2333
+ launchTunnel();
2334
+ });
2335
+
2336
+ async function launchTunnel(): Promise<void> {
2337
+ if (tunnelMode === "disable") return;
2338
+
2339
+ const available = await isCloudflaredAvailable();
2340
+ if (!available) {
2341
+ if (tunnelMode === "enable") {
2342
+ console.log(
2343
+ "\n [tunnel] cloudflared not found -- install it for tunnel support",
2344
+ );
2345
+ console.log(
2346
+ " https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n",
2347
+ );
2348
+ }
2349
+ return;
2350
+ }
2351
+
2352
+ console.log(" [tunnel] starting cloudflare tunnel...");
2353
+ try {
2354
+ const { url: tunnelUrl, process: child } = await startTunnel(
2355
+ Number(PORT),
2356
+ );
2357
+ tunnelProcess = child;
2358
+
2359
+ const accessUrl = buildTunnelAccessUrl(tunnelUrl, TOKEN, PASSWORD);
2360
+ console.log(`\n Tunnel: ${accessUrl}\n`);
2361
+
2362
+ // Print QR code for tunnel URL (great for mobile access)
2363
+ qrcode.generate(accessUrl, { small: true }, (code: string) => {
2364
+ console.log(code);
2365
+ });
2366
+
2367
+ // Log if tunnel exits unexpectedly
2368
+ child.on("close", (code: number | null) => {
2369
+ if (code !== null && code !== 0) {
2370
+ console.log(` [tunnel] cloudflared exited (code ${code})`);
2371
+ }
2372
+ tunnelProcess = null;
2373
+ });
2374
+ } catch (err: any) {
2375
+ console.log(` [tunnel] failed to start: ${err.message}`);
2376
+ tunnelProcess = null;
2377
+ }
2378
+ }
2379
+
2380
+ function shutdown(): void {
2381
+ try {
2382
+ persistSessions(); // save before exit
2383
+ } catch (e: any) {
2384
+ console.error("[shutdown] persist failed:", e.message);
2385
+ }
2386
+ closeDb(); // close SQLite connection
2387
+ adapterRegistry.shutdown(); // stop all adapters
2388
+ // Kill cloudflared tunnel if running
2389
+ if (tunnelProcess) {
2390
+ try {
2391
+ tunnelProcess.kill("SIGTERM");
2392
+ } catch {}
2393
+ tunnelProcess = null;
2394
+ }
2395
+ for (const session of sessionMap.values()) {
2396
+ for (const tab of session.tabs) {
2397
+ if (tab.vt) {
2398
+ tab.vt.dispose();
2399
+ tab.vt = null;
2400
+ }
2401
+ if (!tab.ended && tab.pty) tab.pty.kill();
2402
+ }
2403
+ }
2404
+ process.exit(0);
2405
+ }
2406
+ process.on("SIGINT", shutdown);
2407
+ process.on("SIGTERM", shutdown);