@wangyaoshen/remux 0.3.9-dev.390cb29 → 0.3.10-dev.19fb76c

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.
@@ -29,6 +29,11 @@ let package = Package(
29
29
  .linkedLibrary("z"),
30
30
  ]
31
31
  ),
32
+ .testTarget(
33
+ name: "RemuxTests",
34
+ dependencies: ["Remux"],
35
+ path: "Tests/RemuxTests"
36
+ ),
32
37
  .binaryTarget(
33
38
  name: "GhosttyKit",
34
39
  path: "../../vendor/ghostty/macos/GhosttyKit.xcframework"
@@ -0,0 +1,114 @@
1
+ import Foundation
2
+
3
+ enum WindowCommandAction: String, Sendable {
4
+ case splitRight
5
+ case splitDown
6
+ case closePane
7
+ case focusNextPane
8
+ case focusPreviousPane
9
+ case newBrowserPane
10
+ case newMarkdownPane
11
+ case commandPalette
12
+ case copyMode
13
+ case findInTerminal
14
+ }
15
+
16
+ struct WindowCommand: Equatable, Sendable {
17
+ private static let actionKey = "action"
18
+ private static let targetWindowNumberKey = "targetWindowNumber"
19
+
20
+ let action: WindowCommandAction
21
+ let targetWindowNumber: Int
22
+
23
+ init(action: WindowCommandAction, targetWindowNumber: Int) {
24
+ self.action = action
25
+ self.targetWindowNumber = targetWindowNumber
26
+ }
27
+
28
+ init?(notification: Notification) {
29
+ guard notification.name == .remuxWindowCommand,
30
+ let rawAction = notification.userInfo?[Self.actionKey] as? String,
31
+ let action = WindowCommandAction(rawValue: rawAction),
32
+ let targetWindowNumber = notification.userInfo?[Self.targetWindowNumberKey] as? Int else {
33
+ return nil
34
+ }
35
+ self.init(action: action, targetWindowNumber: targetWindowNumber)
36
+ }
37
+
38
+ func matches(windowNumber: Int?) -> Bool {
39
+ guard let windowNumber else { return false }
40
+ return targetWindowNumber == windowNumber
41
+ }
42
+
43
+ func post() {
44
+ NotificationCenter.default.post(
45
+ name: .remuxWindowCommand,
46
+ object: nil,
47
+ userInfo: [
48
+ Self.actionKey: action.rawValue,
49
+ Self.targetWindowNumberKey: targetWindowNumber,
50
+ ]
51
+ )
52
+ }
53
+ }
54
+
55
+ enum TerminalCommandAction: String, Sendable {
56
+ case showSearch
57
+ }
58
+
59
+ struct TerminalCommand: Equatable, Sendable {
60
+ private static let actionKey = "action"
61
+ private static let targetWindowNumberKey = "targetWindowNumber"
62
+ private static let leafIDKey = "leafID"
63
+
64
+ let action: TerminalCommandAction
65
+ let targetWindowNumber: Int
66
+ let leafID: UUID?
67
+
68
+ init(action: TerminalCommandAction, targetWindowNumber: Int, leafID: UUID?) {
69
+ self.action = action
70
+ self.targetWindowNumber = targetWindowNumber
71
+ self.leafID = leafID
72
+ }
73
+
74
+ init?(notification: Notification) {
75
+ guard notification.name == .remuxTerminalCommand,
76
+ let rawAction = notification.userInfo?[Self.actionKey] as? String,
77
+ let action = TerminalCommandAction(rawValue: rawAction),
78
+ let targetWindowNumber = notification.userInfo?[Self.targetWindowNumberKey] as? Int else {
79
+ return nil
80
+ }
81
+ self.init(
82
+ action: action,
83
+ targetWindowNumber: targetWindowNumber,
84
+ leafID: notification.userInfo?[Self.leafIDKey] as? UUID
85
+ )
86
+ }
87
+
88
+ func matches(windowNumber: Int?, leafID: UUID?) -> Bool {
89
+ guard let windowNumber, targetWindowNumber == windowNumber else {
90
+ return false
91
+ }
92
+ return self.leafID == leafID
93
+ }
94
+
95
+ func post() {
96
+ var userInfo: [String: Any] = [
97
+ Self.actionKey: action.rawValue,
98
+ Self.targetWindowNumberKey: targetWindowNumber,
99
+ ]
100
+ if let leafID {
101
+ userInfo[Self.leafIDKey] = leafID
102
+ }
103
+ NotificationCenter.default.post(
104
+ name: .remuxTerminalCommand,
105
+ object: nil,
106
+ userInfo: userInfo
107
+ )
108
+ }
109
+ }
110
+
111
+ extension Notification.Name {
112
+ static let remuxWindowCommand = Notification.Name("remuxWindowCommand")
113
+ static let remuxTerminalCommand = Notification.Name("remuxTerminalCommand")
114
+ }
@@ -38,6 +38,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
38
38
  finderIntegration = FinderIntegration(state: state)
39
39
  finderIntegration?.registerServices()
40
40
 
41
+ NotificationCenter.default.addObserver(
42
+ self,
43
+ selector: #selector(handleTerminalDataNotification(_:)),
44
+ name: .remuxTerminalData,
45
+ object: nil
46
+ )
47
+
41
48
  // Start autosave
42
49
  SessionPersistence.shared.startAutosave { [weak self] in
43
50
  guard let self else {
@@ -60,6 +67,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
60
67
  }
61
68
 
62
69
  func applicationWillTerminate(_ notification: Notification) {
70
+ NotificationCenter.default.removeObserver(self)
71
+
63
72
  // Stop socket controller
64
73
  socketController?.stop()
65
74
 
@@ -254,4 +263,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
254
263
  }
255
264
  detachedWindows.removeAll()
256
265
  }
266
+
267
+ @objc private func handleTerminalDataNotification(_ notification: Notification) {
268
+ guard let data = notification.userInfo?["data"] as? Data else { return }
269
+
270
+ let oscNotifications = OSCNotificationParser.parse(data)
271
+ guard !oscNotifications.isEmpty else { return }
272
+
273
+ let sessionName = state.currentSession.isEmpty ? "Remux" : state.currentSession
274
+ for oscNotification in oscNotifications {
275
+ notificationManager?.handleNotification(.init(
276
+ title: oscNotification.title,
277
+ body: oscNotification.body,
278
+ tabIndex: state.activeTabIndex,
279
+ sessionName: sessionName
280
+ ))
281
+ }
282
+ }
257
283
  }
