@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,149 @@
1
+ import AppKit
2
+
3
+ /// Persisted representation of a keyboard shortcut.
4
+ /// Adapted from ghostty-org/ghostty KeyEquivalent binding storage.
5
+ struct StoredShortcut: Codable, Equatable, Hashable, Sendable {
6
+ var key: String // e.g. "d", "left", "\r"
7
+ var command: Bool
8
+ var shift: Bool
9
+ var option: Bool
10
+ var control: Bool
11
+
12
+ init(
13
+ key: String,
14
+ command: Bool = false,
15
+ shift: Bool = false,
16
+ option: Bool = false,
17
+ control: Bool = false
18
+ ) {
19
+ self.key = key
20
+ self.command = command
21
+ self.shift = shift
22
+ self.option = option
23
+ self.control = control
24
+ }
25
+
26
+ /// Human-readable display string like "⌘D" or "⌃⌘F".
27
+ var displayString: String {
28
+ var parts: [String] = []
29
+ if control { parts.append("⌃") }
30
+ if option { parts.append("⌥") }
31
+ if shift { parts.append("⇧") }
32
+ if command { parts.append("⌘") }
33
+ parts.append(keyDisplayName)
34
+ return parts.joined()
35
+ }
36
+
37
+ /// Display name for the key character.
38
+ private var keyDisplayName: String {
39
+ switch key.lowercased() {
40
+ case "left": return "←"
41
+ case "right": return "→"
42
+ case "up": return "↑"
43
+ case "down": return "↓"
44
+ case "\r", "return", "enter": return "↩"
45
+ case "\t", "tab": return "⇥"
46
+ case " ", "space": return "Space"
47
+ case "\u{1b}", "escape": return "⎋"
48
+ case "\u{7f}", "delete": return "⌫"
49
+ case "[": return "["
50
+ case "]": return "]"
51
+ case "{": return "{"
52
+ case "}": return "}"
53
+ default:
54
+ return key.uppercased()
55
+ }
56
+ }
57
+
58
+ // MARK: - NSEvent conversion
59
+
60
+ /// Create a StoredShortcut from an NSEvent (for recording shortcuts).
61
+ @MainActor
62
+ static func from(event: NSEvent) -> StoredShortcut? {
63
+ guard event.type == .keyDown else { return nil }
64
+
65
+ let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
66
+
67
+ // Require at least one modifier key (prevent bare letters from being shortcuts)
68
+ let hasModifier = mods.contains(.command) || mods.contains(.control) ||
69
+ mods.contains(.option)
70
+ guard hasModifier else { return nil }
71
+
72
+ let keyStr: String
73
+ switch Int(event.keyCode) {
74
+ case 123: keyStr = "left"
75
+ case 124: keyStr = "right"
76
+ case 125: keyStr = "down"
77
+ case 126: keyStr = "up"
78
+ case 36: keyStr = "return"
79
+ case 48: keyStr = "tab"
80
+ case 53: keyStr = "escape"
81
+ case 51: keyStr = "delete"
82
+ case 49: keyStr = "space"
83
+ default:
84
+ if let chars = event.charactersIgnoringModifiers, !chars.isEmpty {
85
+ keyStr = chars.lowercased()
86
+ } else {
87
+ return nil
88
+ }
89
+ }
90
+
91
+ return StoredShortcut(
92
+ key: keyStr,
93
+ command: mods.contains(.command),
94
+ shift: mods.contains(.shift),
95
+ option: mods.contains(.option),
96
+ control: mods.contains(.control)
97
+ )
98
+ }
99
+
100
+ /// Check if an NSEvent matches this shortcut.
101
+ @MainActor
102
+ func matches(event: NSEvent) -> Bool {
103
+ guard let recorded = StoredShortcut.from(event: event) else { return false }
104
+ return self == recorded
105
+ }
106
+
107
+ // MARK: - Persistence via UserDefaults
108
+
109
+ private static let defaultsKeyPrefix = "shortcut_"
110
+
111
+ /// Get the current shortcut for an action (user-customized or default).
112
+ static func shortcut(for action: ShortcutAction) -> StoredShortcut {
113
+ let key = defaultsKeyPrefix + action.rawValue
114
+ if let data = UserDefaults.standard.data(forKey: key),
115
+ let stored = try? JSONDecoder().decode(StoredShortcut.self, from: data) {
116
+ return stored
117
+ }
118
+ return action.defaultShortcut
119
+ }
120
+
121
+ /// Save a custom shortcut for an action.
122
+ static func setShortcut(_ shortcut: StoredShortcut, for action: ShortcutAction) {
123
+ let key = defaultsKeyPrefix + action.rawValue
124
+ if let data = try? JSONEncoder().encode(shortcut) {
125
+ UserDefaults.standard.set(data, forKey: key)
126
+ }
127
+ }
128
+
129
+ /// Reset a shortcut to its default.
130
+ static func resetShortcut(for action: ShortcutAction) {
131
+ let key = defaultsKeyPrefix + action.rawValue
132
+ UserDefaults.standard.removeObject(forKey: key)
133
+ }
134
+
135
+ /// Reset all shortcuts to defaults.
136
+ static func resetAll() {
137
+ for action in ShortcutAction.allCases {
138
+ resetShortcut(for: action)
139
+ }
140
+ }
141
+
142
+ /// Detect conflicts: returns any other actions that share the same shortcut.
143
+ static func conflicts(for action: ShortcutAction) -> [ShortcutAction] {
144
+ let current = shortcut(for: action)
145
+ return ShortcutAction.allCases.filter { other in
146
+ other != action && shortcut(for: other) == current
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,308 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Main content view: sidebar + tab bar + split terminal/inspect area.
5
+ /// Includes command palette overlay and copy mode support.
6
+ struct MainContentView: View {
7
+ @Environment(RemuxState.self) private var state
8
+ @State private var showInspect = false
9
+
10
+ // Split pane state
11
+ @State private var splitRoot: SplitNode = .leaf(SplitNode.LeafData(tabIndex: 0))
12
+ @State private var focusedLeafID: UUID?
13
+
14
+ // Command palette
15
+ @State private var showCommandPalette = false
16
+
17
+ // Copy mode
18
+ @State private var showCopyMode = false
19
+
20
+ // Port scanner
21
+ @State private var portScanner = PortScanner()
22
+
23
+ // SSH detector
24
+ @State private var sshDetector = SSHDetector()
25
+
26
+ var body: some View {
27
+ ZStack {
28
+ NavigationSplitView {
29
+ SidebarView(portScanner: portScanner)
30
+ } detail: {
31
+ if case .connected = state.connectionStatus {
32
+ VStack(spacing: 0) {
33
+ TabBarView()
34
+
35
+ ZStack {
36
+ if showInspect {
37
+ HSplitView {
38
+ splitContent
39
+ .frame(minWidth: 300)
40
+ InspectView()
41
+ .frame(minWidth: 250, idealWidth: 350)
42
+ }
43
+ } else {
44
+ splitContent
45
+ }
46
+
47
+ // Copy mode overlay
48
+ CopyModeOverlay(
49
+ isActive: $showCopyMode,
50
+ onRequestContent: {
51
+ // Return terminal lines from inspect snapshot
52
+ if let snapshot = state.inspectSnapshot {
53
+ return snapshot.items.map { $0.content }
54
+ }
55
+ return []
56
+ },
57
+ onCopy: { _ in
58
+ // Text already copied to clipboard in the overlay
59
+ }
60
+ )
61
+ }
62
+ }
63
+ .toolbar {
64
+ ToolbarItemGroup(placement: .automatic) {
65
+ // SSH upload button
66
+ if sshDetector.hasActiveConnection {
67
+ Button(action: { handleSSHUpload() }) {
68
+ Image(systemName: "square.and.arrow.up")
69
+ }
70
+ .help("Upload File via SCP")
71
+ }
72
+
73
+ Button(action: { showInspect.toggle() }) {
74
+ Image(systemName: showInspect ? "doc.text.fill" : "doc.text")
75
+ }
76
+ .help("Toggle Inspect (\u{2318}I)")
77
+ .keyboardShortcut("i", modifiers: .command)
78
+ }
79
+ }
80
+ } else {
81
+ ConnectionView()
82
+ }
83
+ }
84
+ .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
85
+
86
+ // Command palette overlay
87
+ CommandPalette(
88
+ isPresented: $showCommandPalette,
89
+ commands: buildCommandList()
90
+ )
91
+ }
92
+ .onAppear {
93
+ // Set initial focused leaf
94
+ if focusedLeafID == nil {
95
+ focusedLeafID = splitRoot.allLeaves.first?.id
96
+ }
97
+ }
98
+ }
99
+
100
+ @ViewBuilder
101
+ private var splitContent: some View {
102
+ SplitView(
103
+ node: splitRoot,
104
+ focusedLeafID: $focusedLeafID,
105
+ onSplit: { leafID, orientation in
106
+ splitPane(leafID: leafID, orientation: orientation)
107
+ },
108
+ onClose: { leafID in
109
+ closePane(leafID: leafID)
110
+ },
111
+ onRatioChange: { branchID, ratio in
112
+ splitRoot = splitRoot.updateRatio(branchID: branchID, ratio: ratio)
113
+ }
114
+ )
115
+ }
116
+
117
+ // MARK: - Split operations
118
+
119
+ /// Split the given (or focused) pane in the specified direction.
120
+ func splitPane(leafID: UUID? = nil, orientation: SplitNode.Orientation, panelType: PanelType = .terminal) {
121
+ let targetID = leafID ?? focusedLeafID ?? splitRoot.allLeaves.first?.id
122
+ guard let targetID else { return }
123
+
124
+ let newTabIndex = state.activeTabIndex
125
+ splitRoot = splitRoot.split(
126
+ leafID: targetID,
127
+ orientation: orientation,
128
+ newTabIndex: newTabIndex,
129
+ panelType: panelType
130
+ )
131
+
132
+ // Focus the new pane
133
+ if let newLeaves = splitRoot.allLeaves.last {
134
+ focusedLeafID = newLeaves.id
135
+ }
136
+ }
137
+
138
+ /// Close a specific pane.
139
+ func closePane(leafID: UUID? = nil) {
140
+ let targetID = leafID ?? focusedLeafID ?? splitRoot.allLeaves.last?.id
141
+ guard let targetID else { return }
142
+
143
+ // Don't close the last pane
144
+ guard splitRoot.allLeaves.count > 1 else { return }
145
+
146
+ // Move focus before removing
147
+ if focusedLeafID == targetID {
148
+ focusedLeafID = splitRoot.previousLeaf(before: targetID)?.id
149
+ ?? splitRoot.allLeaves.first?.id
150
+ }
151
+
152
+ if let newRoot = splitRoot.removeLeaf(id: targetID) {
153
+ splitRoot = newRoot
154
+ }
155
+ }
156
+
157
+ /// Focus the next pane.
158
+ func focusNextPane() {
159
+ guard let current = focusedLeafID,
160
+ let next = splitRoot.nextLeaf(after: current) else { return }
161
+ focusedLeafID = next.id
162
+ }
163
+
164
+ /// Focus the previous pane.
165
+ func focusPreviousPane() {
166
+ guard let current = focusedLeafID,
167
+ let prev = splitRoot.previousLeaf(before: current) else { return }
168
+ focusedLeafID = prev.id
169
+ }
170
+
171
+ /// Add a browser pane to the split tree.
172
+ func addBrowserPane() {
173
+ splitPane(orientation: .horizontal, panelType: .browser)
174
+ }
175
+
176
+ /// Add a markdown pane to the split tree.
177
+ func addMarkdownPane() {
178
+ splitPane(orientation: .horizontal, panelType: .markdown)
179
+ }
180
+
181
+ /// Toggle command palette visibility.
182
+ func toggleCommandPalette() {
183
+ showCommandPalette.toggle()
184
+ }
185
+
186
+ /// Toggle copy mode.
187
+ func toggleCopyMode() {
188
+ // Request inspect content first so copy mode has data
189
+ if !showCopyMode {
190
+ state.requestInspect(tabIndex: state.activeTabIndex)
191
+ }
192
+ showCopyMode.toggle()
193
+ }
194
+
195
+ /// Get the current split layout as a snapshot for persistence.
196
+ var splitLayoutSnapshot: SplitNodeSnapshot {
197
+ splitRoot.toSnapshot()
198
+ }
199
+
200
+ /// Restore split layout from a snapshot.
201
+ mutating func restoreSplitLayout(_ snapshot: SplitNodeSnapshot) {
202
+ splitRoot = SplitNode.fromSnapshot(snapshot)
203
+ focusedLeafID = splitRoot.allLeaves.first?.id
204
+ }
205
+
206
+ // MARK: - Command palette commands
207
+
208
+ private func buildCommandList() -> [PaletteCommand] {
209
+ var commands: [PaletteCommand] = []
210
+
211
+ // Commands from ShortcutAction
212
+ for action in ShortcutAction.allCases {
213
+ let shortcut = StoredShortcut.shortcut(for: action)
214
+ commands.append(PaletteCommand(
215
+ id: action.rawValue,
216
+ name: action.displayName,
217
+ shortcut: shortcut.displayString,
218
+ category: action.category,
219
+ action: { [self] in
220
+ executeShortcutAction(action)
221
+ }
222
+ ))
223
+ }
224
+
225
+ // Additional commands
226
+ commands.append(PaletteCommand(
227
+ id: "newBrowserPane",
228
+ name: "New Browser Pane",
229
+ shortcut: "\u{2318}\u{21E7}B",
230
+ category: "Panels",
231
+ action: { [self] in addBrowserPane() }
232
+ ))
233
+
234
+ commands.append(PaletteCommand(
235
+ id: "newMarkdownPane",
236
+ name: "New Markdown Pane",
237
+ shortcut: "\u{2318}\u{21E7}M",
238
+ category: "Panels",
239
+ action: { [self] in addMarkdownPane() }
240
+ ))
241
+
242
+ commands.append(PaletteCommand(
243
+ id: "copyMode",
244
+ name: "Copy Mode",
245
+ shortcut: "\u{2318}\u{21E7}C",
246
+ category: "Terminal",
247
+ action: { [self] in toggleCopyMode() }
248
+ ))
249
+
250
+ commands.append(PaletteCommand(
251
+ id: "commandPalette",
252
+ name: "Command Palette",
253
+ shortcut: "\u{2318}\u{21E7}P",
254
+ category: "Window",
255
+ action: { /* Already open */ }
256
+ ))
257
+
258
+ return commands
259
+ }
260
+
261
+ private func executeShortcutAction(_ action: ShortcutAction) {
262
+ switch action {
263
+ case .find: break // Handled by terminal view
264
+ case .clearTerminal: break
265
+ case .newTab: state.createTab()
266
+ case .closeTab:
267
+ if let tab = state.tabs.first(where: { $0.active }),
268
+ let pane = tab.panes.first {
269
+ state.closeTab(id: pane.id)
270
+ }
271
+ case .nextTab:
272
+ let tabs = state.tabs
273
+ let idx = state.activeTabIndex
274
+ if let nextTab = tabs.first(where: { $0.index > idx }) ?? tabs.first,
275
+ let pane = nextTab.panes.first {
276
+ state.switchTab(id: pane.id)
277
+ }
278
+ case .prevTab:
279
+ let tabs = state.tabs
280
+ let idx = state.activeTabIndex
281
+ if let prevTab = tabs.last(where: { $0.index < idx }) ?? tabs.last,
282
+ let pane = prevTab.panes.first {
283
+ state.switchTab(id: pane.id)
284
+ }
285
+ case .splitRight: splitPane(orientation: .horizontal)
286
+ case .splitDown: splitPane(orientation: .vertical)
287
+ case .closePane: closePane()
288
+ case .focusNextPane: focusNextPane()
289
+ case .focusPrevPane: focusPreviousPane()
290
+ case .toggleSidebar: break // Handled by NSSplitViewController
291
+ case .toggleInspect: showInspect.toggle()
292
+ case .toggleFullscreen:
293
+ NSApp.keyWindow?.toggleFullScreen(nil)
294
+ case .focusLeft, .focusRight, .focusUp, .focusDown:
295
+ break // Directional focus — would need spatial awareness
296
+ }
297
+ }
298
+
299
+ // MARK: - SSH upload
300
+
301
+ private func handleSSHUpload() {
302
+ guard let url = sshDetector.pickFileForUpload(),
303
+ let cmd = sshDetector.buildUploadCommand(localPath: url.path) else {
304
+ return
305
+ }
306
+ state.sendTerminalInput(cmd + "\n")
307
+ }
308
+ }