@wangyaoshen/remux 0.3.8-dev.a8ceb0c

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/push.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Web Push notification module for Remux.
3
+ * VAPID key management (generate on first run, persist in SQLite settings),
4
+ * push subscription helpers, and broadcast-to-all utility.
5
+ *
6
+ * Uses the web-push library (MIT) for VAPID auth + notification delivery.
7
+ * https://github.com/web-push-libs/web-push
8
+ */
9
+
10
+ import webpush from "web-push";
11
+ import {
12
+ getSetting,
13
+ setSetting,
14
+ getPushSubscription,
15
+ listPushSubscriptions,
16
+ removePushSubscription,
17
+ } from "./store.js";
18
+
19
+ // ── VAPID Keys ──────────────────────────────────────────────────
20
+
21
+ const VAPID_PUBLIC_KEY = "vapid_public_key";
22
+ const VAPID_PRIVATE_KEY = "vapid_private_key";
23
+ const VAPID_SUBJECT = "mailto:remux@localhost";
24
+
25
+ let vapidPublicKey: string | null = null;
26
+ let vapidReady = false;
27
+
28
+ /**
29
+ * Initialize Web Push: load existing VAPID keys from SQLite settings,
30
+ * or generate new ones on first run.
31
+ */
32
+ export function initPush(): void {
33
+ let pubKey = getSetting(VAPID_PUBLIC_KEY);
34
+ let privKey = getSetting(VAPID_PRIVATE_KEY);
35
+
36
+ if (!pubKey || !privKey) {
37
+ // First run: generate VAPID key pair
38
+ const keys = webpush.generateVAPIDKeys();
39
+ pubKey = keys.publicKey;
40
+ privKey = keys.privateKey;
41
+ setSetting(VAPID_PUBLIC_KEY, pubKey);
42
+ setSetting(VAPID_PRIVATE_KEY, privKey);
43
+ console.log("[push] generated new VAPID keys");
44
+ } else {
45
+ console.log("[push] loaded VAPID keys from store");
46
+ }
47
+
48
+ webpush.setVapidDetails(VAPID_SUBJECT, pubKey, privKey);
49
+ vapidPublicKey = pubKey;
50
+ vapidReady = true;
51
+ }
52
+
53
+ /**
54
+ * Return the public VAPID key for client-side subscription.
55
+ * Returns null if push is not initialized.
56
+ */
57
+ export function getVapidPublicKey(): string | null {
58
+ return vapidPublicKey;
59
+ }
60
+
61
+ /**
62
+ * Check if push notifications are initialized and ready.
63
+ */
64
+ export function isPushReady(): boolean {
65
+ return vapidReady;
66
+ }
67
+
68
+ /**
69
+ * Send a push notification to a specific device by deviceId.
70
+ * Returns true if sent successfully, false otherwise.
71
+ * Automatically removes stale subscriptions on 404/410.
72
+ */
73
+ export async function sendPushNotification(
74
+ deviceId: string,
75
+ title: string,
76
+ body: string,
77
+ ): Promise<boolean> {
78
+ if (!vapidReady) return false;
79
+
80
+ const sub = getPushSubscription(deviceId);
81
+ if (!sub) return false;
82
+
83
+ const payload = JSON.stringify({ title, body, tag: "notification" });
84
+
85
+ try {
86
+ await webpush.sendNotification(
87
+ {
88
+ endpoint: sub.endpoint,
89
+ keys: { p256dh: sub.p256dh, auth: sub.auth },
90
+ },
91
+ payload,
92
+ );
93
+ return true;
94
+ } catch (err: any) {
95
+ // 404 or 410 means subscription is no longer valid
96
+ if (err.statusCode === 404 || err.statusCode === 410) {
97
+ removePushSubscription(deviceId);
98
+ console.log(
99
+ `[push] removed stale subscription for device ${deviceId} (${err.statusCode})`,
100
+ );
101
+ } else {
102
+ console.error(`[push] failed to send to device ${deviceId}:`, err.message);
103
+ }
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Broadcast a push notification to all subscribed devices,
110
+ * optionally excluding specific device IDs (e.g., currently connected ones).
111
+ */
112
+ export async function broadcastPush(
113
+ title: string,
114
+ body: string,
115
+ excludeDeviceIds: string[] = [],
116
+ ): Promise<void> {
117
+ if (!vapidReady) return;
118
+
119
+ const subs = listPushSubscriptions();
120
+ const excludeSet = new Set(excludeDeviceIds);
121
+
122
+ const promises = subs
123
+ .filter((s) => !excludeSet.has(s.deviceId))
124
+ .map((s) => sendPushNotification(s.deviceId, title, body));
125
+
126
+ await Promise.allSettled(promises);
127
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Lightweight content renderers for workspace artifacts.
3
+ * Converts diff, markdown, and ANSI text to styled HTML.
4
+ * No external dependencies — pure string/regex parsing.
5
+ */
6
+
7
+ // ── Helpers ────────────────────────────────────────────────────
8
+
9
+ function escapeHtml(s: string): string {
10
+ return s
11
+ .replace(/&/g, "&amp;")
12
+ .replace(/</g, "&lt;")
13
+ .replace(/>/g, "&gt;")
14
+ .replace(/"/g, "&quot;");
15
+ }
16
+
17
+ // ── Content-type detection ─────────────────────────────────────
18
+
19
+ /**
20
+ * Detect content type from raw text.
21
+ * Priority: diff > ansi > markdown > plain.
22
+ */
23
+ export function detectContentType(
24
+ text: string,
25
+ ): "diff" | "markdown" | "ansi" | "plain" {
26
+ if (!text) return "plain";
27
+
28
+ // Unified diff: "diff --git" header or "---"/"+++" pair with @@ hunks
29
+ if (/^diff --git /m.test(text)) return "diff";
30
+ if (
31
+ /^--- .+\n\+\+\+ .+\n@@/m.test(text)
32
+ )
33
+ return "diff";
34
+
35
+ // ANSI: contains SGR escape sequences
36
+ if (/\x1b\[[\d;]*m/.test(text)) return "ansi";
37
+
38
+ // Markdown heuristics: headers, bold, code blocks, links
39
+ if (/^#{1,6}\s+\S/m.test(text)) return "markdown";
40
+ if (/\*\*[^*]+\*\*/.test(text)) return "markdown";
41
+ if (/```[\s\S]*?```/.test(text)) return "markdown";
42
+ if (/\[[^\]]+\]\([^)]+\)/.test(text)) return "markdown";
43
+
44
+ return "plain";
45
+ }
46
+
47
+ // ── Diff renderer ──────────────────────────────────────────────
48
+
49
+ /**
50
+ * Render unified diff text to HTML.
51
+ * Produces a <div class="diff-container"> with per-line markup.
52
+ */
53
+ export function renderDiff(diffText: string): string {
54
+ const lines = diffText.split("\n");
55
+ const out: string[] = ['<div class="diff-container">'];
56
+
57
+ // Track line numbers for old/new sides
58
+ let oldLine = 0;
59
+ let newLine = 0;
60
+
61
+ for (const raw of lines) {
62
+ const escaped = escapeHtml(raw);
63
+
64
+ if (
65
+ raw.startsWith("diff --git") ||
66
+ raw.startsWith("index ") ||
67
+ raw.startsWith("---") ||
68
+ raw.startsWith("+++")
69
+ ) {
70
+ out.push(
71
+ `<div class="diff-header">${escaped}</div>`,
72
+ );
73
+ } else if (raw.startsWith("@@")) {
74
+ // Parse hunk header for line numbers
75
+ const m = raw.match(/@@ -(\d+)/);
76
+ if (m) {
77
+ oldLine = parseInt(m[1], 10);
78
+ const m2 = raw.match(/@@ -\d+(?:,\d+)? \+(\d+)/);
79
+ newLine = m2 ? parseInt(m2[1], 10) : oldLine;
80
+ }
81
+ out.push(
82
+ `<div class="diff-hunk">${escaped}</div>`,
83
+ );
84
+ } else if (raw.startsWith("+")) {
85
+ out.push(
86
+ `<div class="diff-add"><span class="diff-line-num">${newLine}</span>${escaped}</div>`,
87
+ );
88
+ newLine++;
89
+ } else if (raw.startsWith("-")) {
90
+ out.push(
91
+ `<div class="diff-del"><span class="diff-line-num">${oldLine}</span>${escaped}</div>`,
92
+ );
93
+ oldLine++;
94
+ } else {
95
+ // Context line (starts with space or is empty)
96
+ out.push(
97
+ `<div class="diff-ctx"><span class="diff-line-num">${oldLine}</span>${escaped}</div>`,
98
+ );
99
+ oldLine++;
100
+ newLine++;
101
+ }
102
+ }
103
+
104
+ out.push("</div>");
105
+ return out.join("\n");
106
+ }
107
+
108
+ // ── Markdown renderer ──────────────────────────────────────────
109
+
110
+ /**
111
+ * Lightweight markdown-to-HTML converter.
112
+ * Covers headers, bold, italic, inline code, code blocks,
113
+ * lists, links, blockquotes, horizontal rules, paragraphs.
114
+ */
115
+ export function renderMarkdown(md: string): string {
116
+ const lines = md.split("\n");
117
+ const out: string[] = ['<div class="rendered-md">'];
118
+
119
+ let inCodeBlock = false;
120
+ let codeLang = "";
121
+ let codeLines: string[] = [];
122
+ let inList: "ul" | "ol" | null = null;
123
+ let inBlockquote = false;
124
+ let paragraph: string[] = [];
125
+
126
+ function flushParagraph(): void {
127
+ if (paragraph.length > 0) {
128
+ const text = paragraph.join(" ");
129
+ out.push(`<p>${inlineFormat(text)}</p>`);
130
+ paragraph = [];
131
+ }
132
+ }
133
+
134
+ function flushList(): void {
135
+ if (inList) {
136
+ out.push(`</${inList}>`);
137
+ inList = null;
138
+ }
139
+ }
140
+
141
+ function flushBlockquote(): void {
142
+ if (inBlockquote) {
143
+ out.push("</blockquote>");
144
+ inBlockquote = false;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Apply inline formatting to raw (unescaped) text.
150
+ * Handles: inline code, bold, italic, links.
151
+ * Escapes HTML at leaf level to avoid double-escaping.
152
+ */
153
+ function inlineFormat(raw: string): string {
154
+ // Split on inline code spans first to protect their content
155
+ const parts = raw.split(/(`[^`]+`)/);
156
+ return parts
157
+ .map((part) => {
158
+ if (part.startsWith("`") && part.endsWith("`")) {
159
+ // Inline code — escape content, wrap in <code>
160
+ const code = part.slice(1, -1);
161
+ return `<code>${escapeHtml(code)}</code>`;
162
+ }
163
+ // Non-code text: escape, then apply bold/italic/links
164
+ let escaped = escapeHtml(part);
165
+ escaped = escaped.replace(
166
+ /\*\*([^*]+)\*\*/g,
167
+ "<strong>$1</strong>",
168
+ );
169
+ escaped = escaped.replace(
170
+ /(?<!\*)\*([^*]+)\*(?!\*)/g,
171
+ "<em>$1</em>",
172
+ );
173
+ escaped = escaped.replace(
174
+ /\[([^\]]+)\]\(([^)]+)\)/g,
175
+ '<a href="$2" target="_blank" rel="noopener">$1</a>',
176
+ );
177
+ return escaped;
178
+ })
179
+ .join("");
180
+ }
181
+
182
+ for (let i = 0; i < lines.length; i++) {
183
+ const line = lines[i];
184
+
185
+ // Code block toggle
186
+ if (line.startsWith("```")) {
187
+ if (!inCodeBlock) {
188
+ flushParagraph();
189
+ flushList();
190
+ flushBlockquote();
191
+ inCodeBlock = true;
192
+ codeLang = line.slice(3).trim();
193
+ codeLines = [];
194
+ continue;
195
+ } else {
196
+ // End code block
197
+ const langAttr = codeLang
198
+ ? ` class="language-${escapeHtml(codeLang)}"`
199
+ : "";
200
+ out.push(
201
+ `<pre><code${langAttr}>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
202
+ );
203
+ inCodeBlock = false;
204
+ codeLang = "";
205
+ codeLines = [];
206
+ continue;
207
+ }
208
+ }
209
+
210
+ if (inCodeBlock) {
211
+ codeLines.push(line);
212
+ continue;
213
+ }
214
+
215
+ // Horizontal rule
216
+ if (/^---+$/.test(line.trim())) {
217
+ flushParagraph();
218
+ flushList();
219
+ flushBlockquote();
220
+ out.push("<hr>");
221
+ continue;
222
+ }
223
+
224
+ // Headers
225
+ const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
226
+ if (headerMatch) {
227
+ flushParagraph();
228
+ flushList();
229
+ flushBlockquote();
230
+ const level = headerMatch[1].length;
231
+ const text = escapeHtml(headerMatch[2]);
232
+ out.push(`<h${level}>${text}</h${level}>`);
233
+ continue;
234
+ }
235
+
236
+ // Blockquotes
237
+ if (line.startsWith("> ")) {
238
+ flushParagraph();
239
+ flushList();
240
+ if (!inBlockquote) {
241
+ inBlockquote = true;
242
+ out.push("<blockquote>");
243
+ }
244
+ out.push(inlineFormat(line.slice(2)));
245
+ continue;
246
+ } else if (inBlockquote) {
247
+ flushBlockquote();
248
+ }
249
+
250
+ // Unordered list
251
+ if (/^[-*]\s+/.test(line)) {
252
+ flushParagraph();
253
+ flushBlockquote();
254
+ if (inList !== "ul") {
255
+ flushList();
256
+ inList = "ul";
257
+ out.push("<ul>");
258
+ }
259
+ const text = line.replace(/^[-*]\s+/, "");
260
+ out.push(`<li>${inlineFormat(text)}</li>`);
261
+ continue;
262
+ }
263
+
264
+ // Ordered list
265
+ if (/^\d+\.\s+/.test(line)) {
266
+ flushParagraph();
267
+ flushBlockquote();
268
+ if (inList !== "ol") {
269
+ flushList();
270
+ inList = "ol";
271
+ out.push("<ol>");
272
+ }
273
+ const text = line.replace(/^\d+\.\s+/, "");
274
+ out.push(`<li>${inlineFormat(text)}</li>`);
275
+ continue;
276
+ }
277
+
278
+ // End of list on non-list line
279
+ if (inList && !/^\s*$/.test(line)) {
280
+ flushList();
281
+ }
282
+
283
+ // Empty line = paragraph break
284
+ if (line.trim() === "") {
285
+ flushParagraph();
286
+ flushList();
287
+ continue;
288
+ }
289
+
290
+ // Regular text -> accumulate into paragraph (raw, inlineFormat handles escaping)
291
+ paragraph.push(line);
292
+ }
293
+
294
+ // Flush remaining state
295
+ if (inCodeBlock) {
296
+ const langAttr = codeLang
297
+ ? ` class="language-${escapeHtml(codeLang)}"`
298
+ : "";
299
+ out.push(
300
+ `<pre><code${langAttr}>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
301
+ );
302
+ }
303
+ flushParagraph();
304
+ flushList();
305
+ flushBlockquote();
306
+
307
+ out.push("</div>");
308
+ return out.join("\n");
309
+ }
310
+
311
+ // ── ANSI renderer ──────────────────────────────────────────────
312
+
313
+ // Standard 8 ANSI colors (normal intensity)
314
+ const ANSI_COLORS: Record<number, string> = {
315
+ 30: "#000000",
316
+ 31: "#cc0000",
317
+ 32: "#00cc00",
318
+ 33: "#cccc00",
319
+ 34: "#0000cc",
320
+ 35: "#cc00cc",
321
+ 36: "#00cccc",
322
+ 37: "#cccccc",
323
+ };
324
+
325
+ // Bright ANSI colors
326
+ const ANSI_BRIGHT_COLORS: Record<number, string> = {
327
+ 90: "#555555",
328
+ 91: "#ff5555",
329
+ 92: "#55ff55",
330
+ 93: "#ffff55",
331
+ 94: "#5555ff",
332
+ 95: "#ff55ff",
333
+ 96: "#55ffff",
334
+ 97: "#ffffff",
335
+ };
336
+
337
+ interface AnsiState {
338
+ bold: boolean;
339
+ dim: boolean;
340
+ italic: boolean;
341
+ underline: boolean;
342
+ fgColor: string | null;
343
+ }
344
+
345
+ /**
346
+ * Convert ANSI escape sequences to HTML spans.
347
+ * Supports: basic 8 colors, bright colors, bold, dim, italic, underline, reset.
348
+ * Strips non-SGR escape sequences (cursor movement, erase, etc).
349
+ */
350
+ export function renderAnsi(ansiText: string): string {
351
+ // First, strip all non-SGR CSI sequences (anything that's not \x1b[...m)
352
+ // Also strip OSC sequences (\x1b]...\x07)
353
+ let cleaned = ansiText.replace(/\x1b\][^\x07]*\x07/g, "");
354
+ // Strip non-SGR CSI sequences: \x1b[ followed by params and a final byte that isn't 'm'
355
+ cleaned = cleaned.replace(/\x1b\[[\d;]*[A-LN-Za-ln-z]/g, "");
356
+
357
+ // Check if there are any SGR sequences at all
358
+ if (!/\x1b\[[\d;]*m/.test(cleaned)) {
359
+ return escapeHtml(cleaned);
360
+ }
361
+
362
+ const state: AnsiState = {
363
+ bold: false,
364
+ dim: false,
365
+ italic: false,
366
+ underline: false,
367
+ fgColor: null,
368
+ };
369
+
370
+ const parts: string[] = [];
371
+ let spanOpen = false;
372
+
373
+ // Split on SGR sequences
374
+ const re = /\x1b\[([\d;]*)m/g;
375
+ let lastIndex = 0;
376
+ let match: RegExpExecArray | null;
377
+
378
+ while ((match = re.exec(cleaned)) !== null) {
379
+ // Emit text before this sequence
380
+ const text = cleaned.slice(lastIndex, match.index);
381
+ if (text) {
382
+ parts.push(escapeHtml(text));
383
+ }
384
+ lastIndex = match.index + match[0].length;
385
+
386
+ // Parse SGR codes
387
+ const codes = match[1]
388
+ ? match[1].split(";").map(Number)
389
+ : [0];
390
+
391
+ for (const code of codes) {
392
+ if (code === 0) {
393
+ // Reset
394
+ if (spanOpen) {
395
+ parts.push("</span>");
396
+ spanOpen = false;
397
+ }
398
+ state.bold = false;
399
+ state.dim = false;
400
+ state.italic = false;
401
+ state.underline = false;
402
+ state.fgColor = null;
403
+ } else if (code === 1) {
404
+ state.bold = true;
405
+ } else if (code === 2) {
406
+ state.dim = true;
407
+ } else if (code === 3) {
408
+ state.italic = true;
409
+ } else if (code === 4) {
410
+ state.underline = true;
411
+ } else if (code >= 30 && code <= 37) {
412
+ state.fgColor = ANSI_COLORS[code] || null;
413
+ } else if (code >= 90 && code <= 97) {
414
+ state.fgColor = ANSI_BRIGHT_COLORS[code] || null;
415
+ }
416
+ }
417
+
418
+ // Close previous span and open new one with current state
419
+ if (spanOpen) {
420
+ parts.push("</span>");
421
+ spanOpen = false;
422
+ }
423
+
424
+ const classes: string[] = [];
425
+ const styles: string[] = [];
426
+
427
+ if (state.bold) classes.push("ansi-bold");
428
+ if (state.dim) classes.push("ansi-dim");
429
+ if (state.italic) classes.push("ansi-italic");
430
+ if (state.underline) classes.push("ansi-underline");
431
+ if (state.fgColor) styles.push(`color:${state.fgColor}`);
432
+
433
+ if (classes.length > 0 || styles.length > 0) {
434
+ let tag = "<span";
435
+ if (classes.length > 0) tag += ` class="${classes.join(" ")}"`;
436
+ if (styles.length > 0) tag += ` style="${styles.join(";")}"`;
437
+ tag += ">";
438
+ parts.push(tag);
439
+ spanOpen = true;
440
+ }
441
+ }
442
+
443
+ // Remaining text after last sequence
444
+ const remainder = cleaned.slice(lastIndex);
445
+ if (remainder) {
446
+ parts.push(escapeHtml(remainder));
447
+ }
448
+
449
+ // Close any open span
450
+ if (spanOpen) {
451
+ parts.push("</span>");
452
+ }
453
+
454
+ return parts.join("");
455
+ }