@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,94 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Horizontal tab bar showing tabs in the current session.
5
+ /// Supports click to switch, + button to create, close button on hover.
6
+ struct TabBarView: View {
7
+ @Environment(RemuxState.self) private var state
8
+
9
+ var body: some View {
10
+ ScrollView(.horizontal, showsIndicators: false) {
11
+ HStack(spacing: 0) {
12
+ ForEach(state.tabs, id: \.index) { tab in
13
+ TabItemView(
14
+ tab: tab,
15
+ isActive: tab.index == state.activeTabIndex,
16
+ onSelect: {
17
+ if let pane = tab.panes.first {
18
+ state.switchTab(id: pane.id)
19
+ }
20
+ },
21
+ onClose: {
22
+ if let pane = tab.panes.first {
23
+ state.closeTab(id: pane.id)
24
+ }
25
+ }
26
+ )
27
+ }
28
+
29
+ // New tab button
30
+ Button(action: { state.createTab() }) {
31
+ Image(systemName: "plus")
32
+ .font(.system(size: 11))
33
+ .foregroundStyle(.secondary)
34
+ .frame(width: 28, height: 28)
35
+ }
36
+ .buttonStyle(.plain)
37
+ .help("New Tab")
38
+
39
+ Spacer()
40
+ }
41
+ }
42
+ .frame(height: 32)
43
+ .background(.bar)
44
+ }
45
+ }
46
+
47
+ struct TabItemView: View {
48
+ let tab: WorkspaceTab
49
+ let isActive: Bool
50
+ let onSelect: () -> Void
51
+ let onClose: () -> Void
52
+
53
+ @State private var isHovering = false
54
+
55
+ var body: some View {
56
+ HStack(spacing: 4) {
57
+ if tab.hasBell {
58
+ Circle()
59
+ .fill(.red)
60
+ .frame(width: 6, height: 6)
61
+ }
62
+
63
+ Text(tab.name)
64
+ .font(.system(size: 12))
65
+ .lineLimit(1)
66
+
67
+ if isHovering {
68
+ Button(action: onClose) {
69
+ Image(systemName: "xmark")
70
+ .font(.system(size: 8, weight: .bold))
71
+ .foregroundStyle(.secondary)
72
+ }
73
+ .buttonStyle(.plain)
74
+ .frame(width: 14, height: 14)
75
+ }
76
+ }
77
+ .padding(.horizontal, 12)
78
+ .padding(.vertical, 6)
79
+ .background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
80
+ .cornerRadius(6)
81
+ .overlay(
82
+ RoundedRectangle(cornerRadius: 6)
83
+ .stroke(isActive ? Color.accentColor.opacity(0.3) : Color.clear, lineWidth: 1)
84
+ )
85
+ .onHover { isHovering = $0 }
86
+ .onTapGesture { onSelect() }
87
+ .contextMenu {
88
+ Button("Close Tab") { onClose() }
89
+ Button("Rename Tab...") {
90
+ // TODO: rename dialog
91
+ }
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,101 @@
1
+ import AppKit
2
+ import UniformTypeIdentifiers
3
+
4
+ /// Helper for enhanced clipboard operations in the terminal.
5
+ /// Adapted from ghostty-org/ghostty macOS clipboard handling patterns.
6
+ enum ClipboardHelper {
7
+
8
+ /// Read paste content from the pasteboard with priority:
9
+ /// file URLs -> plain text -> RTF -> HTML
10
+ static func pasteContent(from pasteboard: NSPasteboard) -> String? {
11
+ // Priority 1: File URLs — paste as escaped shell paths
12
+ if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
13
+ !urls.isEmpty {
14
+ let paths = urls.compactMap { url -> String? in
15
+ guard url.isFileURL else { return url.absoluteString }
16
+ return escapeForShell(url.path)
17
+ }
18
+ if !paths.isEmpty {
19
+ return paths.joined(separator: " ")
20
+ }
21
+ }
22
+
23
+ // Priority 2: Plain text
24
+ if let text = pasteboard.string(forType: .string), !text.isEmpty {
25
+ return text
26
+ }
27
+
28
+ // Priority 3: RTF — extract plain text from attributed string
29
+ if let rtfData = pasteboard.data(forType: .rtf) {
30
+ if let attrStr = NSAttributedString(rtf: rtfData, documentAttributes: nil) {
31
+ let text = attrStr.string
32
+ if !text.isEmpty { return text }
33
+ }
34
+ }
35
+
36
+ // Priority 4: HTML — extract plain text from HTML
37
+ if let htmlData = pasteboard.data(forType: .html) {
38
+ if let attrStr = try? NSAttributedString(
39
+ data: htmlData,
40
+ options: [.documentType: NSAttributedString.DocumentType.html],
41
+ documentAttributes: nil
42
+ ) {
43
+ let text = attrStr.string
44
+ if !text.isEmpty { return text }
45
+ }
46
+ }
47
+
48
+ return nil
49
+ }
50
+
51
+ /// Escape a file path for safe use in shell commands.
52
+ /// Handles spaces, parentheses, quotes, and other special characters.
53
+ static func escapeForShell(_ path: String) -> String {
54
+ let specialChars: Set<Character> = [
55
+ " ", "(", ")", "'", "\"", "\\", "!", "#", "$", "&",
56
+ ";", "|", "<", ">", "?", "*", "[", "]", "{", "}",
57
+ "~", "`", "^",
58
+ ]
59
+
60
+ var result = ""
61
+ for char in path {
62
+ if specialChars.contains(char) {
63
+ result.append("\\")
64
+ }
65
+ result.append(char)
66
+ }
67
+ return result
68
+ }
69
+
70
+ /// Save a pasted image from the pasteboard to a temp file.
71
+ /// Returns the file URL if successful.
72
+ static func saveImageToTemp(from pasteboard: NSPasteboard) -> URL? {
73
+ // Check for image data types
74
+ let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
75
+
76
+ for imageType in imageTypes {
77
+ guard let imageData = pasteboard.data(forType: imageType) else { continue }
78
+
79
+ guard let image = NSImage(data: imageData),
80
+ let tiffData = image.tiffRepresentation,
81
+ let bitmap = NSBitmapImageRep(data: tiffData),
82
+ let pngData = bitmap.representation(using: .png, properties: [:]) else {
83
+ continue
84
+ }
85
+
86
+ let tempDir = FileManager.default.temporaryDirectory
87
+ let filename = "remux-paste-\(UUID().uuidString.prefix(8)).png"
88
+ let fileURL = tempDir.appendingPathComponent(filename)
89
+
90
+ do {
91
+ try pngData.write(to: fileURL)
92
+ return fileURL
93
+ } catch {
94
+ NSLog("[remux] Failed to save pasted image: \(error)")
95
+ continue
96
+ }
97
+ }
98
+
99
+ return nil
100
+ }
101
+ }
@@ -0,0 +1,325 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ /// Vi-like copy mode overlay for terminal text selection.
5
+ /// Toggle with Cmd+Shift+C. Shows "COPY MODE" indicator.
6
+ /// Arrow keys move cursor, v enters visual select, y copies, Escape exits.
7
+ ///
8
+ /// Works by reading current terminal text from inspect API and
9
+ /// rendering a selection overlay.
10
+ ///
11
+ /// Adapted from tmux copy-mode and Zellij scroll/search mode UX patterns.
12
+ struct CopyModeOverlay: View {
13
+ @Binding var isActive: Bool
14
+ @State private var cursorRow: Int = 0
15
+ @State private var cursorCol: Int = 0
16
+ @State private var selectionStart: CopyModePosition?
17
+ @State private var isVisualMode: Bool = false
18
+ @State private var terminalLines: [String] = []
19
+ @State private var statusMessage: String = ""
20
+
21
+ /// Callback to request terminal text content.
22
+ var onRequestContent: (() -> [String])?
23
+ /// Callback when text is copied.
24
+ var onCopy: ((String) -> Void)?
25
+
26
+ struct CopyModePosition: Equatable {
27
+ var row: Int
28
+ var col: Int
29
+ }
30
+
31
+ var body: some View {
32
+ if isActive {
33
+ ZStack {
34
+ // Semi-transparent overlay
35
+ Color.black.opacity(0.05)
36
+ .allowsHitTesting(true)
37
+
38
+ VStack {
39
+ // Mode indicator
40
+ HStack {
41
+ Spacer()
42
+
43
+ HStack(spacing: 6) {
44
+ Image(systemName: "doc.on.doc")
45
+ .font(.system(size: 10))
46
+ Text(isVisualMode ? "VISUAL" : "COPY MODE")
47
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
48
+ }
49
+ .padding(.horizontal, 10)
50
+ .padding(.vertical, 4)
51
+ .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6))
52
+ .overlay(
53
+ RoundedRectangle(cornerRadius: 6)
54
+ .stroke(Color.yellow.opacity(0.5), lineWidth: 1)
55
+ )
56
+ .padding(.trailing, 12)
57
+ .padding(.top, 8)
58
+ }
59
+
60
+ Spacer()
61
+
62
+ // Status bar
63
+ HStack {
64
+ if !statusMessage.isEmpty {
65
+ Text(statusMessage)
66
+ .font(.system(size: 11, design: .monospaced))
67
+ .foregroundStyle(.secondary)
68
+ }
69
+
70
+ Spacer()
71
+
72
+ Text("Ln \(cursorRow + 1), Col \(cursorCol + 1)")
73
+ .font(.system(size: 11, design: .monospaced))
74
+ .foregroundStyle(.secondary)
75
+
76
+ Text("| h/j/k/l: move | v: visual | y: yank | Esc: exit")
77
+ .font(.system(size: 10, design: .monospaced))
78
+ .foregroundStyle(.tertiary)
79
+ }
80
+ .padding(.horizontal, 10)
81
+ .padding(.vertical, 4)
82
+ .background(.ultraThinMaterial)
83
+ }
84
+ }
85
+ .onAppear {
86
+ loadContent()
87
+ }
88
+ .onKeyPress { keyPress in
89
+ handleKeyPress(keyPress)
90
+ }
91
+ }
92
+ }
93
+
94
+ private func loadContent() {
95
+ if let lines = onRequestContent?() {
96
+ terminalLines = lines
97
+ if !lines.isEmpty {
98
+ cursorRow = max(0, lines.count - 1)
99
+ cursorCol = 0
100
+ }
101
+ }
102
+ statusMessage = ""
103
+ }
104
+
105
+ private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
106
+ switch keyPress.characters {
107
+ // Movement: vi keys
108
+ case "h":
109
+ moveCursor(dRow: 0, dCol: -1)
110
+ return .handled
111
+ case "j":
112
+ moveCursor(dRow: 1, dCol: 0)
113
+ return .handled
114
+ case "k":
115
+ moveCursor(dRow: -1, dCol: 0)
116
+ return .handled
117
+ case "l":
118
+ moveCursor(dRow: 0, dCol: 1)
119
+ return .handled
120
+
121
+ // Movement: word
122
+ case "w":
123
+ moveWordForward()
124
+ return .handled
125
+ case "b":
126
+ moveWordBackward()
127
+ return .handled
128
+
129
+ // Line start/end
130
+ case "0":
131
+ cursorCol = 0
132
+ return .handled
133
+ case "$":
134
+ if cursorRow < terminalLines.count {
135
+ cursorCol = max(0, terminalLines[cursorRow].count - 1)
136
+ }
137
+ return .handled
138
+
139
+ // Top/bottom
140
+ case "g":
141
+ cursorRow = 0
142
+ cursorCol = 0
143
+ return .handled
144
+ case "G":
145
+ cursorRow = max(0, terminalLines.count - 1)
146
+ cursorCol = 0
147
+ return .handled
148
+
149
+ // Visual mode toggle
150
+ case "v":
151
+ toggleVisualMode()
152
+ return .handled
153
+
154
+ // Yank (copy)
155
+ case "y":
156
+ yankSelection()
157
+ return .handled
158
+
159
+ default:
160
+ break
161
+ }
162
+
163
+ // Arrow keys
164
+ switch keyPress.key {
165
+ case .upArrow:
166
+ moveCursor(dRow: -1, dCol: 0)
167
+ return .handled
168
+ case .downArrow:
169
+ moveCursor(dRow: 1, dCol: 0)
170
+ return .handled
171
+ case .leftArrow:
172
+ moveCursor(dRow: 0, dCol: -1)
173
+ return .handled
174
+ case .rightArrow:
175
+ moveCursor(dRow: 0, dCol: 1)
176
+ return .handled
177
+ case .escape:
178
+ if isVisualMode {
179
+ isVisualMode = false
180
+ selectionStart = nil
181
+ statusMessage = ""
182
+ } else {
183
+ isActive = false
184
+ }
185
+ return .handled
186
+ default:
187
+ break
188
+ }
189
+
190
+ return .ignored
191
+ }
192
+
193
+ private func moveCursor(dRow: Int, dCol: Int) {
194
+ let newRow = max(0, min(terminalLines.count - 1, cursorRow + dRow))
195
+ let maxCol = max(0, (newRow < terminalLines.count ? terminalLines[newRow].count : 80) - 1)
196
+ let newCol = max(0, min(maxCol, cursorCol + dCol))
197
+ cursorRow = newRow
198
+ cursorCol = newCol
199
+ }
200
+
201
+ private func moveWordForward() {
202
+ guard cursorRow < terminalLines.count else { return }
203
+ let line = terminalLines[cursorRow]
204
+ let chars = Array(line)
205
+ var col = cursorCol
206
+
207
+ // Skip current word characters
208
+ while col < chars.count && !chars[col].isWhitespace { col += 1 }
209
+ // Skip whitespace
210
+ while col < chars.count && chars[col].isWhitespace { col += 1 }
211
+
212
+ if col >= chars.count && cursorRow < terminalLines.count - 1 {
213
+ cursorRow += 1
214
+ cursorCol = 0
215
+ } else {
216
+ cursorCol = min(col, max(0, chars.count - 1))
217
+ }
218
+ }
219
+
220
+ private func moveWordBackward() {
221
+ guard cursorRow < terminalLines.count else { return }
222
+ let line = terminalLines[cursorRow]
223
+ let chars = Array(line)
224
+ var col = cursorCol
225
+
226
+ if col <= 0 && cursorRow > 0 {
227
+ cursorRow -= 1
228
+ cursorCol = max(0, terminalLines[cursorRow].count - 1)
229
+ return
230
+ }
231
+
232
+ // Skip whitespace backwards
233
+ while col > 0 && chars[max(0, col - 1)].isWhitespace { col -= 1 }
234
+ // Skip word characters backwards
235
+ while col > 0 && !chars[max(0, col - 1)].isWhitespace { col -= 1 }
236
+
237
+ cursorCol = max(0, col)
238
+ }
239
+
240
+ private func toggleVisualMode() {
241
+ if isVisualMode {
242
+ isVisualMode = false
243
+ selectionStart = nil
244
+ statusMessage = ""
245
+ } else {
246
+ isVisualMode = true
247
+ selectionStart = CopyModePosition(row: cursorRow, col: cursorCol)
248
+ statusMessage = "VISUAL: move to select, y to yank"
249
+ }
250
+ }
251
+
252
+ private func yankSelection() {
253
+ let text: String
254
+ if isVisualMode, let start = selectionStart {
255
+ text = extractSelection(from: start, to: CopyModePosition(row: cursorRow, col: cursorCol))
256
+ } else {
257
+ // Yank current line
258
+ if cursorRow < terminalLines.count {
259
+ text = terminalLines[cursorRow]
260
+ } else {
261
+ text = ""
262
+ }
263
+ }
264
+
265
+ guard !text.isEmpty else {
266
+ statusMessage = "Nothing to yank"
267
+ return
268
+ }
269
+
270
+ // Copy to clipboard
271
+ NSPasteboard.general.clearContents()
272
+ NSPasteboard.general.setString(text, forType: .string)
273
+
274
+ statusMessage = "Yanked \(text.count) chars"
275
+ onCopy?(text)
276
+
277
+ // Exit copy mode after yank
278
+ isVisualMode = false
279
+ selectionStart = nil
280
+
281
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
282
+ self.isActive = false
283
+ }
284
+ }
285
+
286
+ private func extractSelection(from start: CopyModePosition, to end: CopyModePosition) -> String {
287
+ let (s, e) = start.row < end.row || (start.row == end.row && start.col <= end.col)
288
+ ? (start, end) : (end, start)
289
+
290
+ guard s.row < terminalLines.count else { return "" }
291
+
292
+ if s.row == e.row {
293
+ let line = terminalLines[s.row]
294
+ let chars = Array(line)
295
+ let startIdx = min(s.col, chars.count)
296
+ let endIdx = min(e.col + 1, chars.count)
297
+ return String(chars[startIdx..<endIdx])
298
+ }
299
+
300
+ var result: [String] = []
301
+
302
+ // First line (from start col)
303
+ let firstLine = terminalLines[s.row]
304
+ let firstChars = Array(firstLine)
305
+ let startIdx = min(s.col, firstChars.count)
306
+ result.append(String(firstChars[startIdx...]))
307
+
308
+ // Middle lines (full)
309
+ for row in (s.row + 1)..<e.row {
310
+ if row < terminalLines.count {
311
+ result.append(terminalLines[row])
312
+ }
313
+ }
314
+
315
+ // Last line (up to end col)
316
+ if e.row < terminalLines.count {
317
+ let lastLine = terminalLines[e.row]
318
+ let lastChars = Array(lastLine)
319
+ let endIdx = min(e.col + 1, lastChars.count)
320
+ result.append(String(lastChars[..<endIdx]))
321
+ }
322
+
323
+ return result.joined(separator: "\n")
324
+ }
325
+ }
@@ -0,0 +1,39 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// SwiftUI wrapper for GhosttyNativeView (libghostty Metal renderer).
5
+ /// The relay socket path is used to create the ghostty surface with `nc -U` as its command.
6
+ struct GhosttyNativeTerminalView: NSViewRepresentable {
7
+ let socketPath: String
8
+ @Binding var viewRef: GhosttyNativeView?
9
+ var onResize: ((Int, Int) -> Void)?
10
+ var onBell: (() -> Void)?
11
+ var onTitle: ((String) -> Void)?
12
+ var onSearchStart: ((String?) -> Void)?
13
+ var onSearchEnd: (() -> Void)?
14
+ var onSearchTotal: ((Int) -> Void)?
15
+ var onSearchSelected: ((Int) -> Void)?
16
+
17
+ func makeNSView(context: Context) -> GhosttyNativeView {
18
+ let view = GhosttyNativeView(frame: .zero, socketPath: socketPath)
19
+ view.onResize = onResize
20
+ view.onBell = onBell
21
+ view.onTitle = onTitle
22
+ view.onSearchStart = onSearchStart
23
+ view.onSearchEnd = onSearchEnd
24
+ view.onSearchTotal = onSearchTotal
25
+ view.onSearchSelected = onSearchSelected
26
+ DispatchQueue.main.async { viewRef = view }
27
+ return view
28
+ }
29
+
30
+ func updateNSView(_ nsView: GhosttyNativeView, context: Context) {
31
+ nsView.onResize = onResize
32
+ nsView.onBell = onBell
33
+ nsView.onTitle = onTitle
34
+ nsView.onSearchStart = onSearchStart
35
+ nsView.onSearchEnd = onSearchEnd
36
+ nsView.onSearchTotal = onSearchTotal
37
+ nsView.onSearchSelected = onSearchSelected
38
+ }
39
+ }