@@ -6,6 +6,7 @@ import RemuxKit
6
6
  struct MainContentView: View {
7
7
  @Environment(RemuxState.self) private var state
8
8
  @State private var showInspect = false
9
+ @State private var windowNumber: Int?
9
10
 
10
11
  // Split pane state
11
12
  @State private var splitRoot: SplitNode = .leaf(SplitNode.LeafData(tabIndex: 0))
@@ -88,6 +89,11 @@ struct MainContentView: View {
88
89
  isPresented: $showCommandPalette,
89
90
  commands: buildCommandList()
90
91
  )
92
+
93
+ WindowObserver { window in
94
+ windowNumber = window?.windowNumber
95
+ }
96
+ .frame(width: 0, height: 0)
91
97
  }
92
98
  .onAppear {
93
99
  // Set initial focused leaf
@@ -95,6 +101,13 @@ struct MainContentView: View {
95
101
  focusedLeafID = splitRoot.allLeaves.first?.id
96
102
  }
97
103
  }
104
+ .onReceive(NotificationCenter.default.publisher(for: .remuxWindowCommand)) { notification in
105
+ guard let command = WindowCommand(notification: notification),
106
+ command.matches(windowNumber: windowNumber) else {
107
+ return
108
+ }
109
+ handleWindowCommand(command.action)
110
+ }
98
111
  }
99
112
 
100
113
  @ViewBuilder
@@ -296,6 +309,49 @@ struct MainContentView: View {
296
309
  }
297
310
  }
298
311
 
312
+ private func handleWindowCommand(_ action: WindowCommandAction) {
313
+ switch action {
314
+ case .splitRight:
315
+ splitPane(orientation: .horizontal)
316
+ case .splitDown:
317
+ splitPane(orientation: .vertical)
318
+ case .closePane:
319
+ closePane()
320
+ case .focusNextPane:
321
+ focusNextPane()
322
+ case .focusPreviousPane:
323
+ focusPreviousPane()
324
+ case .newBrowserPane:
325
+ addBrowserPane()
326
+ case .newMarkdownPane:
327
+ addMarkdownPane()
328
+ case .commandPalette:
329
+ toggleCommandPalette()
330
+ case .copyMode:
331
+ toggleCopyMode()
332
+ case .findInTerminal:
333
+ guard let windowNumber,
334
+ let leafID = activeTerminalLeafID else {
335
+ return
336
+ }
337
+ TerminalCommand(
338
+ action: .showSearch,
339
+ targetWindowNumber: windowNumber,
340
+ leafID: leafID
341
+ ).post()
342
+ }
343
+ }
344
+
345
+ private var activeTerminalLeafID: UUID? {
346
+ if let focusedLeafID,
347
+ let leaf = splitRoot.findLeaf(id: focusedLeafID),
348
+ leaf.panelType == .terminal {
349
+ return focusedLeafID
350
+ }
351
+
352
+ return splitRoot.allLeaves.first(where: { $0.panelType == .terminal })?.id
353
+ }
354
+
299
355
  // MARK: - SSH upload
