@wangyaoshen/remux 0.3.8-dev.bab6c95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +138 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +456 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  21. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  22. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  23. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  24. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  25. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  26. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  27. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  28. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  29. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  30. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  31. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  32. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  33. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  34. package/apps/macos/Package.swift +37 -0
  35. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  36. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  37. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  38. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  39. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  40. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  41. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  42. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  43. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  44. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  45. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  46. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  47. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  48. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  49. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  50. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  51. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  52. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  53. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  54. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  55. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  56. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  57. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  58. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  59. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  60. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  61. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  62. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  63. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  64. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  65. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  66. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  67. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  68. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  69. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  70. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  71. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  72. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  73. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  74. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  75. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  76. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  77. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  78. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  79. package/build.mjs +33 -0
  80. package/native/android/DecodeGoldenPayloads.kt +487 -0
  81. package/native/android/ProtocolModels.kt +188 -0
  82. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  83. package/native/ios/ProtocolModels.swift +200 -0
  84. package/package.json +45 -0
  85. package/packages/RemuxKit/Package.swift +27 -0
  86. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  87. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  88. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  89. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  90. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  91. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  92. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  93. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  94. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  95. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  96. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  97. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  98. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  99. package/playwright.config.ts +17 -0
  100. package/pnpm-lock.yaml +1588 -0
  101. package/pty-daemon.js +303 -0
  102. package/release-please-config.json +14 -0
  103. package/scripts/auto-deploy.sh +46 -0
  104. package/scripts/build-dmg.sh +121 -0
  105. package/scripts/build-ghostty-kit.sh +43 -0
  106. package/scripts/check-active-terminology.mjs +132 -0
  107. package/scripts/sync-ghostty-web.sh +28 -0
  108. package/server.js +7074 -0
  109. package/src/adapters/agent-events.ts +246 -0
  110. package/src/adapters/claude-code.ts +158 -0
  111. package/src/adapters/codex.ts +210 -0
  112. package/src/adapters/generic-shell.ts +58 -0
  113. package/src/adapters/index.ts +15 -0
  114. package/src/adapters/registry.ts +99 -0
  115. package/src/adapters/types.ts +41 -0
  116. package/src/auth.ts +174 -0
  117. package/src/e2ee.ts +236 -0
  118. package/src/git-service.ts +168 -0
  119. package/src/message-buffer.ts +137 -0
  120. package/src/pty-daemon.ts +357 -0
  121. package/src/push.ts +127 -0
  122. package/src/renderers.ts +455 -0
  123. package/src/server.ts +2407 -0
  124. package/src/service.ts +226 -0
  125. package/src/session.ts +978 -0
  126. package/src/store.ts +1422 -0
  127. package/src/team.ts +123 -0
  128. package/src/tunnel.ts +126 -0
  129. package/src/types.d.ts +50 -0
  130. package/src/vt-tracker.ts +188 -0
  131. package/src/workspace-head.ts +144 -0
  132. package/src/workspace.ts +153 -0
  133. package/src/ws-handler.ts +1526 -0
  134. package/start.ps1 +83 -0
  135. package/tests/adapters.test.js +171 -0
  136. package/tests/auth.test.js +243 -0
  137. package/tests/codex-adapter.test.js +535 -0
  138. package/tests/durable-stream.test.js +153 -0
  139. package/tests/e2e/app.spec.js +530 -0
  140. package/tests/e2ee.test.js +325 -0
  141. package/tests/message-buffer.test.js +245 -0
  142. package/tests/message-routing.test.js +305 -0
  143. package/tests/pty-daemon.test.js +346 -0
  144. package/tests/push.test.js +281 -0
  145. package/tests/renderers.test.js +391 -0
  146. package/tests/search-shell.test.js +499 -0
  147. package/tests/server.test.js +882 -0
  148. package/tests/service.test.js +267 -0
  149. package/tests/store.test.js +369 -0
  150. package/tests/tunnel.test.js +67 -0
  151. package/tests/workspace-head.test.js +116 -0
  152. package/tests/workspace.test.js +417 -0
  153. package/tsconfig.backend.json +11 -0
  154. package/tsconfig.json +15 -0
  155. package/tui/client/client_test.go +125 -0
  156. package/tui/client/connection.go +342 -0
  157. package/tui/client/host_manager.go +141 -0
  158. package/tui/config/cache.go +81 -0
  159. package/tui/config/config.go +53 -0
  160. package/tui/config/config_test.go +89 -0
  161. package/tui/go.mod +32 -0
  162. package/tui/go.sum +50 -0
  163. package/tui/main.go +261 -0
  164. package/tui/tests/integration_test.go +283 -0
  165. package/tui/ui/model.go +310 -0
  166. package/vitest.config.js +10 -0
