@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.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
package/src/team.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * E17: Team Mode Foundations — user identity, RBAC, workspace/project models.
3
+ * Personal Mode (current): single implicit workspace, no user auth.
4
+ * Team Mode: multi-user with roles and permissions.
5
+ */
6
+
7
+ import { getDb } from "./store.js";
8
+
9
+ // E17-001: User Identity
10
+ export interface UserIdentity {
11
+ userId: string;
12
+ displayName: string;
13
+ email: string;
14
+ avatarUrl?: string;
15
+ role: "owner" | "admin" | "member" | "viewer";
16
+ }
17
+
18
+ // E17-003: RBAC
19
+ export type Permission = "read" | "write" | "admin" | "approve";
20
+
21
+ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
22
+ owner: ["read", "write", "admin", "approve"],
23
+ admin: ["read", "write", "admin", "approve"],
24
+ member: ["read", "write", "approve"],
25
+ viewer: ["read"],
26
+ };
27
+
28
+ export function hasPermission(role: string, permission: Permission): boolean {
29
+ return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
30
+ }
31
+
32
+ // E17-004: Workspace model
33
+ export interface Workspace {
34
+ id: string;
35
+ name: string;
36
+ ownerId: string;
37
+ createdAt: string;
38
+ }
39
+
40
+ // E17-005: Project model
41
+ export interface Project {
42
+ id: string;
43
+ workspaceId: string;
44
+ name: string;
45
+ description: string;
46
+ createdAt: string;
47
+ }
48
+
49
+ // E17-006: Audit Log
50
+ export interface AuditEntry {
51
+ id: number;
52
+ userId: string;
53
+ action: string;
54
+ target: string;
55
+ timestamp: string;
56
+ details: string;
57
+ }
58
+
59
+ export function initTeamTables(): void {
60
+ const db = getDb();
61
+
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS users (
64
+ user_id TEXT PRIMARY KEY,
65
+ display_name TEXT NOT NULL,
66
+ email TEXT UNIQUE NOT NULL,
67
+ avatar_url TEXT,
68
+ role TEXT NOT NULL DEFAULT 'member',
69
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS workspaces (
73
+ id TEXT PRIMARY KEY,
74
+ name TEXT NOT NULL,
75
+ owner_id TEXT NOT NULL,
76
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS projects (
80
+ id TEXT PRIMARY KEY,
81
+ workspace_id TEXT NOT NULL,
82
+ name TEXT NOT NULL,
83
+ description TEXT DEFAULT '',
84
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS audit_log (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ user_id TEXT NOT NULL,
90
+ action TEXT NOT NULL,
91
+ target TEXT NOT NULL,
92
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
93
+ details TEXT DEFAULT ''
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS workspace_members (
97
+ workspace_id TEXT NOT NULL,
98
+ user_id TEXT NOT NULL,
99
+ role TEXT NOT NULL DEFAULT 'member',
100
+ joined_at TEXT NOT NULL DEFAULT (datetime('now')),
101
+ PRIMARY KEY (workspace_id, user_id)
102
+ );
103
+ `);
104
+ }
105
+
106
+ export function logAudit(
107
+ userId: string,
108
+ action: string,
109
+ target: string,
110
+ details?: string,
111
+ ): void {
112
+ const db = getDb();
113
+ db.prepare(
114
+ "INSERT INTO audit_log (user_id, action, target, details) VALUES (?, ?, ?, ?)",
115
+ ).run(userId, action, target, details || "");
116
+ }
117
+
118
+ export function getAuditLog(limit = 100): AuditEntry[] {
119
+ const db = getDb();
120
+ return db
121
+ .prepare("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?")
122
+ .all(limit) as AuditEntry[];
123
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Cloudflare Tunnel support for Remux.
3
+ * Detects cloudflared, spawns a quick tunnel, parses the URL.
4
+ * Adapted from cloudflare/cloudflared quick-tunnel pattern.
5
+ */
6
+
7
+ import { spawn, execFile, type ChildProcess } from "child_process";
8
+
9
+ // ── CLI arg parsing ─────────────────────────────────────────────
10
+
11
+ export type TunnelMode = "enable" | "disable" | "auto";
12
+
13
+ /**
14
+ * Parse tunnel-related CLI flags from argv.
15
+ */
16
+ export function parseTunnelArgs(argv: string[]): { tunnelMode: TunnelMode } {
17
+ if (argv.includes("--no-tunnel")) return { tunnelMode: "disable" };
18
+ if (argv.includes("--tunnel")) return { tunnelMode: "enable" };
19
+ return { tunnelMode: "auto" };
20
+ }
21
+
22
+ // ── cloudflared detection ───────────────────────────────────────
23
+
24
+ /**
25
+ * Check if cloudflared is available on PATH.
26
+ */
27
+ export function isCloudflaredAvailable(): Promise<boolean> {
28
+ return new Promise((resolve) => {
29
+ execFile("cloudflared", ["--version"], (err) => {
30
+ resolve(!err);
31
+ });
32
+ });
33
+ }
34
+
35
+ // ── Tunnel lifecycle ────────────────────────────────────────────
36
+
37
+ const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
38
+
39
+ /**
40
+ * Start a cloudflared quick tunnel pointing at the given local URL.
41
+ */
42
+ export function startTunnel(
43
+ port: number,
44
+ options: { signal?: AbortSignal } = {},
45
+ ): Promise<{ url: string; process: ChildProcess }> {
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn(
48
+ "cloudflared",
49
+ ["tunnel", "--url", `http://localhost:${port}`],
50
+ { stdio: ["ignore", "pipe", "pipe"] },
51
+ );
52
+
53
+ let resolved = false;
54
+ let output = "";
55
+ const TIMEOUT_MS = 30_000;
56
+
57
+ const timer = setTimeout(() => {
58
+ if (!resolved) {
59
+ resolved = true;
60
+ child.kill("SIGTERM");
61
+ reject(new Error("cloudflared tunnel URL not detected within 30s"));
62
+ }
63
+ }, TIMEOUT_MS);
64
+
65
+ function handleData(data: Buffer) {
66
+ output += data.toString();
67
+ const match = output.match(TUNNEL_URL_RE);
68
+ if (match && !resolved) {
69
+ resolved = true;
70
+ clearTimeout(timer);
71
+ // Stop accumulating data after URL is found
72
+ child.stderr!.removeListener("data", handleData);
73
+ child.stdout!.removeListener("data", handleData);
74
+ resolve({ url: match[0], process: child });
75
+ }
76
+ }
77
+
78
+ // cloudflared logs connection info to stderr
79
+ child.stderr!.on("data", handleData);
80
+ child.stdout!.on("data", handleData);
81
+
82
+ child.on("error", (err) => {
83
+ if (!resolved) {
84
+ resolved = true;
85
+ clearTimeout(timer);
86
+ reject(err);
87
+ }
88
+ });
89
+
90
+ child.on("close", (code) => {
91
+ if (!resolved) {
92
+ resolved = true;
93
+ clearTimeout(timer);
94
+ reject(
95
+ new Error(
96
+ `cloudflared exited with code ${code} before URL was detected`,
97
+ ),
98
+ );
99
+ }
100
+ });
101
+
102
+ if (options.signal) {
103
+ options.signal.addEventListener(
104
+ "abort",
105
+ () => {
106
+ child.kill("SIGTERM");
107
+ },
108
+ { once: true },
109
+ );
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Build the full tunnel access URL, appending auth token if present.
116
+ */
117
+ export function buildTunnelAccessUrl(
118
+ tunnelUrl: string,
119
+ token: string | null,
120
+ password: string | null,
121
+ ): string {
122
+ // If password auth, the user logs in via the password page (no token in URL)
123
+ if (password && !token) return tunnelUrl;
124
+ if (token) return `${tunnelUrl}?token=${token}`;
125
+ return tunnelUrl;
126
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ declare module "qrcode-terminal" {
2
+ export function generate(
3
+ text: string,
4
+ options?: { small?: boolean },
5
+ callback?: (code: string) => void,
6
+ ): void;
7
+ }
8
+
9
+ declare module "web-push" {
10
+ export interface PushSubscription {
11
+ endpoint: string;
12
+ keys: {
13
+ p256dh: string;
14
+ auth: string;
15
+ };
16
+ }
17
+
18
+ export interface RequestOptions {
19
+ TTL?: number;
20
+ headers?: Record<string, string>;
21
+ vapidDetails?: {
22
+ subject: string;
23
+ publicKey: string;
24
+ privateKey: string;
25
+ };
26
+ }
27
+
28
+ export interface SendResult {
29
+ statusCode: number;
30
+ headers: Record<string, string>;
31
+ body: string;
32
+ }
33
+
34
+ export interface VapidKeys {
35
+ publicKey: string;
36
+ privateKey: string;
37
+ }
38
+
39
+ export function generateVAPIDKeys(): VapidKeys;
40
+ export function setVapidDetails(
41
+ subject: string,
42
+ publicKey: string,
43
+ privateKey: string,
44
+ ): void;
45
+ export function sendNotification(
46
+ subscription: PushSubscription,
47
+ payload?: string | Buffer | null,
48
+ options?: RequestOptions,
49
+ ): Promise<SendResult>;
50
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Server-side ghostty-vt (WASM) -- tsm-style VT tracking.
3
+ * Loads the ghostty-vt WASM binary and provides a factory for VT terminals.
4
+ */
5
+
6
+ import fs from "fs";
7
+
8
+ // ── WASM state (module-level singletons) ────────────────────────
9
+
10
+ let wasmExports: any = null;
11
+ let wasmMemory: WebAssembly.Memory | null = null;
12
+
13
+ // ── VT Terminal type ─────────────────────────────────────────────
14
+
15
+ export interface VtTerminal {
16
+ handle: number;
17
+ consume(data: string | Buffer): void;
18
+ resize(cols: number, rows: number): void;
19
+ isAltScreen(): boolean;
20
+ /** Build a VT escape sequence snapshot from viewport cells (tsm Snapshot equivalent). */
21
+ snapshot(): string | null;
22
+ /** Extract plain text from viewport (for Inspect view). */
23
+ textSnapshot(): { text: string; cols: number; rows: number };
24
+ dispose(): void;
25
+ }
26
+
27
+ // ── Init ─────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Load the ghostty-vt WASM binary from disk.
31
+ */
32
+ export async function initGhosttyVt(wasmPath: string): Promise<void> {
33
+ const wasmBytes = fs.readFileSync(wasmPath);
34
+ const result = await WebAssembly.instantiate(wasmBytes, {
35
+ env: { log: () => {} },
36
+ });
37
+ wasmExports = result.instance.exports;
38
+ wasmMemory = wasmExports.memory;
39
+ console.log("[ghostty-vt] WASM loaded for server-side VT tracking");
40
+ }
41
+
42
+ // ── Factory ──────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Create a new VT terminal instance with the given dimensions.
46
+ * Returns null if WASM is not loaded.
47
+ */
48
+ export function createVtTerminal(
49
+ cols: number,
50
+ rows: number,
51
+ ): VtTerminal | null {
52
+ if (!wasmExports) return null;
53
+ const handle = wasmExports.ghostty_terminal_new(cols, rows);
54
+ if (!handle) return null;
55
+
56
+ return {
57
+ handle,
58
+
59
+ consume(data: string | Buffer) {
60
+ if (!this.handle) return; // disposed
61
+ const bytes = typeof data === "string" ? Buffer.from(data) : data;
62
+ const ptr = wasmExports.ghostty_wasm_alloc_u8_array(bytes.length);
63
+ new Uint8Array(wasmMemory!.buffer).set(bytes, ptr);
64
+ wasmExports.ghostty_terminal_write(handle, ptr, bytes.length);
65
+ wasmExports.ghostty_wasm_free_u8_array(ptr, bytes.length);
66
+ },
67
+
68
+ resize(cols: number, rows: number) {
69
+ if (!this.handle) return; // disposed
70
+ wasmExports.ghostty_terminal_resize(handle, cols, rows);
71
+ },
72
+
73
+ isAltScreen(): boolean {
74
+ if (!this.handle) return false; // disposed
75
+ return !!wasmExports.ghostty_terminal_is_alternate_screen(handle);
76
+ },
77
+
78
+ snapshot(): string | null {
79
+ if (!this.handle) return null; // disposed
80
+ wasmExports.ghostty_render_state_update(handle);
81
+ const cols = wasmExports.ghostty_render_state_get_cols(handle);
82
+ const rows = wasmExports.ghostty_render_state_get_rows(handle);
83
+ const cellSize = 16;
84
+ const bufSize = cols * rows * cellSize;
85
+ const bufPtr = wasmExports.ghostty_wasm_alloc_u8_array(bufSize);
86
+ const count = wasmExports.ghostty_render_state_get_viewport(
87
+ handle,
88
+ bufPtr,
89
+ bufSize,
90
+ );
91
+
92
+ const view = new DataView(wasmMemory!.buffer);
93
+ let out = "\x1b[H\x1b[2J"; // clear + home
94
+ let lastFg: number | null = null;
95
+ let lastBg: number | null = null;
96
+ let lastFlags = 0;
97
+
98
+ for (let row = 0; row < rows; row++) {
99
+ if (row > 0) out += "\r\n";
100
+ for (let col = 0; col < cols; col++) {
101
+ const off = bufPtr + (row * cols + col) * cellSize;
102
+ const cp = view.getUint32(off, true);
103
+ const fg_r = view.getUint8(off + 4);
104
+ const fg_g = view.getUint8(off + 5);
105
+ const fg_b = view.getUint8(off + 6);
106
+ const bg_r = view.getUint8(off + 7);
107
+ const bg_g = view.getUint8(off + 8);
108
+ const bg_b = view.getUint8(off + 9);
109
+ const flags = view.getUint8(off + 10);
110
+ const width = view.getUint8(off + 11);
111
+
112
+ if (width === 0) continue; // continuation cell (wide char)
113
+
114
+ // SGR: only emit changes
115
+ const fgKey = (fg_r << 16) | (fg_g << 8) | fg_b;
116
+ const bgKey = (bg_r << 16) | (bg_g << 8) | bg_b;
117
+ let sgr = "";
118
+ if (flags !== lastFlags) {
119
+ sgr += "\x1b[0m"; // reset, then re-apply
120
+ if (flags & 1) sgr += "\x1b[1m"; // bold
121
+ if (flags & 2) sgr += "\x1b[3m"; // italic
122
+ if (flags & 4) sgr += "\x1b[4m"; // underline
123
+ if (flags & 128) sgr += "\x1b[2m"; // faint
124
+ lastFg = null;
125
+ lastBg = null; // force re-emit colors after reset
126
+ lastFlags = flags;
127
+ }
128
+ if (fgKey !== lastFg && fgKey !== 0) {
129
+ sgr += `\x1b[38;2;${fg_r};${fg_g};${fg_b}m`;
130
+ lastFg = fgKey;
131
+ }
132
+ if (bgKey !== lastBg && bgKey !== 0) {
133
+ sgr += `\x1b[48;2;${bg_r};${bg_g};${bg_b}m`;
134
+ lastBg = bgKey;
135
+ }
136
+ out += sgr;
137
+ out += cp > 0 ? String.fromCodePoint(cp) : " ";
138
+ }
139
+ }
140
+
141
+ // Restore cursor position
142
+ const cx = wasmExports.ghostty_render_state_get_cursor_x(handle);
143
+ const cy = wasmExports.ghostty_render_state_get_cursor_y(handle);
144
+ out += `\x1b[0m\x1b[${cy + 1};${cx + 1}H`;
145
+
146
+ wasmExports.ghostty_wasm_free_u8_array(bufPtr, bufSize);
147
+ return out;
148
+ },
149
+
150
+ textSnapshot(): { text: string; cols: number; rows: number } {
151
+ if (!this.handle) return { text: "", cols: 0, rows: 0 }; // disposed
152
+ wasmExports.ghostty_render_state_update(handle);
153
+ const cols = wasmExports.ghostty_render_state_get_cols(handle);
154
+ const rows = wasmExports.ghostty_render_state_get_rows(handle);
155
+ const cellSize = 16;
156
+ const bufSize = cols * rows * cellSize;
157
+ const bufPtr = wasmExports.ghostty_wasm_alloc_u8_array(bufSize);
158
+ wasmExports.ghostty_render_state_get_viewport(handle, bufPtr, bufSize);
159
+
160
+ const view = new DataView(wasmMemory!.buffer);
161
+ const lines: string[] = [];
162
+
163
+ for (let row = 0; row < rows; row++) {
164
+ let line = "";
165
+ for (let col = 0; col < cols; col++) {
166
+ const off = bufPtr + (row * cols + col) * cellSize;
167
+ const cp = view.getUint32(off, true);
168
+ const width = view.getUint8(off + 11);
169
+ if (width === 0) continue; // continuation cell (wide char)
170
+ line += cp > 0 ? String.fromCodePoint(cp) : " ";
171
+ }
172
+ lines.push(line.trimEnd());
173
+ }
174
+
175
+ wasmExports.ghostty_wasm_free_u8_array(bufPtr, bufSize);
176
+
177
+ // Trim trailing empty lines
178
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
179
+ return { text: lines.join("\n"), cols, rows };
180
+ },
181
+
182
+ dispose() {
183
+ if (this.handle === 0) return; // already disposed
184
+ wasmExports.ghostty_terminal_free(handle);
185
+ this.handle = 0;
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Workspace head — shared state for multi-device sync.
3
+ * Tracks which session/tab/view the workspace is focused on.
4
+ * All connected devices see the same workspace head and can update it.
5
+ *
6
+ * Inspired by CRDTs and Figma's multiplayer cursor model.
7
+ */
8
+
9
+ import { getDb } from "./store.js";
10
+
11
+ // ── Types ───────────────────────────────────────────────────────
12
+
13
+ export interface WorkspaceHead {
14
+ id: string;
15
+ sessionName: string;
16
+ tabId: number;
17
+ topicId: string | null;
18
+ view: string; // "live" | "inspect" | "workspace"
19
+ revision: number;
20
+ updatedByDevice: string | null;
21
+ updatedAt: number;
22
+ }
23
+
24
+ // ── Table initialization ────────────────────────────────────────
25
+
26
+ /**
27
+ * Create the workspace_head table if it doesn't exist.
28
+ * Called from store.ts getDb() during database initialization.
29
+ */
30
+ export function initWorkspaceHeadTable(): void {
31
+ const db = getDb();
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS workspace_head (
34
+ id TEXT PRIMARY KEY DEFAULT 'global',
35
+ session_name TEXT NOT NULL,
36
+ tab_id INTEGER NOT NULL,
37
+ topic_id TEXT,
38
+ view TEXT NOT NULL DEFAULT 'live',
39
+ revision INTEGER NOT NULL DEFAULT 0,
40
+ updated_by_device TEXT,
41
+ updated_at INTEGER NOT NULL
42
+ );
43
+ `);
44
+ }
45
+
46
+ // ── CRUD ────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Get the current workspace head (global singleton).
50
+ * Returns null if no head has been set yet.
51
+ */
52
+ export function getHead(): WorkspaceHead | null {
53
+ const db = getDb();
54
+ const row = db
55
+ .prepare("SELECT * FROM workspace_head WHERE id = 'global'")
56
+ .get() as any;
57
+ if (!row) return null;
58
+ return {
59
+ id: row.id,
60
+ sessionName: row.session_name,
61
+ tabId: row.tab_id,
62
+ topicId: row.topic_id,
63
+ view: row.view,
64
+ revision: row.revision,
65
+ updatedByDevice: row.updated_by_device,
66
+ updatedAt: row.updated_at,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Update the workspace head with partial fields.
72
+ * Auto-increments revision and sets updatedAt.
73
+ * Creates the head if it doesn't exist yet.
74
+ */
75
+ export function updateHead(
76
+ fields: Partial<Omit<WorkspaceHead, "id" | "revision" | "updatedAt">>,
77
+ deviceId?: string,
78
+ ): WorkspaceHead {
79
+ const db = getDb();
80
+ const now = Date.now();
81
+ const current = getHead();
82
+
83
+ if (!current) {
84
+ // First-time creation
85
+ const head: WorkspaceHead = {
86
+ id: "global",
87
+ sessionName: fields.sessionName || "default",
88
+ tabId: fields.tabId ?? 0,
89
+ topicId: fields.topicId ?? null,
90
+ view: fields.view || "live",
91
+ revision: 1,
92
+ updatedByDevice: deviceId || fields.updatedByDevice || null,
93
+ updatedAt: now,
94
+ };
95
+ db.prepare(
96
+ `INSERT INTO workspace_head (id, session_name, tab_id, topic_id, view, revision, updated_by_device, updated_at)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
98
+ ).run(
99
+ head.id,
100
+ head.sessionName,
101
+ head.tabId,
102
+ head.topicId,
103
+ head.view,
104
+ head.revision,
105
+ head.updatedByDevice,
106
+ head.updatedAt,
107
+ );
108
+ return head;
109
+ }
110
+
111
+ // Update existing head
112
+ const updated: WorkspaceHead = {
113
+ id: "global",
114
+ sessionName: fields.sessionName ?? current.sessionName,
115
+ tabId: fields.tabId ?? current.tabId,
116
+ topicId: fields.topicId !== undefined ? fields.topicId : current.topicId,
117
+ view: fields.view ?? current.view,
118
+ revision: current.revision + 1,
119
+ updatedByDevice: deviceId || fields.updatedByDevice || current.updatedByDevice,
120
+ updatedAt: now,
121
+ };
122
+
123
+ db.prepare(
124
+ `UPDATE workspace_head SET
125
+ session_name = ?,
126
+ tab_id = ?,
127
+ topic_id = ?,
128
+ view = ?,
129
+ revision = ?,
130
+ updated_by_device = ?,
131
+ updated_at = ?
132
+ WHERE id = 'global'`,
133
+ ).run(
134
+ updated.sessionName,
135
+ updated.tabId,
136
+ updated.topicId,
137
+ updated.view,
138
+ updated.revision,
139
+ updated.updatedByDevice,
140
+ updated.updatedAt,
141
+ );
142
+
143
+ return updated;
144
+ }