@wangyaoshen/remux 0.3.8-dev.29e114b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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">▼</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">🔔</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">☀</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">☰</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">↑</button>
|
|
729
|
+
<button data-seq="down">↓</button>
|
|
730
|
+
<button data-seq="left">←</button>
|
|
731
|
+
<button data-seq="right">→</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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' ? '☀' : '☾';
|
|
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' ? '☀' : '☾';
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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">✓</button>' : '')
|
|
1591
|
+
+ (d.trust !== 'blocked' ? '<button data-block="' + d.id + '" title="Block">✗</button>' : '')
|
|
1592
|
+
+ '<button data-rename-dev="' + d.id + '" title="Rename">✎</button>'
|
|
1593
|
+
+ (!isSelf ? '<button data-revoke="' + d.id + '" title="Revoke">🗑</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">×</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 ? '✓' : '✗ ' + 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);
|