300
356
 
301
357
  private func handleSSHUpload() {
@@ -6,20 +6,6 @@ import RemuxKit
6
6
  final class MenuBarManager {
7
7
  private weak var state: RemuxState?
8
8
 
9
- /// Callback for split operations, wired from AppDelegate.
10
- var onSplitRight: (() -> Void)?
11
- var onSplitDown: (() -> Void)?
12
- var onClosePane: (() -> Void)?
13
- var onFocusNextPane: (() -> Void)?
14
- var onFocusPreviousPane: (() -> Void)?
15
-
16
- /// Callbacks for new features.
17
- var onNewBrowserPane: (() -> Void)?
18
- var onNewMarkdownPane: (() -> Void)?
19
- var onCommandPalette: (() -> Void)?
20
- var onCopyMode: (() -> Void)?
21
- var onDetachPane: (() -> Void)?
22
-
23
9
  init(state: RemuxState) {
24
10
  self.state = state
25
11
  setupMenuBar()
@@ -211,52 +197,53 @@ final class MenuBarManager {
211
197
  }
212
198
 
213
199
  @objc private func findInTerminal(_ sender: Any?) {
214
- // Search is triggered through the GhosttyNativeView's performKeyEquivalent
215
- // which intercepts Cmd+F. The menu item provides discoverability.
200
+ dispatch(.findInTerminal)
216
201
  }
217
202
 
218
203
  // MARK: - Split pane actions
219
204
 
220
205
  @objc private func splitRightAction(_ sender: Any?) {
221
- onSplitRight?()
206
+ dispatch(.splitRight)
222
207
  }
223
208
 
224
209
  @objc private func splitDownAction(_ sender: Any?) {
225
- onSplitDown?()
210
+ dispatch(.splitDown)
226
211
  }
227
212
 
228
213
  @objc private func closePaneAction(_ sender: Any?) {
229
- onClosePane?()
214
+ dispatch(.closePane)
230
215
  }
231
216
 
232
217
  @objc private func focusNextAction(_ sender: Any?) {
233
- onFocusNextPane?()
218
+ dispatch(.focusNextPane)
234
219
  }
235
220
 
236
221
  @objc private func focusPrevAction(_ sender: Any?) {
237
- onFocusPreviousPane?()
222
+ dispatch(.focusPreviousPane)
238
223
  }
239
224
 
240
225
  // MARK: - New panel actions
241
226
 
242
227
  @objc private func newBrowserPaneAction(_ sender: Any?) {
243
- onNewBrowserPane?()
228
+ dispatch(.newBrowserPane)
244
229
  }
245
230
 
246
231
  @objc private func newMarkdownPaneAction(_ sender: Any?) {
247
- onNewMarkdownPane?()
232
+ dispatch(.newMarkdownPane)
248
233
  }
249
234
 
250
235
  @objc private func commandPaletteAction(_ sender: Any?) {
251
- onCommandPalette?()
236
+ dispatch(.commandPalette)
252
237
  }
253
238
 
254
239
  @objc private func copyModeAction(_ sender: Any?) {
255
- onCopyMode?()
240
+ dispatch(.copyMode)
256
241
  }
257
242
 
258
243
  @objc private func detachPaneAction(_ sender: Any?) {
259
- onDetachPane?()
244
+ if let appDelegate = NSApp.delegate as? AppDelegate {
245
+ appDelegate.detachPaneToWindow()
246
+ }
260
247
  }
261
248
 
262
249
  @objc private func openInEditor(_ sender: Any?) {
@@ -272,4 +259,9 @@ final class MenuBarManager {
272
259
 
273
260
  FinderIntegration.openInExternalEditor(path: cwd, editor: editor)
274
261
  }
262
+
263
+ private func dispatch(_ action: WindowCommandAction) {
264
+ guard let targetWindowNumber = NSApp.keyWindow?.windowNumber else { return }
265
+ WindowCommand(action: action, targetWindowNumber: targetWindowNumber).post()
266
+ }
275
267
  }
@@ -84,7 +84,7 @@ final class NotificationManager: NSObject {
84
84
  }
85
85
  }
86
86
 
87
- extension NotificationManager: @preconcurrency UNUserNotificationCenterDelegate {
87
+ extension NotificationManager: UNUserNotificationCenterDelegate {
88
88
  nonisolated func userNotificationCenter(
89
89
  _ center: UNUserNotificationCenter,
90
90
  didReceive response: UNNotificationResponse,
@@ -102,21 +102,44 @@ extension NotificationManager: @preconcurrency UNUserNotificationCenterDelegate
102
102
  willPresent notification: UNNotification,
103
103
  withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
104
104
  ) {
105
- let options: UNNotificationPresentationOptions = NSApp.isActive ? [] : [.banner, .sound]
105
+ let options: UNNotificationPresentationOptions
106
+ if Thread.isMainThread {
107
+ options = MainActor.assumeIsolated {
108
+ currentPresentationOptions()
109
+ }
110
+ } else {
111
+ options = DispatchQueue.main.sync {
112
+ MainActor.assumeIsolated {
113
+ currentPresentationOptions()
114
+ }
115
+ }
116
+ }
106
117
  completionHandler(options)
107
118
  }
119
+
120
+ @MainActor
121
+ private func currentPresentationOptions() -> UNNotificationPresentationOptions {
122
+ NSApp.isActive ? [] : [.banner, .sound]
123
+ }
108
124
  }
109
125
 
110
126
  // MARK: - OSC Notification Parser
111
127
 
112
128
  /// Parses OSC 9/99/777 notification sequences from PTY data.
113
129
  /// Ref: cmux OSC notification detection approach
130
+ struct OSCParsedNotification: Equatable {
131
+ let title: String
132
+ let body: String
133
+ }
134
+
114
135
  struct OSCNotificationParser {
136
+ private static let defaultTitle = "Terminal Notification"
137
+
115
138
  /// Parse PTY data for notification sequences.
116
139
  /// Returns extracted notifications (if any).
117
- static func parse(_ data: Data) -> [String] {
140
+ static func parse(_ data: Data) -> [OSCParsedNotification] {
118
141
  guard let text = String(data: data, encoding: .utf8) else { return [] }
119
- var notifications: [String] = []
142
+ var notifications: [OSCParsedNotification] = []
120
143
 
121
144
  // OSC 9: iTerm2 notification — ESC ] 9 ; <message> BEL/ST
122
145
  let osc9Pattern = "\u{1b}]9;([^\u{07}\u{1b}]+)[\u{07}]"
@@ -124,7 +147,24 @@ struct OSCNotificationParser {
124
147
  let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
125
148
  for match in matches {
126
149
  if let range = Range(match.range(at: 1), in: text) {
127
- notifications.append(String(text[range]))
150
+ notifications.append(OSCParsedNotification(
151
+ title: defaultTitle,
152
+ body: String(text[range])
153
+ ))
154
+ }
155
+ }
156
+ }
157
+
158
+ // OSC 99: kitty notification — ESC ] 99 ; <message> BEL
159
+ let osc99Pattern = "\u{1b}]99;([^\u{07}\u{1b}]+)[\u{07}]"
160
+ if let regex = try? NSRegularExpression(pattern: osc99Pattern) {
161
+ let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
162
+ for match in matches {
163
+ if let range = Range(match.range(at: 1), in: text) {
164
+ notifications.append(OSCParsedNotification(
165
+ title: defaultTitle,
166
+ body: String(text[range])
167
+ ))
128
168
  }
129
169
  }
130
170
  }
@@ -134,8 +174,13 @@ struct OSCNotificationParser {
134
174
  if let regex = try? NSRegularExpression(pattern: osc777Pattern) {
135
175
  let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
136
176
  for match in matches {
137
- if let range = Range(match.range(at: 2), in: text) {
138
- notifications.append(String(text[range]))
177
+ if let titleRange = Range(match.range(at: 1), in: text),
178
+ let bodyRange = Range(match.range(at: 2), in: text) {
179
+ let title = String(text[titleRange]).isEmpty ? defaultTitle : String(text[titleRange])
180
+ notifications.append(OSCParsedNotification(
181
+ title: title,
182
+ body: String(text[bodyRange])
183
+ ))
139
184
  }
140
185
  }
141
186
  }
@@ -64,7 +64,7 @@ struct SplitLeafView: View {
64
64
  private var panelContent: some View {
65
65
  switch data.panelType {
66
66
  case .terminal:
67
- TerminalContainerView()
67
+ TerminalContainerView(leafID: data.id)
68
68
 
69
69
  case .browser:
70
70
  let panel = getBrowserPanel()
@@ -243,15 +243,21 @@ final class GhosttyNativeView: NSView, @preconcurrency NSTextInputClient {
243
243
 
244
244
  /// Navigate to the next search match.
245
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))
246
+ performBindingAction("search_forward")
249
247
  }
250
248
 
251
249
  /// Navigate to the previous search match.
252
250
  func searchBackward() {
251
+ performBindingAction("search_backward")
252
+ }
253
+
254
+ /// Update the active search query for the surface.
255
+ func updateSearch(_ query: String) {
256
+ performBindingAction("search:\(query)")
257
+ }
258
+
259
+ private func performBindingAction(_ action: String) {
253
260
  guard let surface else { return }
254
- let action = "search_backward"
255
261
  _ = ghostty_surface_binding_action(surface, action, UInt(action.utf8.count))
256
262
  }
257
263
 
@@ -6,9 +6,12 @@ import RemuxKit
6
6
  /// Remote PTY -> WebSocket -> RemuxState -> relay.writeToTerminal() -> socket -> nc stdout -> ghostty Metal render
7
7
  /// User types -> ghostty -> nc stdin -> socket -> relay.onDataFromClient -> state.sendTerminalData() -> WebSocket -> remote PTY
8
8
  struct TerminalContainerView: View {
9
+ let leafID: UUID?
10
+
9
11
  @Environment(RemuxState.self) private var state
10
12
  @State private var terminalView: GhosttyNativeView?
11
13
  @State private var relay = TerminalRelay()
14
+ @State private var windowNumber: Int?
12
15
 
13
16
  // Search state
14
17
  @State private var searchVisible = false
@@ -16,6 +19,10 @@ struct TerminalContainerView: View {
16
19
  @State private var searchTotal = 0
17
20
  @State private var searchSelected = -1
18
21
 
22
+ init(leafID: UUID? = nil) {
23
+ self.leafID = leafID
24
+ }
25
+
19
26
  var body: some View {
20
27
  ZStack(alignment: .topTrailing) {
21
28
  GhosttyNativeTerminalView(
@@ -55,15 +62,11 @@ struct TerminalContainerView: View {
55
62
  selectedMatch: $searchSelected,
56
63
  onSearch: { query in
57
64
  guard let view = terminalView else { return }
65
+ view.updateSearch(query)
58
66
  if query.isEmpty {
59
67
  searchTotal = 0
60
68
  searchSelected = -1
61
69
  }
62
- // Ghostty search is triggered by typing into the search bar,
63
- // which sends the text via binding_action
64
- let action = "search:\(query)"
65
- view.sendText("") // ensure surface has focus
66
- _ = action // search is driven by the overlay text field
67
70
  },
68
71
  onNext: {
69
72
  terminalView?.searchForward()
@@ -74,14 +77,37 @@ struct TerminalContainerView: View {
74
77
  onClose: {
75
78
  searchTotal = 0
76
79
  searchSelected = -1
80
+ terminalView?.updateSearch("")
77
81
  }
78
82
  )
83
+
84
+ WindowObserver { window in
85
+ windowNumber = window?.windowNumber
86
+ }
87
+ .frame(width: 0, height: 0)
79
88
  }
80
89
  .onReceive(NotificationCenter.default.publisher(for: .remuxTerminalData)) { notification in
81
90
  if let data = notification.userInfo?["data"] as? Data {
82
91
  relay.writeToTerminal(data)
83
92
  }
84
93
  }
94
+ .onReceive(NotificationCenter.default.publisher(for: .remuxWindowCommand)) { notification in
95
+ guard leafID == nil,
96
+ let command = WindowCommand(notification: notification),
97
+ command.action == .findInTerminal,
98
+ command.matches(windowNumber: windowNumber) else {
99
+ return
100
+ }
101
+ openSearch()
102
+ }
103
+ .onReceive(NotificationCenter.default.publisher(for: .remuxTerminalCommand)) { notification in
104
+ guard let command = TerminalCommand(notification: notification),
105
+ command.action == .showSearch,
106
+ command.matches(windowNumber: windowNumber, leafID: leafID) else {
107
+ return
108
+ }
109
+ openSearch()
110
+ }
85
111
  .onAppear {
86
112
  relay.onDataFromClient = { data in
87
113
  state.sendTerminalData(data)
@@ -92,4 +118,8 @@ struct TerminalContainerView: View {
92
118
  relay.stop()
93
119
  }
94
120
  }
121
+
122
+ private func openSearch() {
123
+ searchVisible = true
124
+ }
95
125
  }
@@ -0,0 +1,38 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ struct WindowObserver: NSViewRepresentable {
5
+ var onWindowChange: (NSWindow?) -> Void
6
+
7
+ func makeNSView(context: Context) -> WindowObserverView {
8
+ let view = WindowObserverView()
9
+ view.onWindowChange = onWindowChange
10
+ return view
11
+ }
12
+
13
+ func updateNSView(_ nsView: WindowObserverView, context: Context) {
14
+ nsView.onWindowChange = onWindowChange
15
+ nsView.reportWindow()
16
+ }
17
+ }
18
+
19
+ final class WindowObserverView: NSView {
20
+ var onWindowChange: ((NSWindow?) -> Void)?
21
+
22
+ override func viewDidMoveToWindow() {
23
+ super.viewDidMoveToWindow()
24
+ reportWindow()
25
+ }
26
+
27
+ override func viewDidMoveToSuperview() {
28
+ super.viewDidMoveToSuperview()
29
+ reportWindow()
30
+ }
31
+
32
+ func reportWindow() {
33
+ DispatchQueue.main.async { [weak self] in
34
+ guard let self else { return }
35
+ self.onWindowChange?(self.window)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import Remux
4
+
5
+ @Suite("AppCommand")
6
+ struct AppCommandTests {
7
+
8
+ @Test("Window command only matches its target window")
9
+ func windowCommandMatchesTargetWindow() {
10
+ let command = WindowCommand(action: .splitRight, targetWindowNumber: 17)
11
+
12
+ #expect(command.matches(windowNumber: 17))
13
+ #expect(!command.matches(windowNumber: 18))
14
+ #expect(!command.matches(windowNumber: nil))
15
+ }
16
+
17
+ @Test("Terminal command matches both window and leaf")
18
+ func terminalCommandMatchesWindowAndLeaf() {
19
+ let leafID = UUID()
20
+ let command = TerminalCommand(
21
+ action: .showSearch,
22
+ targetWindowNumber: 42,
23
+ leafID: leafID
24
+ )
25
+
26
+ #expect(command.matches(windowNumber: 42, leafID: leafID))
27
+ #expect(!command.matches(windowNumber: 43, leafID: leafID))
28
+ #expect(!command.matches(windowNumber: 42, leafID: UUID()))
29
+ }
30
+ }
@@ -0,0 +1,28 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import Remux
4
+
5
+ @Suite("OSCNotificationParser")
6
+ struct OSCNotificationParserTests {
7
+
8
+ @Test("Parses OSC 9 and OSC 99 notifications")
9
+ func parsesOSC9And99() {
10
+ let payload = "\u{1b}]9;Build complete\u{07}\u{1b}]99;Tests passed\u{07}"
11
+ let parsed = OSCNotificationParser.parse(Data(payload.utf8))
12
+
13
+ #expect(parsed == [
14
+ OSCParsedNotification(title: "Terminal Notification", body: "Build complete"),
15
+ OSCParsedNotification(title: "Terminal Notification", body: "Tests passed"),
16
+ ])
17
+ }
18
+
19
+ @Test("Parses OSC 777 notifications with explicit title")
20
+ func parsesOSC777() {
21
+ let payload = "\u{1b}]777;notify;Deploy;Finished successfully\u{07}"
22
+ let parsed = OSCNotificationParser.parse(Data(payload.utf8))
23
+
24
+ #expect(parsed == [
25
+ OSCParsedNotification(title: "Deploy", body: "Finished successfully"),
26
+ ])
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangyaoshen/remux",
3
- "version": "0.3.9-dev.390cb29",
3
+ "version": "0.3.10-dev.19fb76c",
4
4
  "description": "Remote terminal workspace — powered by ghostty-web",
5
5
  "license": "MIT",
6
6
  "repository": {