@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
@@ -0,0 +1,152 @@
1
+ import AppKit
2
+
3
+ /// Lightweight update checker that polls GitHub releases API.
4
+ /// Adapted from Sparkle's conceptual model but without the framework dependency.
5
+ @MainActor
6
+ @Observable
7
+ final class UpdateChecker {
8
+
9
+ // MARK: - Published state
10
+
11
+ private(set) var latestVersion: String?
12
+ private(set) var releaseURL: String?
13
+ private(set) var releaseNotes: String?
14
+ private(set) var hasUpdate: Bool = false
15
+
16
+ // MARK: - Config
17
+
18
+ /// GitHub API endpoint for latest release.
19
+ static let apiURL = "https://api.github.com/repos/yaoshenwang/remux/releases/latest"
20
+
21
+ /// Check interval: 4 hours.
22
+ private let checkInterval: TimeInterval = 4 * 60 * 60
23
+
24
+ /// UserDefaults key for dismissed version.
25
+ private static let dismissedVersionKey = "UpdateChecker.dismissedVersion"
26
+
27
+ // MARK: - Internal state
28
+
29
+ private var timer: Timer?
30
+ private var isChecking = false
31
+
32
+ init() {}
33
+
34
+ // MARK: - Public API
35
+
36
+ /// Start the update checker: check immediately, then every 4 hours.
37
+ func start() {
38
+ Task { await check() }
39
+ timer = Timer.scheduledTimer(withTimeInterval: checkInterval, repeats: true) { [weak self] _ in
40
+ Task { @MainActor [weak self] in
41
+ await self?.check()
42
+ }
43
+ }
44
+ }
45
+
46
+ /// Stop the periodic check timer.
47
+ func stop() {
48
+ timer?.invalidate()
49
+ timer = nil
50
+ }
51
+
52
+ /// Dismiss the current update notification (suppress until a newer version appears).
53
+ func dismissCurrentUpdate() {
54
+ guard let version = latestVersion else { return }
55
+ UserDefaults.standard.set(version, forKey: Self.dismissedVersionKey)
56
+ hasUpdate = false
57
+ }
58
+
59
+ /// Open the release page in the default browser.
60
+ func openReleasePage() {
61
+ guard let urlStr = releaseURL, let url = URL(string: urlStr) else {
62
+ // Fallback to releases page
63
+ if let url = URL(string: "https://github.com/yaoshenwang/remux/releases") {
64
+ NSWorkspace.shared.open(url)
65
+ }
66
+ return
67
+ }
68
+ NSWorkspace.shared.open(url)
69
+ }
70
+
71
+ /// Force a manual check (ignores dismissed version).
72
+ func checkNow() async {
73
+ await check(ignoreDismissed: true)
74
+ }
75
+
76
+ // MARK: - Check logic
77
+
78
+ private func check(ignoreDismissed: Bool = false) async {
79
+ guard !isChecking else { return }
80
+ isChecking = true
81
+ defer { isChecking = false }
82
+
83
+ guard let url = URL(string: Self.apiURL) else { return }
84
+
85
+ do {
86
+ var request = URLRequest(url: url)
87
+ request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
88
+ request.timeoutInterval = 15
89
+
90
+ let (data, response) = try await URLSession.shared.data(for: request)
91
+
92
+ guard let httpResponse = response as? HTTPURLResponse,
93
+ httpResponse.statusCode == 200 else {
94
+ NSLog("[remux] Update check: non-200 response")
95
+ return
96
+ }
97
+
98
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
99
+ let tagName = json["tag_name"] as? String else {
100
+ NSLog("[remux] Update check: failed to parse response")
101
+ return
102
+ }
103
+
104
+ let remoteVersion = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
105
+ let htmlURL = json["html_url"] as? String
106
+ let body = json["body"] as? String
107
+
108
+ latestVersion = remoteVersion
109
+ releaseURL = htmlURL
110
+ releaseNotes = body
111
+
112
+ let currentVersion = Self.currentBundleVersion()
113
+ let isNewer = Self.isVersion(remoteVersion, newerThan: currentVersion)
114
+
115
+ // Check if this version was dismissed
116
+ let dismissedVersion = UserDefaults.standard.string(forKey: Self.dismissedVersionKey)
117
+ let isDismissed = !ignoreDismissed && dismissedVersion == remoteVersion
118
+
119
+ hasUpdate = isNewer && !isDismissed
120
+
121
+ if hasUpdate {
122
+ NSLog("[remux] Update available: %@ -> %@", currentVersion, remoteVersion)
123
+ }
124
+ } catch {
125
+ NSLog("[remux] Update check failed: %@", error.localizedDescription)
126
+ }
127
+ }
128
+
129
+ // MARK: - Version comparison
130
+
131
+ /// Get the current app version from the bundle, or a fallback.
132
+ static func currentBundleVersion() -> String {
133
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
134
+ ?? ProcessInfo.processInfo.environment["REMUX_VERSION"]
135
+ ?? "0.0.0"
136
+ }
137
+
138
+ /// Compare semantic version strings. Returns true if `a` is newer than `b`.
139
+ static func isVersion(_ a: String, newerThan b: String) -> Bool {
140
+ let aParts = a.split(separator: ".").compactMap { Int($0) }
141
+ let bParts = b.split(separator: ".").compactMap { Int($0) }
142
+
143
+ let maxLen = max(aParts.count, bParts.count)
144
+ for i in 0..<maxLen {
145
+ let aVal = i < aParts.count ? aParts[i] : 0
146
+ let bVal = i < bParts.count ? bParts[i] : 0
147
+ if aVal > bVal { return true }
148
+ if aVal < bVal { return false }
149
+ }
150
+ return false
151
+ }
152
+ }
@@ -0,0 +1,198 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ /// Command entry for the palette. Each command has a display name,
5
+ /// optional shortcut string, and an action closure.
6
+ struct PaletteCommand: Identifiable {
7
+ let id: String
8
+ let name: String
9
+ let shortcut: String
10
+ let category: String
11
+ let action: @MainActor () -> Void
12
+ }
13
+
14
+ /// Modal command palette overlay triggered by Cmd+Shift+P.
15
+ /// Provides fuzzy search over all available commands.
16
+ /// Adapted from VS Code / Warp command palette UX pattern.
17
+ struct CommandPalette: View {
18
+ @Binding var isPresented: Bool
19
+ let commands: [PaletteCommand]
20
+
21
+ @State private var query: String = ""
22
+ @State private var selectedIndex: Int = 0
23
+ @FocusState private var isFocused: Bool
24
+
25
+ private var filteredCommands: [PaletteCommand] {
26
+ if query.isEmpty { return commands }
27
+ let q = query.lowercased()
28
+ return commands.filter { cmd in
29
+ fuzzyMatch(query: q, target: cmd.name.lowercased())
30
+ }
31
+ }
32
+
33
+ var body: some View {
34
+ if isPresented {
35
+ ZStack {
36
+ // Dismiss backdrop
37
+ Color.black.opacity(0.3)
38
+ .ignoresSafeArea()
39
+ .onTapGesture { dismiss() }
40
+
41
+ VStack(spacing: 0) {
42
+ // Search field
43
+ HStack(spacing: 8) {
44
+ Image(systemName: "magnifyingglass")
45
+ .foregroundStyle(.secondary)
46
+ .font(.system(size: 14))
47
+
48
+ TextField("Type a command...", text: $query)
49
+ .textFieldStyle(.plain)
50
+ .font(.system(size: 14))
51
+ .focused($isFocused)
52
+ .onSubmit { executeSelected() }
53
+ .onChange(of: query) { _, _ in
54
+ selectedIndex = 0
55
+ }
56
+ }
57
+ .padding(.horizontal, 14)
58
+ .padding(.vertical, 10)
59
+
60
+ Divider()
61
+
62
+ // Command list
63
+ ScrollViewReader { proxy in
64
+ ScrollView {
65
+ LazyVStack(spacing: 0) {
66
+ let filtered = filteredCommands
67
+ ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, cmd in
68
+ CommandRow(
69
+ command: cmd,
70
+ isSelected: idx == selectedIndex
71
+ )
72
+ .id(idx)
73
+ .onTapGesture {
74
+ selectedIndex = idx
75
+ executeSelected()
76
+ }
77
+ .onHover { hovering in
78
+ if hovering { selectedIndex = idx }
79
+ }
80
+ }
81
+
82
+ if filtered.isEmpty {
83
+ Text("No matching commands")
84
+ .foregroundStyle(.secondary)
85
+ .font(.system(size: 13))
86
+ .padding(.vertical, 20)
87
+ }
88
+ }
89
+ }
90
+ .frame(maxHeight: 300)
91
+ .onChange(of: selectedIndex) { _, newValue in
92
+ proxy.scrollTo(newValue, anchor: .center)
93
+ }
94
+ }
95
+ }
96
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
97
+ .overlay(
98
+ RoundedRectangle(cornerRadius: 10)
99
+ .stroke(Color.primary.opacity(0.1), lineWidth: 1)
100
+ )
101
+ .shadow(color: .black.opacity(0.25), radius: 20, y: 10)
102
+ .frame(width: 500)
103
+ .frame(maxHeight: 380)
104
+ .offset(y: -80)
105
+ }
106
+ .onAppear {
107
+ query = ""
108
+ selectedIndex = 0
109
+ isFocused = true
110
+ }
111
+ .onKeyPress(.upArrow) {
112
+ moveSelection(by: -1)
113
+ return .handled
114
+ }
115
+ .onKeyPress(.downArrow) {
116
+ moveSelection(by: 1)
117
+ return .handled
118
+ }
119
+ .onKeyPress(.escape) {
120
+ dismiss()
121
+ return .handled
122
+ }
123
+ .onKeyPress(.return) {
124
+ executeSelected()
125
+ return .handled
126
+ }
127
+ }
128
+ }
129
+
130
+ private func moveSelection(by delta: Int) {
131
+ let count = filteredCommands.count
132
+ guard count > 0 else { return }
133
+ selectedIndex = (selectedIndex + delta + count) % count
134
+ }
135
+
136
+ private func executeSelected() {
137
+ let filtered = filteredCommands
138
+ guard selectedIndex >= 0, selectedIndex < filtered.count else { return }
139
+ let cmd = filtered[selectedIndex]
140
+ dismiss()
141
+ cmd.action()
142
+ }
143
+
144
+ private func dismiss() {
145
+ isPresented = false
146
+ query = ""
147
+ }
148
+
149
+ /// Simple fuzzy match: all characters in query appear in order in target.
150
+ private func fuzzyMatch(query: String, target: String) -> Bool {
151
+ var targetIdx = target.startIndex
152
+ for qChar in query {
153
+ guard let found = target[targetIdx...].firstIndex(of: qChar) else {
154
+ return false
155
+ }
156
+ targetIdx = target.index(after: found)
157
+ }
158
+ return true
159
+ }
160
+ }
161
+
162
+ /// Single command row in the palette.
163
+ struct CommandRow: View {
164
+ let command: PaletteCommand
165
+ let isSelected: Bool
166
+
167
+ var body: some View {
168
+ HStack {
169
+ VStack(alignment: .leading, spacing: 1) {
170
+ Text(command.name)
171
+ .font(.system(size: 13))
172
+ .foregroundStyle(isSelected ? .primary : .primary)
173
+
174
+ Text(command.category)
175
+ .font(.system(size: 10))
176
+ .foregroundStyle(.secondary)
177
+ }
178
+
179
+ Spacer()
180
+
181
+ if !command.shortcut.isEmpty {
182
+ Text(command.shortcut)
183
+ .font(.system(size: 11, design: .monospaced))
184
+ .foregroundStyle(.secondary)
185
+ .padding(.horizontal, 6)
186
+ .padding(.vertical, 2)
187
+ .background(
188
+ RoundedRectangle(cornerRadius: 4)
189
+ .fill(Color.primary.opacity(0.06))
190
+ )
191
+ }
192
+ }
193
+ .padding(.horizontal, 14)
194
+ .padding(.vertical, 6)
195
+ .background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear)
196
+ .contentShape(Rectangle())
197
+ }
198
+ }
@@ -0,0 +1,84 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Connection setup view — shown when not connected.
5
+ struct ConnectionView: View {
6
+ @Environment(RemuxState.self) private var state
7
+ @State private var serverURL = ""
8
+ @State private var token = ""
9
+ @State private var errorMessage: String?
10
+
11
+ private let keychain = KeychainStore()
12
+
13
+ var body: some View {
14
+ VStack(spacing: 20) {
15
+ Image(systemName: "terminal")
16
+ .font(.system(size: 48))
17
+ .foregroundStyle(.secondary)
18
+
19
+ Text("Connect to Remux Server")
20
+ .font(.title2)
21
+
22
+ VStack(alignment: .leading, spacing: 12) {
23
+ TextField("Server URL (e.g. http://localhost:8767)", text: $serverURL)
24
+ .textFieldStyle(.roundedBorder)
25
+
26
+ SecureField("Token", text: $token)
27
+ .textFieldStyle(.roundedBorder)
28
+
29
+ if let error = errorMessage {
30
+ Text(error)
31
+ .foregroundStyle(.red)
32
+ .font(.caption)
33
+ }
34
+ }
35
+ .frame(maxWidth: 400)
36
+
37
+ Button("Connect") {
38
+ connect()
39
+ }
40
+ .buttonStyle(.borderedProminent)
41
+ .disabled(serverURL.isEmpty || token.isEmpty)
42
+
43
+ // Saved servers
44
+ let savedServers = keychain.savedServers()
45
+ if !savedServers.isEmpty {
46
+ Divider()
47
+ Text("Recent Servers")
48
+ .font(.caption)
49
+ .foregroundStyle(.secondary)
50
+
51
+ ForEach(savedServers, id: \.self) { server in
52
+ Button(server) {
53
+ serverURL = server
54
+ if let savedToken = keychain.loadServerToken(forServer: server) {
55
+ token = savedToken
56
+ connect()
57
+ } else if let resumeToken = keychain.loadResumeToken(forServer: server) {
58
+ connectWithResumeToken(server: server, resumeToken: resumeToken)
59
+ }
60
+ }
61
+ .buttonStyle(.plain)
62
+ .foregroundStyle(.blue)
63
+ }
64
+ }
65
+ }
66
+ .padding(40)
67
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
68
+ }
69
+
70
+ private func connect() {
71
+ guard let url = URL(string: serverURL) else {
72
+ errorMessage = "Invalid URL"
73
+ return
74
+ }
75
+ errorMessage = nil
76
+ try? keychain.saveServerToken(token, forServer: serverURL)
77
+ state.connect(url: url, credential: .token(token))
78
+ }
79
+
80
+ private func connectWithResumeToken(server: String, resumeToken: String) {
81
+ guard let url = URL(string: server) else { return }
82
+ state.connect(url: url, credential: .resumeToken(resumeToken))
83
+ }
84
+ }
@@ -0,0 +1,127 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Inspect panel showing readable terminal content.
5
+ /// Toggleable via Cmd+I or View menu.
6
+ struct InspectView: View {
7
+ @Environment(RemuxState.self) private var state
8
+ @State private var searchQuery = ""
9
+ @State private var isAutoRefreshing = true
10
+
11
+ private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
12
+
13
+ var body: some View {
14
+ VStack(spacing: 0) {
15
+ // Header with badges
16
+ HStack {
17
+ Text("Inspect")
18
+ .font(.headline)
19
+
20
+ Spacer()
21
+
22
+ if let snapshot = state.inspectSnapshot {
23
+ HStack(spacing: 6) {
24
+ Badge(text: snapshot.descriptor.source, color: .blue)
25
+ Badge(text: snapshot.descriptor.precision, color: precisionColor(snapshot.descriptor.precision))
26
+ Badge(text: snapshot.descriptor.staleness, color: stalenessColor(snapshot.descriptor.staleness))
27
+ }
28
+ }
29
+
30
+ Button(action: { requestInspect() }) {
31
+ Image(systemName: "arrow.clockwise")
32
+ }
33
+ .buttonStyle(.plain)
34
+ .help("Refresh")
35
+ }
36
+ .padding(.horizontal, 12)
37
+ .padding(.vertical, 8)
38
+ .background(.bar)
39
+
40
+ // Search bar
41
+ HStack {
42
+ Image(systemName: "magnifyingglass")
43
+ .foregroundStyle(.secondary)
44
+ TextField("Search terminal content...", text: $searchQuery)
45
+ .textFieldStyle(.plain)
46
+ .onSubmit { requestInspect() }
47
+ if !searchQuery.isEmpty {
48
+ Button(action: { searchQuery = ""; requestInspect() }) {
49
+ Image(systemName: "xmark.circle.fill")
50
+ .foregroundStyle(.secondary)
51
+ }
52
+ .buttonStyle(.plain)
53
+ }
54
+ }
55
+ .padding(.horizontal, 12)
56
+ .padding(.vertical, 6)
57
+
58
+ Divider()
59
+
60
+ // Content
61
+ if let snapshot = state.inspectSnapshot {
62
+ ScrollView {
63
+ LazyVStack(alignment: .leading, spacing: 0) {
64
+ ForEach(Array(snapshot.items.enumerated()), id: \.offset) { _, item in
65
+ Text(item.content)
66
+ .font(.system(.body, design: .monospaced))
67
+ .foregroundStyle(item.type == "output" ? .primary : .secondary)
68
+ .textSelection(.enabled)
69
+ .padding(.horizontal, 12)
70
+ .padding(.vertical, 1)
71
+ }
72
+ }
73
+ }
74
+ } else {
75
+ ContentUnavailableView(
76
+ "No Inspect Data",
77
+ systemImage: "doc.text",
78
+ description: Text("Connect to a server and switch to a tab to inspect terminal content.")
79
+ )
80
+ }
81
+ }
82
+ .onReceive(refreshTimer) { _ in
83
+ if isAutoRefreshing {
84
+ requestInspect()
85
+ }
86
+ }
87
+ .onAppear { requestInspect() }
88
+ }
89
+
90
+ private func requestInspect() {
91
+ state.requestInspect(
92
+ tabIndex: state.activeTabIndex,
93
+ query: searchQuery.isEmpty ? nil : searchQuery
94
+ )
95
+ }
96
+
97
+ private func precisionColor(_ p: String) -> Color {
98
+ switch p {
99
+ case "precise": .green
100
+ case "approximate": .yellow
101
+ default: .gray
102
+ }
103
+ }
104
+
105
+ private func stalenessColor(_ s: String) -> Color {
106
+ switch s {
107
+ case "fresh": .green
108
+ case "stale": .orange
109
+ default: .gray
110
+ }
111
+ }
112
+ }
113
+
114
+ struct Badge: View {
115
+ let text: String
116
+ let color: Color
117
+
118
+ var body: some View {
119
+ Text(text)
120
+ .font(.system(size: 10))
121
+ .padding(.horizontal, 6)
122
+ .padding(.vertical, 2)
123
+ .background(color.opacity(0.15))
124
+ .foregroundStyle(color)
125
+ .cornerRadius(4)
126
+ }
127
+ }
@@ -0,0 +1,77 @@
1
+ import SwiftUI
2
+ import ServiceManagement
3
+
4
+ /// Settings window for Remux macOS app.
5
+ struct SettingsView: View {
6
+ @AppStorage("theme") private var theme: String = "system"
7
+ @AppStorage("globalShortcut") private var globalShortcut: String = "⌘⇧R"
8
+ @AppStorage("notifyBell") private var notifyBell = true
9
+ @AppStorage("notifyRunComplete") private var notifyRunComplete = true
10
+ @AppStorage("notifyApproval") private var notifyApproval = true
11
+ @AppStorage("launchAtLogin") private var launchAtLogin = false
12
+
13
+ var body: some View {
14
+ TabView {
15
+ generalTab
16
+ .tabItem { Label("General", systemImage: "gear") }
17
+
18
+ ShortcutSettingsView()
19
+ .tabItem { Label("Shortcuts", systemImage: "keyboard") }
20
+
21
+ notificationsTab
22
+ .tabItem { Label("Notifications", systemImage: "bell") }
23
+ }
24
+ .frame(width: 560, height: 440)
25
+ .padding()
26
+ }
27
+
28
+ private var generalTab: some View {
29
+ Form {
30
+ Picker("Theme", selection: $theme) {
31
+ Text("System").tag("system")
32
+ Text("Dark").tag("dark")
33
+ Text("Light").tag("light")
34
+ }
35
+ .onChange(of: theme) { _, newValue in
36
+ applyTheme(newValue)
37
+ }
38
+
39
+ LabeledContent("Global Shortcut") {
40
+ Text(globalShortcut)
41
+ .foregroundStyle(.secondary)
42
+ }
43
+
44
+ Toggle("Launch at Login", isOn: $launchAtLogin)
45
+ .onChange(of: launchAtLogin) { _, enabled in
46
+ do {
47
+ if enabled {
48
+ try SMAppService.mainApp.register()
49
+ } else {
50
+ try SMAppService.mainApp.unregister()
51
+ }
52
+ } catch {
53
+ launchAtLogin = !enabled
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ private var notificationsTab: some View {
60
+ Form {
61
+ Toggle("Terminal Bell", isOn: $notifyBell)
62
+ Toggle("Run Complete", isOn: $notifyRunComplete)
63
+ Toggle("Approval Needed", isOn: $notifyApproval)
64
+ }
65
+ }
66
+
67
+ private func applyTheme(_ theme: String) {
68
+ switch theme {
69
+ case "dark":
70
+ NSApp.appearance = NSAppearance(named: .darkAqua)
71
+ case "light":
72
+ NSApp.appearance = NSAppearance(named: .aqua)
73
+ default:
74
+ NSApp.appearance = nil // system
75
+ }
76
+ }
77
+ }