@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,311 @@
1
+ import AppKit
2
+
3
+ /// Reads and parses the user's Ghostty configuration file.
4
+ /// Ghostty config format: key = value, # comments, one setting per line.
5
+ /// Ref: ghostty-org/ghostty config file specification.
6
+ @MainActor
7
+ final class GhosttyConfig: Sendable {
8
+
9
+ // MARK: - Parsed values
10
+
11
+ let fontFamily: String?
12
+ let fontSize: CGFloat?
13
+ let theme: String?
14
+ let background: NSColor?
15
+ let foreground: NSColor?
16
+ let cursorColor: NSColor?
17
+ let selectionBackground: NSColor?
18
+ let selectionForeground: NSColor?
19
+ let backgroundOpacity: CGFloat?
20
+ let palette: [Int: NSColor] // ANSI palette 0-15
21
+
22
+ // MARK: - Cache
23
+
24
+ private static var cachedLight: GhosttyConfig?
25
+ private static var cachedDark: GhosttyConfig?
26
+
27
+ // MARK: - Standard config paths
28
+
29
+ /// Default user config path.
30
+ static let userConfigPath: String = {
31
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
32
+ return home + "/.config/ghostty/config"
33
+ }()
34
+
35
+ /// Directories to search for themes.
36
+ static let themeSearchPaths: [String] = {
37
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
38
+ return [
39
+ home + "/.config/ghostty/themes",
40
+ "/usr/share/ghostty/themes",
41
+ "/usr/local/share/ghostty/themes",
42
+ ]
43
+ }()
44
+
45
+ // MARK: - Init (private; use load())
46
+
47
+ private init(values: [String: String]) {
48
+ fontFamily = values["font-family"]
49
+ fontSize = values["font-size"].flatMap { CGFloat(Double($0) ?? 0) }
50
+ theme = values["theme"]
51
+ background = values["background"].flatMap { Self.parseColor($0) }
52
+ foreground = values["foreground"].flatMap { Self.parseColor($0) }
53
+ cursorColor = values["cursor-color"].flatMap { Self.parseColor($0) }
54
+ selectionBackground = values["selection-background"].flatMap { Self.parseColor($0) }
55
+ selectionForeground = values["selection-foreground"].flatMap { Self.parseColor($0) }
56
+
57
+ if let opStr = values["background-opacity"], let op = Double(opStr) {
58
+ backgroundOpacity = CGFloat(max(0, min(1, op)))
59
+ } else {
60
+ backgroundOpacity = nil
61
+ }
62
+
63
+ var pal: [Int: NSColor] = [:]
64
+ for i in 0...15 {
65
+ let key = "palette = \(i)" // ghostty uses "palette = N=RRGGBB" format
66
+ // Actually ghostty uses "palette = 0=#RRGGBB" in a single key
67
+ // But the parsed format from our parser will be "palette" with value "N=#RRGGBB"
68
+ // We handle palette entries specially below
69
+ _ = key
70
+ }
71
+
72
+ // Parse palette entries from the raw values (handled in parsePalette)
73
+ pal = [:] // Will be filled by load() using paletteEntries
74
+ palette = pal
75
+ }
76
+
77
+ private init(
78
+ fontFamily: String?,
79
+ fontSize: CGFloat?,
80
+ theme: String?,
81
+ background: NSColor?,
82
+ foreground: NSColor?,
83
+ cursorColor: NSColor?,
84
+ selectionBackground: NSColor?,
85
+ selectionForeground: NSColor?,
86
+ backgroundOpacity: CGFloat?,
87
+ palette: [Int: NSColor]
88
+ ) {
89
+ self.fontFamily = fontFamily
90
+ self.fontSize = fontSize
91
+ self.theme = theme
92
+ self.background = background
93
+ self.foreground = foreground
94
+ self.cursorColor = cursorColor
95
+ self.selectionBackground = selectionBackground
96
+ self.selectionForeground = selectionForeground
97
+ self.backgroundOpacity = backgroundOpacity
98
+ self.palette = palette
99
+ }
100
+
101
+ // MARK: - Public API
102
+
103
+ /// Load the Ghostty configuration. Caches per color scheme.
104
+ static func load(forScheme scheme: ColorScheme = .current) -> GhosttyConfig {
105
+ switch scheme {
106
+ case .dark:
107
+ if let cached = cachedDark { return cached }
108
+ case .light:
109
+ if let cached = cachedLight { return cached }
110
+ }
111
+
112
+ let config = parseConfigFile(at: userConfigPath, scheme: scheme)
113
+
114
+ switch scheme {
115
+ case .dark: cachedDark = config
116
+ case .light: cachedLight = config
117
+ }
118
+
119
+ return config
120
+ }
121
+
122
+ /// Invalidate the cache (e.g., after detecting config file change).
123
+ static func invalidateCache() {
124
+ cachedLight = nil
125
+ cachedDark = nil
126
+ }
127
+
128
+ enum ColorScheme {
129
+ case light, dark
130
+
131
+ static var current: ColorScheme {
132
+ let appearance = NSApp.effectiveAppearance
133
+ if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
134
+ return .dark
135
+ }
136
+ return .light
137
+ }
138
+ }
139
+
140
+ // MARK: - Parsing
141
+
142
+ /// Parse a Ghostty config file into a GhosttyConfig instance.
143
+ private static func parseConfigFile(at path: String, scheme: ColorScheme) -> GhosttyConfig {
144
+ var values: [String: String] = [:]
145
+ var paletteEntries: [Int: NSColor] = [:]
146
+
147
+ // Parse main config file
148
+ if let lines = readConfigLines(at: path) {
149
+ parseLines(lines, into: &values, palette: &paletteEntries)
150
+ }
151
+
152
+ // If a theme is specified, load theme overrides
153
+ if let themeName = values["theme"] {
154
+ let themeValues = loadTheme(name: themeName, scheme: scheme)
155
+ // Theme values are overridden by user config, so merge theme first
156
+ var merged = themeValues.values
157
+ for (k, v) in values {
158
+ merged[k] = v
159
+ }
160
+ for (k, v) in themeValues.palette {
161
+ if paletteEntries[k] == nil {
162
+ paletteEntries[k] = v
163
+ }
164
+ }
165
+ values = merged
166
+ }
167
+
168
+ return GhosttyConfig(
169
+ fontFamily: values["font-family"],
170
+ fontSize: values["font-size"].flatMap { CGFloat(Double($0) ?? 0) },
171
+ theme: values["theme"],
172
+ background: values["background"].flatMap { parseColor($0) },
173
+ foreground: values["foreground"].flatMap { parseColor($0) },
174
+ cursorColor: values["cursor-color"].flatMap { parseColor($0) },
175
+ selectionBackground: values["selection-background"].flatMap { parseColor($0) },
176
+ selectionForeground: values["selection-foreground"].flatMap { parseColor($0) },
177
+ backgroundOpacity: values["background-opacity"].flatMap { Double($0) }.map { CGFloat(max(0, min(1, $0))) },
178
+ palette: paletteEntries
179
+ )
180
+ }
181
+
182
+ /// Read a config file and return its lines, skipping comments and empty lines.
183
+ private static func readConfigLines(at path: String) -> [String]? {
184
+ guard FileManager.default.fileExists(atPath: path),
185
+ let content = try? String(contentsOfFile: path, encoding: .utf8) else {
186
+ return nil
187
+ }
188
+ return content.components(separatedBy: .newlines)
189
+ }
190
+
191
+ /// Parse config lines into key-value pairs and palette entries.
192
+ private static func parseLines(
193
+ _ lines: [String],
194
+ into values: inout [String: String],
195
+ palette: inout [Int: NSColor]
196
+ ) {
197
+ for rawLine in lines {
198
+ let line = rawLine.trimmingCharacters(in: .whitespaces)
199
+
200
+ // Skip comments and empty lines
201
+ if line.isEmpty || line.hasPrefix("#") { continue }
202
+
203
+ // Split on first "=" sign
204
+ guard let eqIndex = line.firstIndex(of: "=") else { continue }
205
+ let key = String(line[line.startIndex..<eqIndex]).trimmingCharacters(in: .whitespaces)
206
+ let value = String(line[line.index(after: eqIndex)...]).trimmingCharacters(in: .whitespaces)
207
+
208
+ if key.isEmpty { continue }
209
+
210
+ // Handle palette entries: "palette = 0=#000000" or just key "palette" with value "0=#000000"
211
+ if key == "palette" {
212
+ if let parsed = parsePaletteEntry(value) {
213
+ palette[parsed.index] = parsed.color
214
+ }
215
+ continue
216
+ }
217
+
218
+ values[key] = value
219
+ }
220
+ }
221
+
222
+ /// Parse a palette value like "0=#000000" into (index, color).
223
+ private static func parsePaletteEntry(_ value: String) -> (index: Int, color: NSColor)? {
224
+ // Format: "N=RRGGBB" or "N=#RRGGBB"
225
+ guard let eqIdx = value.firstIndex(of: "=") else { return nil }
226
+ let indexStr = String(value[value.startIndex..<eqIdx]).trimmingCharacters(in: .whitespaces)
227
+ let colorStr = String(value[value.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
228
+
229
+ guard let index = Int(indexStr), (0...15).contains(index) else { return nil }
230
+ guard let color = parseColor(colorStr) else { return nil }
231
+ return (index, color)
232
+ }
233
+
234
+ /// Theme file result container.
235
+ private struct ThemeResult {
236
+ var values: [String: String] = [:]
237
+ var palette: [Int: NSColor] = [:]
238
+ }
239
+
240
+ /// Load a theme by name from standard search paths.
241
+ private static func loadTheme(name: String, scheme: ColorScheme) -> ThemeResult {
242
+ // Ghostty supports "theme = auto" which picks light/dark variant
243
+ var themeName = name
244
+ if themeName == "auto" {
245
+ // No specific theme to load for "auto"
246
+ return ThemeResult()
247
+ }
248
+
249
+ // Search theme files
250
+ for searchPath in themeSearchPaths {
251
+ let themePath = searchPath + "/" + themeName
252
+ if let lines = readConfigLines(at: themePath) {
253
+ var result = ThemeResult()
254
+ parseLines(lines, into: &result.values, palette: &result.palette)
255
+ return result
256
+ }
257
+ }
258
+
259
+ // Also check with common extensions
260
+ for ext in ["", ".conf", ".theme"] {
261
+ for searchPath in themeSearchPaths {
262
+ let themePath = searchPath + "/" + themeName + ext
263
+ if let lines = readConfigLines(at: themePath) {
264
+ var result = ThemeResult()
265
+ parseLines(lines, into: &result.values, palette: &result.palette)
266
+ return result
267
+ }
268
+ }
269
+ }
270
+
271
+ return ThemeResult()
272
+ }
273
+
274
+ // MARK: - Color parsing
275
+
276
+ /// Parse a hex color string to NSColor.
277
+ /// Supports: "#RRGGBB", "RRGGBB", "#RGB", "RGB", "#RRGGBBAA"
278
+ static func parseColor(_ str: String) -> NSColor? {
279
+ var hex = str.trimmingCharacters(in: .whitespaces)
280
+ if hex.hasPrefix("#") {
281
+ hex = String(hex.dropFirst())
282
+ }
283
+
284
+ // Expand shorthand #RGB to #RRGGBB
285
+ if hex.count == 3 {
286
+ let chars = Array(hex)
287
+ hex = String([chars[0], chars[0], chars[1], chars[1], chars[2], chars[2]])
288
+ }
289
+
290
+ let scanner = Scanner(string: hex)
291
+ var value: UInt64 = 0
292
+ guard scanner.scanHexInt64(&value) else { return nil }
293
+
294
+ if hex.count == 8 {
295
+ // RRGGBBAA
296
+ let r = CGFloat((value >> 24) & 0xFF) / 255.0
297
+ let g = CGFloat((value >> 16) & 0xFF) / 255.0
298
+ let b = CGFloat((value >> 8) & 0xFF) / 255.0
299
+ let a = CGFloat(value & 0xFF) / 255.0
300
+ return NSColor(srgbRed: r, green: g, blue: b, alpha: a)
301
+ } else if hex.count == 6 {
302
+ // RRGGBB
303
+ let r = CGFloat((value >> 16) & 0xFF) / 255.0
304
+ let g = CGFloat((value >> 8) & 0xFF) / 255.0
305
+ let b = CGFloat(value & 0xFF) / 255.0
306
+ return NSColor(srgbRed: r, green: g, blue: b, alpha: 1.0)
307
+ }
308
+
309
+ return nil
310
+ }
311
+ }
@@ -0,0 +1,115 @@
1
+ import Foundation
2
+
3
+ /// All bindable actions in the app.
4
+ /// Adapted from ghostty-org/ghostty key binding action catalog design.
5
+ enum ShortcutAction: String, CaseIterable, Codable, Sendable {
6
+ // Terminal
7
+ case find
8
+ case clearTerminal
9
+
10
+ // Tabs
11
+ case newTab
12
+ case closeTab
13
+ case nextTab
14
+ case prevTab
15
+
16
+ // Splits
17
+ case splitRight
18
+ case splitDown
19
+ case closePane
20
+ case focusNextPane
21
+ case focusPrevPane
22
+
23
+ // Window
24
+ case toggleSidebar
25
+ case toggleInspect
26
+ case toggleFullscreen
27
+
28
+ // Navigation (directional pane focus)
29
+ case focusLeft
30
+ case focusRight
31
+ case focusUp
32
+ case focusDown
33
+
34
+ /// Human-readable display name for settings UI.
35
+ var displayName: String {
36
+ switch self {
37
+ case .find: return "Find"
38
+ case .clearTerminal: return "Clear Terminal"
39
+ case .newTab: return "New Tab"
40
+ case .closeTab: return "Close Tab"
41
+ case .nextTab: return "Next Tab"
42
+ case .prevTab: return "Previous Tab"
43
+ case .splitRight: return "Split Right"
44
+ case .splitDown: return "Split Down"
45
+ case .closePane: return "Close Pane"
46
+ case .focusNextPane: return "Focus Next Pane"
47
+ case .focusPrevPane: return "Focus Previous Pane"
48
+ case .toggleSidebar: return "Toggle Sidebar"
49
+ case .toggleInspect: return "Toggle Inspect"
50
+ case .toggleFullscreen: return "Toggle Fullscreen"
51
+ case .focusLeft: return "Focus Left"
52
+ case .focusRight: return "Focus Right"
53
+ case .focusUp: return "Focus Up"
54
+ case .focusDown: return "Focus Down"
55
+ }
56
+ }
57
+
58
+ /// Category for grouping in the settings UI.
59
+ var category: String {
60
+ switch self {
61
+ case .find, .clearTerminal:
62
+ return "Terminal"
63
+ case .newTab, .closeTab, .nextTab, .prevTab:
64
+ return "Tabs"
65
+ case .splitRight, .splitDown, .closePane, .focusNextPane, .focusPrevPane:
66
+ return "Splits"
67
+ case .toggleSidebar, .toggleInspect, .toggleFullscreen:
68
+ return "Window"
69
+ case .focusLeft, .focusRight, .focusUp, .focusDown:
70
+ return "Navigation"
71
+ }
72
+ }
73
+
74
+ /// Default keyboard shortcut for this action.
75
+ var defaultShortcut: StoredShortcut {
76
+ switch self {
77
+ case .find:
78
+ return StoredShortcut(key: "f", command: true)
79
+ case .clearTerminal:
80
+ return StoredShortcut(key: "k", command: true)
81
+ case .newTab:
82
+ return StoredShortcut(key: "t", command: true)
83
+ case .closeTab:
84
+ return StoredShortcut(key: "w", command: true)
85
+ case .nextTab:
86
+ return StoredShortcut(key: "}", command: true, shift: true)
87
+ case .prevTab:
88
+ return StoredShortcut(key: "{", command: true, shift: true)
89
+ case .splitRight:
90
+ return StoredShortcut(key: "d", command: true)
91
+ case .splitDown:
92
+ return StoredShortcut(key: "d", command: true, shift: true)
93
+ case .closePane:
94
+ return StoredShortcut(key: "w", command: true, shift: true)
95
+ case .focusNextPane:
96
+ return StoredShortcut(key: "]", command: true, option: true)
97
+ case .focusPrevPane:
98
+ return StoredShortcut(key: "[", command: true, option: true)
99
+ case .toggleSidebar:
100
+ return StoredShortcut(key: "s", command: true, control: true)
101
+ case .toggleInspect:
102
+ return StoredShortcut(key: "i", command: true)
103
+ case .toggleFullscreen:
104
+ return StoredShortcut(key: "f", command: true, control: true)
105
+ case .focusLeft:
106
+ return StoredShortcut(key: "left", command: true, option: true)
107
+ case .focusRight:
108
+ return StoredShortcut(key: "right", command: true, option: true)
109
+ case .focusUp:
110
+ return StoredShortcut(key: "up", command: true, option: true)
111
+ case .focusDown:
112
+ return StoredShortcut(key: "down", command: true, option: true)
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,271 @@
1
+ import SwiftUI
2
+
3
+ /// Settings view for customizing keyboard shortcuts.
4
+ /// Adapted from ghostty-org/ghostty macOS keybinding preferences pattern.
5
+ struct ShortcutSettingsView: View {
6
+ @State private var shortcuts: [ShortcutAction: StoredShortcut] = loadAllShortcuts()
7
+ @State private var recordingAction: ShortcutAction?
8
+ @State private var conflicts: [ShortcutAction: [ShortcutAction]] = [:]
9
+
10
+ var body: some View {
11
+ VStack(alignment: .leading, spacing: 0) {
12
+ // Header
13
+ HStack {
14
+ Text("Keyboard Shortcuts")
15
+ .font(.headline)
16
+ Spacer()
17
+ Button("Reset All") {
18
+ resetAll()
19
+ }
20
+ .controlSize(.small)
21
+ }
22
+ .padding(.horizontal, 16)
23
+ .padding(.vertical, 12)
24
+
25
+ Divider()
26
+
27
+ // Shortcut list grouped by category
28
+ ScrollView {
29
+ LazyVStack(alignment: .leading, spacing: 0) {
30
+ let categories = orderedCategories()
31
+ ForEach(categories, id: \.self) { category in
32
+ let actions = ShortcutAction.allCases.filter { $0.category == category }
33
+ Section {
34
+ ForEach(actions, id: \.self) { action in
35
+ ShortcutRow(
36
+ action: action,
37
+ shortcut: shortcuts[action] ?? action.defaultShortcut,
38
+ isRecording: recordingAction == action,
39
+ hasConflict: !(conflicts[action]?.isEmpty ?? true),
40
+ onStartRecording: {
41
+ recordingAction = action
42
+ },
43
+ onRecorded: { newShortcut in
44
+ applyShortcut(newShortcut, for: action)
45
+ },
46
+ onReset: {
47
+ resetShortcut(for: action)
48
+ },
49
+ onCancelRecording: {
50
+ recordingAction = nil
51
+ }
52
+ )
53
+ }
54
+ } header: {
55
+ Text(category)
56
+ .font(.subheadline.weight(.semibold))
57
+ .foregroundStyle(.secondary)
58
+ .padding(.horizontal, 16)
59
+ .padding(.top, 12)
60
+ .padding(.bottom, 4)
61
+ }
62
+ }
63
+ }
64
+ .padding(.bottom, 12)
65
+ }
66
+ }
67
+ .frame(minWidth: 500, minHeight: 400)
68
+ .onAppear { refreshConflicts() }
69
+ }
70
+
71
+ // MARK: - Actions
72
+
73
+ private func applyShortcut(_ shortcut: StoredShortcut, for action: ShortcutAction) {
74
+ StoredShortcut.setShortcut(shortcut, for: action)
75
+ shortcuts[action] = shortcut
76
+ recordingAction = nil
77
+ refreshConflicts()
78
+ }
79
+
80
+ private func resetShortcut(for action: ShortcutAction) {
81
+ StoredShortcut.resetShortcut(for: action)
82
+ shortcuts[action] = action.defaultShortcut
83
+ recordingAction = nil
84
+ refreshConflicts()
85
+ }
86
+
87
+ private func resetAll() {
88
+ StoredShortcut.resetAll()
89
+ shortcuts = Self.loadAllShortcuts()
90
+ recordingAction = nil
91
+ refreshConflicts()
92
+ }
93
+
94
+ private func refreshConflicts() {
95
+ var newConflicts: [ShortcutAction: [ShortcutAction]] = [:]
96
+ for action in ShortcutAction.allCases {
97
+ let found = StoredShortcut.conflicts(for: action)
98
+ if !found.isEmpty {
99
+ newConflicts[action] = found
100
+ }
101
+ }
102
+ conflicts = newConflicts
103
+ }
104
+
105
+ private func orderedCategories() -> [String] {
106
+ // Maintain a stable order
107
+ ["Terminal", "Tabs", "Splits", "Window", "Navigation"]
108
+ }
109
+
110
+ private static func loadAllShortcuts() -> [ShortcutAction: StoredShortcut] {
111
+ var map: [ShortcutAction: StoredShortcut] = [:]
112
+ for action in ShortcutAction.allCases {
113
+ map[action] = StoredShortcut.shortcut(for: action)
114
+ }
115
+ return map
116
+ }
117
+ }
118
+
119
+ // MARK: - Shortcut Row
120
+
121
+ /// A single row in the shortcut list: action name + shortcut badge + record/reset controls.
122
+ struct ShortcutRow: View {
123
+ let action: ShortcutAction
124
+ let shortcut: StoredShortcut
125
+ let isRecording: Bool
126
+ let hasConflict: Bool
127
+ var onStartRecording: () -> Void
128
+ var onRecorded: (StoredShortcut) -> Void
129
+ var onReset: () -> Void
130
+ var onCancelRecording: () -> Void
131
+
132
+ @State private var isHovering = false
133
+
134
+ var body: some View {
135
+ HStack {
136
+ Text(action.displayName)
137
+ .frame(minWidth: 160, alignment: .leading)
138
+
139
+ Spacer()
140
+
141
+ if isRecording {
142
+ ShortcutRecorderField(
143
+ onRecorded: onRecorded,
144
+ onCancel: onCancelRecording
145
+ )
146
+ .frame(width: 140)
147
+ } else {
148
+ // Shortcut display badge
149
+ Text(shortcut.displayString)
150
+ .font(.system(size: 12, design: .monospaced))
151
+ .padding(.horizontal, 8)
152
+ .padding(.vertical, 3)
153
+ .background(
154
+ RoundedRectangle(cornerRadius: 4)
155
+ .fill(hasConflict ? Color.red.opacity(0.15) : Color.primary.opacity(0.06))
156
+ )
157
+ .overlay(
158
+ RoundedRectangle(cornerRadius: 4)
159
+ .stroke(hasConflict ? Color.red.opacity(0.4) : Color.clear, lineWidth: 1)
160
+ )
161
+ .onTapGesture { onStartRecording() }
162
+ }
163
+
164
+ // Reset button (shown on hover or if customized)
165
+ if isHovering && shortcut != action.defaultShortcut {
166
+ Button(action: onReset) {
167
+ Image(systemName: "arrow.counterclockwise")
168
+ .font(.system(size: 10))
169
+ .foregroundStyle(.secondary)
170
+ }
171
+ .buttonStyle(.plain)
172
+ .help("Reset to Default")
173
+ }
174
+
175
+ if hasConflict {
176
+ Image(systemName: "exclamationmark.triangle.fill")
177
+ .font(.system(size: 10))
178
+ .foregroundStyle(.yellow)
179
+ .help("Shortcut conflict detected")
180
+ }
181
+ }
182
+ .padding(.horizontal, 16)
183
+ .padding(.vertical, 5)
184
+ .background(isHovering ? Color.primary.opacity(0.03) : Color.clear)
185
+ .onHover { isHovering = $0 }
186
+ }
187
+ }
188
+
189
+ // MARK: - Shortcut Recorder
190
+
191
+ /// An inline field that captures the next key event as a new shortcut.
192
+ struct ShortcutRecorderField: NSViewRepresentable {
193
+ var onRecorded: (StoredShortcut) -> Void
194
+ var onCancel: () -> Void
195
+
196
+ func makeNSView(context: Context) -> ShortcutRecorderNSView {
197
+ let view = ShortcutRecorderNSView()
198
+ view.onRecorded = onRecorded
199
+ view.onCancel = onCancel
200
+ // Become first responder after a brief delay to ensure the view is in the hierarchy
201
+ DispatchQueue.main.async {
202
+ view.window?.makeFirstResponder(view)
203
+ }
204
+ return view
205
+ }
206
+
207
+ func updateNSView(_ nsView: ShortcutRecorderNSView, context: Context) {
208
+ nsView.onRecorded = onRecorded
209
+ nsView.onCancel = onCancel
210
+ }
211
+ }
212
+
213
+ /// NSView that captures a single key event for shortcut recording.
214
+ @MainActor
215
+ final class ShortcutRecorderNSView: NSView {
216
+ var onRecorded: ((StoredShortcut) -> Void)?
217
+ var onCancel: (() -> Void)?
218
+
219
+ override var acceptsFirstResponder: Bool { true }
220
+
221
+ override init(frame frameRect: NSRect) {
222
+ super.init(frame: frameRect)
223
+ wantsLayer = true
224
+ layer?.cornerRadius = 4
225
+ layer?.borderWidth = 2
226
+ layer?.borderColor = NSColor.controlAccentColor.cgColor
227
+ }
228
+
229
+ required init?(coder: NSCoder) { fatalError() }
230
+
231
+ override func draw(_ dirtyRect: NSRect) {
232
+ super.draw(dirtyRect)
233
+ let str = NSAttributedString(
234
+ string: "Press shortcut...",
235
+ attributes: [
236
+ .foregroundColor: NSColor.secondaryLabelColor,
237
+ .font: NSFont.systemFont(ofSize: 11),
238
+ ]
239
+ )
240
+ let size = str.size()
241
+ let point = NSPoint(
242
+ x: (bounds.width - size.width) / 2,
243
+ y: (bounds.height - size.height) / 2
244
+ )
245
+ str.draw(at: point)
246
+ }
247
+
248
+ override func keyDown(with event: NSEvent) {
249
+ // Escape cancels recording
250
+ if event.keyCode == 53 {
251
+ onCancel?()
252
+ return
253
+ }
254
+
255
+ if let shortcut = StoredShortcut.from(event: event) {
256
+ onRecorded?(shortcut)
257
+ }
258
+ // If no valid shortcut (e.g. bare key press), ignore and keep recording
259
+ }
260
+
261
+ override func becomeFirstResponder() -> Bool {
262
+ layer?.borderColor = NSColor.controlAccentColor.cgColor
263
+ return true
264
+ }
265
+
266
+ override func resignFirstResponder() -> Bool {
267
+ layer?.borderColor = NSColor.separatorColor.cgColor
268
+ onCancel?()
269
+ return true
270
+ }
271
+ }