@@ -0,0 +1,66 @@
1
+ import Foundation
2
+ import RemuxKit
3
+
4
+ /// Offline Inspect cache using file-based storage.
5
+ /// Caches up to 50MB of recent Inspect snapshots, LRU eviction.
6
+ actor InspectCache {
7
+ static let shared = InspectCache()
8
+
9
+ private let cacheDir: URL
10
+ private let maxSize: Int = 50 * 1024 * 1024 // 50MB
11
+
12
+ private init() {
13
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
14
+ cacheDir = base.appendingPathComponent("remux-inspect-cache", isDirectory: true)
15
+ try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
16
+ }
17
+
18
+ func save(snapshot: InspectSnapshot, server: String, tabIndex: Int) {
19
+ let key = cacheKey(server: server, tabIndex: tabIndex)
20
+ let entry = CacheEntry(snapshot: snapshot, cachedAt: Date())
21
+ guard let data = try? JSONEncoder().encode(entry) else { return }
22
+ let file = cacheDir.appendingPathComponent(key)
23
+ try? data.write(to: file)
24
+ evictIfNeeded()
25
+ }
26
+
27
+ func load(server: String, tabIndex: Int) -> InspectSnapshot? {
28
+ let key = cacheKey(server: server, tabIndex: tabIndex)
29
+ let file = cacheDir.appendingPathComponent(key)
30
+ guard let data = try? Data(contentsOf: file),
31
+ let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else { return nil }
32
+ return entry.snapshot
33
+ }
34
+
35
+ private func cacheKey(server: String, tabIndex: Int) -> String {
36
+ let sanitized = server.replacingOccurrences(of: "/", with: "_")
37
+ .replacingOccurrences(of: ":", with: "_")
38
+ return "\(sanitized)_tab\(tabIndex).json"
39
+ }
40
+
41
+ private func evictIfNeeded() {
42
+ guard let files = try? FileManager.default.contentsOfDirectory(
43
+ at: cacheDir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]
44
+ ) else { return }
45
+
46
+ var totalSize = 0
47
+ var sorted = files.compactMap { url -> (URL, Int, Date)? in
48
+ guard let vals = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
49
+ let size = vals.fileSize,
50
+ let date = vals.contentModificationDate else { return nil }
51
+ totalSize += size
52
+ return (url, size, date)
53
+ }.sorted { $0.2 < $1.2 } // oldest first
54
+
55
+ while totalSize > maxSize, !sorted.isEmpty {
56
+ let oldest = sorted.removeFirst()
57
+ try? FileManager.default.removeItem(at: oldest.0)
58
+ totalSize -= oldest.1
59
+ }
60
+ }
61
+ }
62
+
63
+ private struct CacheEntry: Codable {
64
+ let snapshot: InspectSnapshot
65
+ let cachedAt: Date
66
+ }
@@ -0,0 +1,32 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Main 5-tab navigation: Now / Inspect / Live / Control / Me
5
+ struct MainTabView: View {
6
+ @Environment(RemuxState.self) private var state
7
+ @State private var selectedTab = 0
8
+
9
+ var body: some View {
10
+ TabView(selection: $selectedTab) {
11
+ NowView()
12
+ .tabItem { Label("Now", systemImage: "house.fill") }
13
+ .tag(0)
14
+
15
+ InspectView()
16
+ .tabItem { Label("Inspect", systemImage: "doc.text.magnifyingglass") }
17
+ .tag(1)
18
+
19
+ LiveTerminalView()
20
+ .tabItem { Label("Live", systemImage: "terminal.fill") }
21
+ .tag(2)
22
+
23
+ ControlView()
24
+ .tabItem { Label("Control", systemImage: "slider.horizontal.3") }
25
+ .tag(3)
26
+
27
+ MeView()
28
+ .tabItem { Label("Me", systemImage: "person.fill") }
29
+ .tag(4)
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>aps-environment</key>
6
+ <string>development</string>
7
+ </dict>
8
+ </plist>
@@ -0,0 +1,14 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ @main
5
+ struct RemuxiOSApp: App {
6
+ @State private var state = RemuxState()
7
+
8
+ var body: some Scene {
9
+ WindowGroup {
10
+ RootView()
11
+ .environment(state)
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,130 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Root view: onboarding → Face ID → main app.
5
+ /// iPad uses NavigationSplitView, iPhone uses TabView.
6
+ struct RootView: View {
7
+ @Environment(RemuxState.self) private var state
8
+ @Environment(\.horizontalSizeClass) private var sizeClass
9
+ @State private var isLocked = true
10
+ @State private var faceIdManager = FaceIDManager()
11
+ private let keychain = KeychainStore()
12
+
13
+ var body: some View {
14
+ Group {
15
+ if isLocked {
16
+ LockedView { unlock() }
17
+ } else if case .connected = state.connectionStatus {
18
+ adaptiveMainView
19
+ } else if case .reconnecting = state.connectionStatus {
20
+ adaptiveMainView
21
+ .overlay(ReconnectingBanner())
22
+ } else {
23
+ OnboardingView()
24
+ }
25
+ }
26
+ .onAppear {
27
+ Task { await checkAuth() }
28
+ }
29
+ .onChange(of: state.connectionStatus) { _, newStatus in
30
+ if case .disconnected = newStatus,
31
+ let snapshot = state.inspectSnapshot,
32
+ let server = state.serverURL?.absoluteString {
33
+ Task {
34
+ await InspectCache.shared.save(
35
+ snapshot: snapshot, server: server, tabIndex: state.activeTabIndex
36
+ )
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ @ViewBuilder
43
+ private var adaptiveMainView: some View {
44
+ if sizeClass == .regular {
45
+ iPadLayout
46
+ } else {
47
+ MainTabView()
48
+ }
49
+ }
50
+
51
+ /// iPad multi-column: sidebar (tabs) + terminal + inspect
52
+ private var iPadLayout: some View {
53
+ NavigationSplitView {
54
+ List {
55
+ Section("Session: \(state.currentSession)") {
56
+ ForEach(state.tabs, id: \.index) { tab in
57
+ Button {
58
+ if let pane = tab.panes.first { state.switchTab(id: pane.id) }
59
+ } label: {
60
+ HStack {
61
+ Image(systemName: tab.active ? "terminal.fill" : "terminal")
62
+ Text(tab.name)
63
+ Spacer()
64
+ if tab.hasBell { Circle().fill(.red).frame(width: 6, height: 6) }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ Section {
70
+ Button { state.createTab() } label: {
71
+ Label("New Tab", systemImage: "plus")
72
+ }
73
+ }
74
+ }
75
+ .navigationTitle("Remux")
76
+ } content: {
77
+ LiveTerminalView()
78
+ } detail: {
79
+ InspectView()
80
+ }
81
+ }
82
+
83
+ private func checkAuth() async {
84
+ let authed = await faceIdManager.authenticateIfNeeded()
85
+ isLocked = !authed
86
+ if authed { tryAutoConnect() }
87
+ }
88
+
89
+ private func unlock() {
90
+ Task {
91
+ let authed = await faceIdManager.authenticateIfNeeded()
92
+ if authed { isLocked = false; tryAutoConnect() }
93
+ }
94
+ }
95
+
96
+ private func tryAutoConnect() {
97
+ let servers = keychain.savedServers()
98
+ if let server = servers.first,
99
+ let token = keychain.loadResumeToken(forServer: server) ?? keychain.loadServerToken(forServer: server),
100
+ let url = URL(string: server) {
101
+ state.connect(url: url, credential: .token(token))
102
+ }
103
+ }
104
+ }
105
+
106
+ struct LockedView: View {
107
+ let onUnlock: () -> Void
108
+ var body: some View {
109
+ VStack(spacing: 20) {
110
+ Image(systemName: "lock.fill").font(.system(size: 48)).foregroundStyle(.secondary)
111
+ Text("Remux is Locked").font(.title2)
112
+ Button("Unlock with Face ID") { onUnlock() }
113
+ .buttonStyle(.borderedProminent)
114
+ }
115
+ }
116
+ }
117
+
118
+ struct ReconnectingBanner: View {
119
+ var body: some View {
120
+ VStack {
121
+ HStack {
122
+ ProgressView().scaleEffect(0.8)
123
+ Text("Reconnecting...").font(.caption).foregroundStyle(.secondary)
124
+ }
125
+ .padding(8).background(.ultraThinMaterial).cornerRadius(8)
126
+ Spacer()
127
+ }
128
+ .padding(.top, 8)
129
+ }
130
+ }
@@ -0,0 +1,102 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Control tab: session/tab management with swipe actions.
5
+ struct ControlView: View {
6
+ @Environment(RemuxState.self) private var state
7
+ @State private var showNewSession = false
8
+
9
+ var body: some View {
10
+ NavigationStack {
11
+ List {
12
+ sessionSection
13
+ tabsSection
14
+ actionsSection
15
+ }
16
+ .navigationTitle("Control")
17
+ .alert("New Session", isPresented: $showNewSession) {
18
+ Button("Create") {
19
+ state.createSession(name: "session-\(Int.random(in: 1000...9999))")
20
+ }
21
+ Button("Cancel", role: .cancel) {}
22
+ }
23
+ }
24
+ }
25
+
26
+ private var sessionSection: some View {
27
+ Section("Session") {
28
+ HStack {
29
+ Image(systemName: "server.rack")
30
+ Text(state.currentSession)
31
+ .font(.headline)
32
+ Spacer()
33
+ }
34
+ }
35
+ }
36
+
37
+ private var tabsSection: some View {
38
+ Section("Tabs (\(state.tabs.count))") {
39
+ ForEach(state.tabs, id: \.index) { tab in
40
+ TabRow(tab: tab, isActive: tab.index == state.activeTabIndex)
41
+ .contentShape(Rectangle())
42
+ .onTapGesture {
43
+ if let pane = tab.panes.first {
44
+ state.switchTab(id: pane.id)
45
+ }
46
+ }
47
+ .swipeActions(edge: .trailing) {
48
+ Button(role: .destructive) {
49
+ if let pane = tab.panes.first {
50
+ state.closeTab(id: pane.id)
51
+ }
52
+ } label: {
53
+ Label("Close", systemImage: "xmark")
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ private var actionsSection: some View {
61
+ Section {
62
+ Button(action: { state.createTab() }) {
63
+ Label("New Tab", systemImage: "plus")
64
+ }
65
+ Button(action: { showNewSession = true }) {
66
+ Label("New Session", systemImage: "plus.rectangle.on.folder")
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ struct TabRow: View {
73
+ let tab: WorkspaceTab
74
+ let isActive: Bool
75
+
76
+ var body: some View {
77
+ HStack {
78
+ Image(systemName: isActive ? "terminal.fill" : "terminal")
79
+ .foregroundStyle(isActive ? Color.accentColor : Color.secondary)
80
+ VStack(alignment: .leading) {
81
+ Text(tab.name)
82
+ if let pane = tab.panes.first {
83
+ Text("\(pane.cols)×\(pane.rows)")
84
+ .font(.caption)
85
+ .foregroundStyle(.tertiary)
86
+ }
87
+ }
88
+ Spacer()
89
+ if isActive {
90
+ Text("Active")
91
+ .font(.caption2)
92
+ .padding(.horizontal, 6)
93
+ .padding(.vertical, 2)
94
+ .background(.tint.opacity(0.15))
95
+ .cornerRadius(4)
96
+ }
97
+ if tab.hasBell {
98
+ Circle().fill(.red).frame(width: 6, height: 6)
99
+ }
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,98 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Inspect tab: readable terminal content with badges, search, pagination.
5
+ struct InspectView: View {
6
+ @Environment(RemuxState.self) private var state
7
+ @State private var searchQuery = ""
8
+
9
+ var body: some View {
10
+ NavigationStack {
11
+ VStack(spacing: 0) {
12
+ // Tab selector
13
+ ScrollView(.horizontal, showsIndicators: false) {
14
+ HStack(spacing: 8) {
15
+ ForEach(state.tabs, id: \.index) { tab in
16
+ Button {
17
+ state.requestInspect(tabIndex: tab.index)
18
+ } label: {
19
+ Text(tab.name)
20
+ .font(.caption)
21
+ .padding(.horizontal, 12)
22
+ .padding(.vertical, 6)
23
+ .background(tab.index == state.activeTabIndex ? Color.accentColor : Color.secondary.opacity(0.15))
24
+ .foregroundStyle(tab.index == state.activeTabIndex ? .white : .primary)
25
+ .cornerRadius(16)
26
+ }
27
+ .buttonStyle(.plain)
28
+ }
29
+ }
30
+ .padding(.horizontal)
31
+ .padding(.vertical, 8)
32
+ }
33
+
34
+ // Descriptor badges
35
+ if let snapshot = state.inspectSnapshot {
36
+ HStack(spacing: 6) {
37
+ Badge(text: snapshot.descriptor.source, color: .blue)
38
+ Badge(text: snapshot.descriptor.precision, color: snapshot.descriptor.precision == "precise" ? .green : .yellow)
39
+ Badge(text: snapshot.descriptor.staleness, color: snapshot.descriptor.staleness == "fresh" ? .green : .orange)
40
+ Spacer()
41
+ Text(snapshot.descriptor.capturedAt.prefix(19).replacingOccurrences(of: "T", with: " "))
42
+ .font(.caption2)
43
+ .foregroundStyle(.tertiary)
44
+ }
45
+ .padding(.horizontal)
46
+ .padding(.bottom, 4)
47
+ }
48
+
49
+ Divider()
50
+
51
+ // Content
52
+ if let snapshot = state.inspectSnapshot {
53
+ List {
54
+ ForEach(Array(snapshot.items.enumerated()), id: \.offset) { _, item in
55
+ Text(item.content)
56
+ .font(.system(.caption, design: .monospaced))
57
+ .foregroundStyle(item.type == "output" ? .primary : .secondary)
58
+ .listRowInsets(EdgeInsets(top: 1, leading: 12, bottom: 1, trailing: 12))
59
+ }
60
+ }
61
+ .listStyle(.plain)
62
+ } else {
63
+ ContentUnavailableView(
64
+ "No Inspect Data",
65
+ systemImage: "doc.text",
66
+ description: Text("Pull to refresh or switch tabs above.")
67
+ )
68
+ }
69
+ }
70
+ .navigationTitle("Inspect")
71
+ .searchable(text: $searchQuery, prompt: "Search terminal content")
72
+ .onSubmit(of: .search) {
73
+ state.requestInspect(tabIndex: state.activeTabIndex, query: searchQuery.isEmpty ? nil : searchQuery)
74
+ }
75
+ .refreshable {
76
+ state.requestInspect(tabIndex: state.activeTabIndex, query: searchQuery.isEmpty ? nil : searchQuery)
77
+ }
78
+ .onAppear {
79
+ state.requestInspect(tabIndex: state.activeTabIndex)
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ struct Badge: View {
86
+ let text: String
87
+ let color: Color
88
+
89
+ var body: some View {
90
+ Text(text)
91
+ .font(.system(size: 10))
92
+ .padding(.horizontal, 6)
93
+ .padding(.vertical, 2)
94
+ .background(color.opacity(0.15))
95
+ .foregroundStyle(color)
96
+ .cornerRadius(4)
97
+ }
98
+ }
@@ -0,0 +1,132 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Live terminal tab: full-screen ghostty-web terminal via WKWebView.
5
+ struct LiveTerminalView: View {
6
+ @Environment(RemuxState.self) private var state
7
+ @State private var bridge: GhosttyBridge?
8
+ @State private var showKeyboardAccessory = true
9
+
10
+ var body: some View {
11
+ ZStack {
12
+ // Terminal
13
+ if let bridge {
14
+ GhosttyTerminalView(bridge: bridge)
15
+ .ignoresSafeArea(.keyboard)
16
+ .onReceive(NotificationCenter.default.publisher(for: .remuxTerminalData)) { notification in
17
+ if let data = notification.userInfo?["data"] as? Data {
18
+ bridge.writeToTerminal(data: data)
19
+ }
20
+ }
21
+ }
22
+
23
+ // Connection status overlay
24
+ if case .disconnected = state.connectionStatus {
25
+ Color.black.opacity(0.7)
26
+ .ignoresSafeArea()
27
+ VStack {
28
+ Image(systemName: "wifi.slash")
29
+ .font(.largeTitle)
30
+ .foregroundStyle(.white)
31
+ Text("Disconnected")
32
+ .foregroundStyle(.white)
33
+ }
34
+ }
35
+ }
36
+ .safeAreaInset(edge: .bottom) {
37
+ if showKeyboardAccessory {
38
+ TerminalKeyboardAccessory(bridge: bridge)
39
+ }
40
+ }
41
+ .onAppear { setupBridge() }
42
+ .navigationBarHidden(true)
43
+ }
44
+
45
+ private func setupBridge() {
46
+ let b = GhosttyBridge()
47
+ b.onInput = { input in
48
+ if let data = input.data(using: .utf8) {
49
+ state.sendTerminalData(data)
50
+ }
51
+ }
52
+ b.onResize = { cols, rows in
53
+ state.sendJSON(["type": "resize", "cols": cols, "rows": rows])
54
+ }
55
+ b.onReady = {
56
+ // Terminal initialized — attach to current tab
57
+ }
58
+ b.onBell = {
59
+ UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
60
+ }
61
+ bridge = b
62
+ }
63
+ }
64
+
65
+ /// Keyboard accessory bar with Esc, Tab, Ctrl, arrows.
66
+ /// Design ref: Termius / Blink terminal keyboard accessory.
67
+ struct TerminalKeyboardAccessory: View {
68
+ let bridge: GhosttyBridge?
69
+ @State private var ctrlActive = false
70
+
71
+ var body: some View {
72
+ ScrollView(.horizontal, showsIndicators: false) {
73
+ HStack(spacing: 8) {
74
+ AccessoryButton("Esc") { send("\u{1b}") }
75
+ AccessoryButton("Tab") { send("\t") }
76
+
77
+ AccessoryButton(ctrlActive ? "Ctrl ●" : "Ctrl") {
78
+ ctrlActive.toggle()
79
+ }
80
+
81
+ Divider().frame(height: 20)
82
+
83
+ AccessoryButton("↑") { send("\u{1b}[A") }
84
+ AccessoryButton("↓") { send("\u{1b}[B") }
85
+ AccessoryButton("←") { send("\u{1b}[D") }
86
+ AccessoryButton("→") { send("\u{1b}[C") }
87
+
88
+ Divider().frame(height: 20)
89
+
90
+ AccessoryButton("Ctrl+C") { send("\u{03}") }
91
+ AccessoryButton("Ctrl+D") { send("\u{04}") }
92
+ AccessoryButton("Ctrl+Z") { send("\u{1a}") }
93
+ }
94
+ .padding(.horizontal, 8)
95
+ .padding(.vertical, 4)
96
+ }
97
+ .background(.bar)
98
+ }
99
+
100
+ private func send(_ text: String) {
101
+ if ctrlActive, text.count == 1, let ascii = text.first?.asciiValue {
102
+ // Ctrl+key: send control character (ascii - 64)
103
+ let ctrl = Character(UnicodeScalar(ascii &- 64))
104
+ bridge?.writeString(String(ctrl))
105
+ ctrlActive = false
106
+ } else {
107
+ bridge?.writeString(text)
108
+ }
109
+ }
110
+ }
111
+
112
+ struct AccessoryButton: View {
113
+ let title: String
114
+ let action: () -> Void
115
+
116
+ init(_ title: String, action: @escaping () -> Void) {
117
+ self.title = title
118
+ self.action = action
119
+ }
120
+
121
+ var body: some View {
122
+ Button(action: action) {
123
+ Text(title)
124
+ .font(.system(size: 13, weight: .medium, design: .monospaced))
125
+ .padding(.horizontal, 10)
126
+ .padding(.vertical, 6)
127
+ .background(.quaternary)
128
+ .cornerRadius(6)
129
+ }
130
+ .buttonStyle(.plain)
131
+ }
132
+ }