@wangyaoshen/remux 0.3.8-dev.29e114b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,559 @@
1
+ import AppKit
2
+ import GhosttyKit
3
+ import UniformTypeIdentifiers
4
+
5
+ /// NSView subclass wrapping libghostty's native Metal terminal renderer.
6
+ /// Adapted for ghostty v1.3.1 API: uses `command` field to spawn a relay
7
+ /// process (`nc -U <socket>`) that bridges Unix socket <-> PTY stdio.
8
+ ///
9
+ /// Data flow:
10
+ /// Remote PTY -> WebSocket -> TerminalRelay.writeToTerminal() -> socket -> nc stdout -> ghostty renders
11
+ /// User types -> ghostty -> nc stdin -> socket -> TerminalRelay.onDataFromClient -> WebSocket -> remote PTY
12
+ ///
13
+ /// Supports NSTextInputClient for CJK IME and NSDraggingDestination for file drops.
14
+ ///
15
+ /// Architecture ref: Calyx, Kytos (libghostty-based terminal apps)
16
+ /// IME ref: ghostty-org/ghostty macOS TerminalView NSTextInputClient
17
+ @MainActor
18
+ final class GhosttyNativeView: NSView, @preconcurrency NSTextInputClient {
19
+
20
+ // MARK: - Callbacks
21
+
22
+ var onResize: ((Int, Int) -> Void)?
23
+ var onBell: (() -> Void)?
24
+ var onTitle: ((String) -> Void)?
25
+
26
+ // Search callbacks
27
+ var onSearchStart: ((String?) -> Void)?
28
+ var onSearchEnd: (() -> Void)?
29
+ var onSearchTotal: ((Int) -> Void)?
30
+ var onSearchSelected: ((Int) -> Void)?
31
+
32
+ // MARK: - Ghostty state (nonisolated for deinit cleanup)
33
+
34
+ nonisolated(unsafe) private var ghosttyApp: ghostty_app_t?
35
+ nonisolated(unsafe) private var surface: ghostty_surface_t?
36
+
37
+ /// The relay command spawned by ghostty as its "shell" process.
38
+ private(set) var relayCommand: String?
39
+
40
+ // MARK: - IME composing state
41
+
42
+ /// Whether the input method is actively composing (marked text present).
43
+ private var isComposing: Bool = false
44
+
45
+ /// The current marked text from the input method.
46
+ private var imeMarkedText: NSMutableAttributedString = NSMutableAttributedString()
47
+
48
+ /// The selected range within the marked text.
49
+ private var imeSelectedRange: NSRange = NSRange(location: NSNotFound, length: 0)
50
+
51
+ // MARK: - File drop state
52
+
53
+ /// Whether a drag is currently hovering over the view.
54
+ private var isDragHighlighted: Bool = false {
55
+ didSet {
56
+ layer?.borderWidth = isDragHighlighted ? 2 : 0
57
+ layer?.borderColor = isDragHighlighted
58
+ ? NSColor.controlAccentColor.cgColor
59
+ : nil
60
+ }
61
+ }
62
+
63
+ // MARK: - Init
64
+
65
+ /// Create the view with a relay socket path. Ghostty will spawn `nc -U <socketPath>`.
66
+ init(frame frameRect: NSRect, socketPath: String) {
67
+ self.relayCommand = "nc -U \(socketPath)"
68
+ super.init(frame: frameRect)
69
+ setupView()
70
+ initGhostty()
71
+ }
72
+
73
+ override init(frame frameRect: NSRect) {
74
+ super.init(frame: frameRect)
75
+ setupView()
76
+ }
77
+
78
+ required init?(coder: NSCoder) {
79
+ fatalError("init(coder:) not supported")
80
+ }
81
+
82
+ deinit {
83
+ if let surface { ghostty_surface_free(surface) }
84
+ if let ghosttyApp { ghostty_app_free(ghosttyApp) }
85
+ }
86
+
87
+ private func setupView() {
88
+ wantsLayer = true
89
+ layer?.isOpaque = true
90
+ layer?.backgroundColor = NSColor.black.cgColor
91
+
92
+ // Register for file drag-and-drop
93
+ registerForDraggedTypes([.fileURL])
94
+ }
95
+
96
+ // MARK: - Ghostty initialization
97
+
98
+ private func initGhostty() {
99
+ ghostty_init(0, nil)
100
+
101
+ let config = ghostty_config_new()!
102
+ ghostty_config_load_default_files(config)
103
+ ghostty_config_finalize(config)
104
+
105
+ // Runtime callbacks
106
+ var rtConfig = ghostty_runtime_config_s()
107
+ rtConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
108
+ rtConfig.supports_selection_clipboard = true
109
+
110
+ rtConfig.wakeup_cb = { ud in
111
+ guard let ud else { return }
112
+ let view = Unmanaged<GhosttyNativeView>.fromOpaque(ud).takeUnretainedValue()
113
+ DispatchQueue.main.async {
114
+ view.needsDisplay = true
115
+ }
116
+ }
117
+
118
+ rtConfig.action_cb = { app, target, action in
119
+ guard let app else { return false }
120
+ guard let ud = ghostty_app_userdata(app) else { return false }
121
+ let view = Unmanaged<GhosttyNativeView>.fromOpaque(ud).takeUnretainedValue()
122
+ return GhosttyNativeView.handleAction(view: view, target: target, action: action)
123
+ }
124
+
125
+ rtConfig.read_clipboard_cb = { ud, clipboard, state in
126
+ guard let ud else { return false }
127
+ let view = Unmanaged<GhosttyNativeView>.fromOpaque(ud).takeUnretainedValue()
128
+ guard let surface = view.surface else { return false }
129
+ let pb = NSPasteboard.general
130
+ if let str = pb.string(forType: .string) {
131
+ str.withCString { ptr in
132
+ ghostty_surface_complete_clipboard_request(surface, ptr, state, true)
133
+ }
134
+ return true
135
+ }
136
+ return false
137
+ }
138
+
139
+ rtConfig.confirm_read_clipboard_cb = { ud, content, state, req in
140
+ // Auto-confirm clipboard reads for simplicity
141
+ guard let ud else { return }
142
+ let view = Unmanaged<GhosttyNativeView>.fromOpaque(ud).takeUnretainedValue()
143
+ guard let surface = view.surface, let content else { return }
144
+ ghostty_surface_complete_clipboard_request(surface, content, state, true)
145
+ }
146
+
147
+ rtConfig.write_clipboard_cb = { _, clipboard, content, count, _ in
148
+ guard let content, count > 0, let data = content.pointee.data else { return }
149
+ let str = String(cString: data)
150
+ let pb: NSPasteboard
151
+ if clipboard == GHOSTTY_CLIPBOARD_SELECTION {
152
+ // Selection clipboard: use a named pasteboard
153
+ pb = NSPasteboard(name: .init("org.remux.selection"))
154
+ } else {
155
+ pb = NSPasteboard.general
156
+ }
157
+ pb.clearContents()
158
+ pb.setString(str, forType: .string)
159
+ }
160
+
161
+ rtConfig.close_surface_cb = { _, _ in }
162
+
163
+ ghosttyApp = ghostty_app_new(&rtConfig, config)
164
+ ghostty_config_free(config)
165
+ guard let ghosttyApp else { return }
166
+
167
+ // Surface config — relay command as the "shell"
168
+ var surfaceConfig = ghostty_surface_config_new()
169
+ surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
170
+ surfaceConfig.platform = ghostty_platform_u(
171
+ macos: ghostty_platform_macos_s(
172
+ nsview: Unmanaged.passUnretained(self).toOpaque()
173
+ )
174
+ )
175
+ surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
176
+
177
+ if let screen = NSScreen.main {
178
+ surfaceConfig.scale_factor = screen.backingScaleFactor
179
+ }
180
+
181
+ // Set relay command: nc connects to the Unix socket and pipes stdio
182
+ if let cmd = relayCommand {
183
+ cmd.withCString { ptr in
184
+ surfaceConfig.command = ptr
185
+ surface = ghostty_surface_new(ghosttyApp, &surfaceConfig)
186
+ }
187
+ } else {
188
+ surface = ghostty_surface_new(ghosttyApp, &surfaceConfig)
189
+ }
190
+ }
191
+
192
+ // MARK: - Action handler (called from C callback context)
193
+
194
+ /// Handle ghostty actions. This is a static method so the C function pointer
195
+ /// (action_cb) can dispatch to it via Unmanaged userdata.
196
+ nonisolated private static func handleAction(
197
+ view: GhosttyNativeView,
198
+ target: ghostty_target_s,
199
+ action: ghostty_action_s
200
+ ) -> Bool {
201
+ // All UI work must happen on MainActor
202
+ DispatchQueue.main.async { @MainActor in
203
+ switch action.tag {
204
+ case GHOSTTY_ACTION_RING_BELL:
205
+ view.onBell?()
206
+
207
+ case GHOSTTY_ACTION_SET_TITLE:
208
+ if let titlePtr = action.action.set_title.title {
209
+ let title = String(cString: titlePtr)
210
+ view.onTitle?(title)
211
+ }
212
+
213
+ case GHOSTTY_ACTION_START_SEARCH:
214
+ var needle: String? = nil
215
+ if let needlePtr = action.action.start_search.needle {
216
+ needle = String(cString: needlePtr)
217
+ }
218
+ view.onSearchStart?(needle)
219
+
220
+ case GHOSTTY_ACTION_END_SEARCH:
221
+ view.onSearchEnd?()
222
+
223
+ case GHOSTTY_ACTION_SEARCH_TOTAL:
224
+ let total = Int(action.action.search_total.total)
225
+ view.onSearchTotal?(total)
226
+
227
+ case GHOSTTY_ACTION_SEARCH_SELECTED:
228
+ let selected = Int(action.action.search_selected.selected)
229
+ view.onSearchSelected?(selected)
230
+
231
+ case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
232
+ // Could forward to NotificationManager if desired
233
+ break
234
+
235
+ default:
236
+ break
237
+ }
238
+ }
239
+ return true
240
+ }
241
+
242
+ // MARK: - Search methods
243
+
244
+ /// Navigate to the next search match.
245
+ func searchForward() {
246
+ guard let surface else { return }
247
+ let action = "search_forward"
248
+ _ = ghostty_surface_binding_action(surface, action, UInt(action.utf8.count))
249
+ }
250
+
251
+ /// Navigate to the previous search match.
252
+ func searchBackward() {
253
+ guard let surface else { return }
254
+ let action = "search_backward"
255
+ _ = ghostty_surface_binding_action(surface, action, UInt(action.utf8.count))
256
+ }
257
+
258
+ /// Send text input directly to the ghostty surface (used for paste).
259
+ func sendText(_ text: String) {
260
+ guard let surface else { return }
261
+ ghostty_surface_text(surface, text, UInt(text.utf8.count))
262
+ }
263
+
264
+ // MARK: - View lifecycle
265
+
266
+ override var acceptsFirstResponder: Bool { true }
267
+ override var isFlipped: Bool { true }
268
+
269
+ override func viewDidMoveToWindow() {
270
+ super.viewDidMoveToWindow()
271
+ guard let surface else { return }
272
+ if let screen = window?.screen {
273
+ ghostty_surface_set_content_scale(surface, screen.backingScaleFactor, screen.backingScaleFactor)
274
+ }
275
+ ghostty_surface_set_focus(surface, window != nil)
276
+ }
277
+
278
+ override func setFrameSize(_ newSize: NSSize) {
279
+ super.setFrameSize(newSize)
280
+ guard let surface, newSize.width > 0, newSize.height > 0 else { return }
281
+ ghostty_surface_set_size(surface, UInt32(newSize.width), UInt32(newSize.height))
282
+ let termSize = ghostty_surface_size(surface)
283
+ onResize?(Int(termSize.columns), Int(termSize.rows))
284
+ }
285
+
286
+ override func draw(_ dirtyRect: NSRect) {
287
+ surface.flatMap { ghostty_surface_draw($0) }
288
+ }
289
+
290
+ // MARK: - Keyboard input with IME support
291
+
292
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
293
+ // Do not intercept key equivalents while composing
294
+ if isComposing { return false }
295
+
296
+ // Intercept Cmd+V for enhanced paste
297
+ if event.modifierFlags.contains(.command),
298
+ event.charactersIgnoringModifiers == "v" {
299
+ handlePaste()
300
+ return true
301
+ }
302
+
303
+ // Intercept Cmd+F for search
304
+ if event.modifierFlags.contains(.command),
305
+ event.charactersIgnoringModifiers == "f" {
306
+ onSearchStart?(nil)
307
+ return true
308
+ }
309
+
310
+ return super.performKeyEquivalent(with: event)
311
+ }
312
+
313
+ override func keyDown(with event: NSEvent) {
314
+ // Let the input method system handle the event first (for CJK IME).
315
+ // inputContext?.handleEvent() will call back into NSTextInputClient methods:
316
+ // setMarkedText (composing), insertText (committed), etc.
317
+ if inputContext?.handleEvent(event) == true {
318
+ return
319
+ }
320
+
321
+ // If the input method did not handle it, send directly to ghostty
322
+ guard let surface else { return }
323
+ var key = ghostty_input_key_s()
324
+ key.action = GHOSTTY_ACTION_PRESS
325
+ key.keycode = UInt32(event.keyCode)
326
+ key.composing = false
327
+ if event.isARepeat { key.action = GHOSTTY_ACTION_REPEAT }
328
+ _ = ghostty_surface_key(surface, key)
329
+ }
330
+
331
+ override func keyUp(with event: NSEvent) {
332
+ guard let surface else { return }
333
+ var key = ghostty_input_key_s()
334
+ key.action = GHOSTTY_ACTION_RELEASE
335
+ key.keycode = UInt32(event.keyCode)
336
+ key.composing = false
337
+ _ = ghostty_surface_key(surface, key)
338
+ }
339
+
340
+ override func doCommand(by selector: Selector) {
341
+ // Called by the input method for special commands (e.g. moveLeft:, deleteBackward:).
342
+ // We intentionally do nothing here — ghostty handles these via keyDown.
343
+ }
344
+
345
+ // MARK: - NSTextInputClient (CJK IME support)
346
+
347
+ /// Whether there is currently marked (composing) text.
348
+ func hasMarkedText() -> Bool {
349
+ return isComposing
350
+ }
351
+
352
+ /// The range of the marked text within the total text storage.
353
+ func markedRange() -> NSRange {
354
+ if isComposing {
355
+ return NSRange(location: 0, length: imeMarkedText.length)
356
+ }
357
+ return NSRange(location: NSNotFound, length: 0)
358
+ }
359
+
360
+ /// The range of the current selection. Returns empty range at the end.
361
+ func selectedRange() -> NSRange {
362
+ return imeSelectedRange
363
+ }
364
+
365
+ /// Called by the input method to set or update composing text.
366
+ func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
367
+ let attrStr: NSAttributedString
368
+ if let s = string as? NSAttributedString {
369
+ attrStr = s
370
+ } else if let s = string as? String {
371
+ attrStr = NSAttributedString(string: s)
372
+ } else {
373
+ return
374
+ }
375
+
376
+ imeMarkedText = NSMutableAttributedString(attributedString: attrStr)
377
+ imeSelectedRange = selectedRange
378
+ isComposing = imeMarkedText.length > 0
379
+
380
+ // Notify ghostty that we are in a composing state
381
+ if isComposing, let surface {
382
+ var key = ghostty_input_key_s()
383
+ key.action = GHOSTTY_ACTION_PRESS
384
+ key.keycode = 0
385
+ key.composing = true
386
+ _ = ghostty_surface_key(surface, key)
387
+ }
388
+
389
+ needsDisplay = true
390
+ }
391
+
392
+ /// Called by the input method when composition is canceled.
393
+ func unmarkText() {
394
+ imeMarkedText = NSMutableAttributedString()
395
+ imeSelectedRange = NSRange(location: NSNotFound, length: 0)
396
+ isComposing = false
397
+ needsDisplay = true
398
+ }
399
+
400
+ /// Valid attributes for marked text display.
401
+ func validAttributesForMarkedText() -> [NSAttributedString.Key] {
402
+ return [.underlineStyle, .foregroundColor, .backgroundColor]
403
+ }
404
+
405
+ /// Return attributed substring for the proposed range.
406
+ func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
407
+ // We don't maintain a text storage, so return nil
408
+ return nil
409
+ }
410
+
411
+ /// Called when the input method commits text (final input after composing).
412
+ func insertText(_ string: Any, replacementRange: NSRange) {
413
+ // Unmark first if we were composing
414
+ let wasComposing = isComposing
415
+ if wasComposing {
416
+ unmarkText()
417
+ }
418
+
419
+ // Send the committed text to ghostty
420
+ guard let surface else { return }
421
+ if let str = string as? String {
422
+ ghostty_surface_text(surface, str, UInt(str.utf8.count))
423
+ } else if let attrStr = string as? NSAttributedString {
424
+ let str = attrStr.string
425
+ ghostty_surface_text(surface, str, UInt(str.utf8.count))
426
+ }
427
+ }
428
+
429
+ /// Return the first rect for the character at the given range.
430
+ /// Used by the input method to position the candidates window.
431
+ func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
432
+ // Return a rect near the cursor position for the IME candidates window.
433
+ // We approximate using the bottom-left of the view + some offset.
434
+ guard let windowRef = window else {
435
+ return NSRect(x: 0, y: 0, width: 0, height: 20)
436
+ }
437
+
438
+ // Use a position near the center-bottom of the view as a reasonable default
439
+ let viewRect = NSRect(x: 0, y: bounds.height - 20, width: bounds.width, height: 20)
440
+ let windowRect = convert(viewRect, to: nil)
441
+ let screenRect = windowRef.convertToScreen(windowRect)
442
+ return screenRect
443
+ }
444
+
445
+ /// Return the character index for a given point (used by input method).
446
+ func characterIndex(for point: NSPoint) -> Int {
447
+ return NSNotFound
448
+ }
449
+
450
+ // MARK: - Mouse input
451
+
452
+ override func mouseDown(with event: NSEvent) {
453
+ guard let surface else { return }
454
+ window?.makeFirstResponder(self)
455
+ ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, ghostty_input_mods_e(rawValue: 0))
456
+ }
457
+
458
+ override func mouseUp(with event: NSEvent) {
459
+ guard let surface else { return }
460
+ ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, ghostty_input_mods_e(rawValue: 0))
461
+ }
462
+
463
+ override func mouseMoved(with event: NSEvent) {
464
+ guard let surface else { return }
465
+ let pt = convert(event.locationInWindow, from: nil)
466
+ ghostty_surface_mouse_pos(surface, pt.x, pt.y, ghostty_input_mods_e(rawValue: 0))
467
+ }
468
+
469
+ override func mouseDragged(with event: NSEvent) {
470
+ guard let surface else { return }
471
+ let pt = convert(event.locationInWindow, from: nil)
472
+ ghostty_surface_mouse_pos(surface, pt.x, pt.y, ghostty_input_mods_e(rawValue: 0))
473
+ }
474
+
475
+ override func scrollWheel(with event: NSEvent) {
476
+ guard let surface else { return }
477
+ ghostty_surface_mouse_scroll(surface, event.scrollingDeltaX, event.scrollingDeltaY, 0)
478
+ }
479
+
480
+ // MARK: - Focus
481
+
482
+ override func becomeFirstResponder() -> Bool {
483
+ surface.flatMap { ghostty_surface_set_focus($0, true) }
484
+ return super.becomeFirstResponder()
485
+ }
486
+
487
+ override func resignFirstResponder() -> Bool {
488
+ surface.flatMap { ghostty_surface_set_focus($0, false) }
489
+ return super.resignFirstResponder()
490
+ }
491
+
492
+ // MARK: - NSDraggingDestination (File Drop Support)
493
+
494
+ override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
495
+ guard sender.draggingPasteboard.canReadObject(
496
+ forClasses: [NSURL.self],
497
+ options: [.urlReadingFileURLsOnly: true]
498
+ ) else {
499
+ return []
500
+ }
501
+ isDragHighlighted = true
502
+ return .copy
503
+ }
504
+
505
+ override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
506
+ guard isDragHighlighted else { return [] }
507
+ return .copy
508
+ }
509
+
510
+ override func draggingExited(_ sender: (any NSDraggingInfo)?) {
511
+ isDragHighlighted = false
512
+ }
513
+
514
+ override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
515
+ isDragHighlighted = false
516
+
517
+ let pb = sender.draggingPasteboard
518
+ guard let urls = pb.readObjects(
519
+ forClasses: [NSURL.self],
520
+ options: [.urlReadingFileURLsOnly: true]
521
+ ) as? [URL], !urls.isEmpty else {
522
+ return false
523
+ }
524
+
525
+ // Shell-escape each path and join with spaces
526
+ let escapedPaths = urls.map { url in
527
+ ClipboardHelper.escapeForShell(url.path)
528
+ }
529
+ let text = escapedPaths.joined(separator: " ")
530
+ sendText(text)
531
+ return true
532
+ }
533
+
534
+ override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
535
+ return true
536
+ }
537
+
538
+ override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
539
+ isDragHighlighted = false
540
+ }
541
+
542
+ // MARK: - Private: Paste handling
543
+
544
+ private func handlePaste() {
545
+ let pb = NSPasteboard.general
546
+
547
+ // Check for image first — save to temp and paste path
548
+ if let imageURL = ClipboardHelper.saveImageToTemp(from: pb) {
549
+ let escapedPath = ClipboardHelper.escapeForShell(imageURL.path)
550
+ sendText(escapedPath)
551
+ return
552
+ }
553
+
554
+ // Use ClipboardHelper for enhanced paste (files, RTF, HTML, etc.)
555
+ if let content = ClipboardHelper.pasteContent(from: pb) {
556
+ sendText(content)
557
+ }
558
+ }
559
+ }
@@ -0,0 +1,109 @@
1
+ import SwiftUI
2
+
3
+ /// Search overlay displayed on top of the terminal surface.
4
+ /// Adapted from ghostty-org/ghostty macOS SearchView.swift (design pattern)
5
+ struct SurfaceSearchOverlay: View {
6
+ @Binding var isVisible: Bool
7
+ @Binding var searchText: String
8
+ @Binding var totalMatches: Int
9
+ @Binding var selectedMatch: Int
10
+
11
+ var onSearch: (String) -> Void
12
+ var onNext: () -> Void
13
+ var onPrevious: () -> Void
14
+ var onClose: () -> Void
15
+
16
+ @FocusState private var isFocused: Bool
17
+
18
+ var body: some View {
19
+ if isVisible {
20
+ HStack(spacing: 6) {
21
+ // Search text field
22
+ TextField("Find...", text: $searchText)
23
+ .textFieldStyle(.roundedBorder)
24
+ .frame(width: 200)
25
+ .focused($isFocused)
26
+ .onSubmit {
27
+ if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
28
+ onPrevious()
29
+ } else {
30
+ onNext()
31
+ }
32
+ }
33
+ .onChange(of: searchText) { _, newValue in
34
+ onSearch(newValue)
35
+ }
36
+ .onExitCommand {
37
+ closeSearch()
38
+ }
39
+
40
+ // Match indicator
41
+ matchIndicator
42
+ .font(.system(size: 11, design: .monospaced))
43
+ .foregroundStyle(.secondary)
44
+ .frame(minWidth: 50)
45
+
46
+ // Navigation buttons
47
+ Button(action: onPrevious) {
48
+ Image(systemName: "chevron.up")
49
+ .font(.system(size: 11, weight: .medium))
50
+ }
51
+ .buttonStyle(.borderless)
52
+ .help("Previous Match (Shift+Enter)")
53
+ .disabled(totalMatches <= 0)
54
+
55
+ Button(action: onNext) {
56
+ Image(systemName: "chevron.down")
57
+ .font(.system(size: 11, weight: .medium))
58
+ }
59
+ .buttonStyle(.borderless)
60
+ .help("Next Match (Enter)")
61
+ .disabled(totalMatches <= 0)
62
+
63
+ // Close button
64
+ Button(action: closeSearch) {
65
+ Image(systemName: "xmark")
66
+ .font(.system(size: 10, weight: .bold))
67
+ .foregroundStyle(.secondary)
68
+ }
69
+ .buttonStyle(.borderless)
70
+ .help("Close (Esc)")
71
+ }
72
+ .padding(.horizontal, 10)
73
+ .padding(.vertical, 6)
74
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
75
+ .overlay(
76
+ RoundedRectangle(cornerRadius: 8)
77
+ .stroke(Color.primary.opacity(0.1), lineWidth: 1)
78
+ )
79
+ .shadow(color: .black.opacity(0.15), radius: 4, y: 2)
80
+ .padding(.trailing, 12)
81
+ .padding(.top, 8)
82
+ .onAppear {
83
+ isFocused = true
84
+ }
85
+ }
86
+ }
87
+
88
+ @ViewBuilder
89
+ private var matchIndicator: some View {
90
+ if searchText.isEmpty {
91
+ Text("")
92
+ } else if totalMatches < 0 {
93
+ // -1 means regex error
94
+ Text("error")
95
+ .foregroundStyle(.red)
96
+ } else if totalMatches == 0 {
97
+ Text("0 results")
98
+ } else {
99
+ let display = selectedMatch >= 0 ? "\(selectedMatch + 1) of \(totalMatches)" : "\(totalMatches) found"
100
+ Text(display)
101
+ }
102
+ }
103
+
104
+ private func closeSearch() {
105
+ searchText = ""
106
+ isVisible = false
107
+ onClose()
108
+ }
109
+ }