native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #143 from supabitapp/ghostty-1-3-0-upgrade-scout

Add Ghostty command palette entries and search shortcut syncing

authored by

khoi and committed by
GitHub
9a87100e d73d6cd7

+310 -16
+5 -1
supacode/App/ContentView.swift
··· 14 14 @Bindable var repositoriesStore: StoreOf<RepositoriesFeature> 15 15 let terminalManager: WorktreeTerminalManager 16 16 @Environment(\.scenePhase) private var scenePhase 17 + @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 17 18 @State private var leftSidebarVisibility: NavigationSplitViewVisibility = .all 18 19 19 20 init(store: StoreOf<AppFeature>, terminalManager: WorktreeTerminalManager) { ··· 90 91 .overlay { 91 92 CommandPaletteOverlayView( 92 93 store: store.scope(state: \.commandPalette, action: \.commandPalette), 93 - items: store.commandPaletteItems 94 + items: CommandPaletteFeature.commandPaletteItems( 95 + from: store.repositories, 96 + ghosttyCommands: ghosttyShortcuts.commandPaletteEntries 97 + ) 94 98 ) 95 99 } 96 100 .background(WindowTabbingDisabler())
+1
supacode/Clients/Terminal/TerminalClient.swift
··· 13 13 case stopRunScript(Worktree) 14 14 case closeFocusedTab(Worktree) 15 15 case closeFocusedSurface(Worktree) 16 + case performBindingAction(Worktree, action: String) 16 17 case startSearch(Worktree) 17 18 case searchSelection(Worktree) 18 19 case navigateSearchNext(Worktree)
+15 -5
supacode/Commands/TerminalCommands.swift
··· 37 37 Button("Find...") { 38 38 startSearchAction?() 39 39 } 40 - .keyboardShortcut("f", modifiers: .command) 40 + .modifier( 41 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "start_search")) 42 + ) 41 43 .disabled(startSearchAction == nil) 42 44 43 45 Button("Find Next") { 44 46 navigateSearchNextAction?() 45 47 } 46 - .keyboardShortcut("g", modifiers: .command) 48 + .modifier( 49 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:next")) 50 + ) 47 51 .disabled(navigateSearchNextAction == nil) 48 52 49 53 Button("Find Previous") { 50 54 navigateSearchPreviousAction?() 51 55 } 52 - .keyboardShortcut("g", modifiers: [.command, .shift]) 56 + .modifier( 57 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:previous")) 58 + ) 53 59 .disabled(navigateSearchPreviousAction == nil) 54 60 55 61 Divider() ··· 57 63 Button("Hide Find Bar") { 58 64 endSearchAction?() 59 65 } 60 - .keyboardShortcut("f", modifiers: [.command, .shift]) 66 + .modifier( 67 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search")) 68 + ) 61 69 .disabled(endSearchAction == nil) 62 70 63 71 Divider() ··· 65 73 Button("Use Selection for Find") { 66 74 searchSelectionAction?() 67 75 } 68 - .keyboardShortcut("e", modifiers: .command) 76 + .modifier( 77 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection")) 78 + ) 69 79 .disabled(searchSelectionAction == nil) 70 80 } 71 81 }
+8 -3
supacode/Features/App/Reducer/AppFeature.swift
··· 24 24 var notificationIndicatorCount: Int = 0 25 25 var lastKnownSystemNotificationsEnabled: Bool 26 26 @Presents var alert: AlertState<Alert>? 27 - var commandPaletteItems: [CommandPaletteItem] { 28 - CommandPaletteFeature.commandPaletteItems(from: repositories) 29 - } 30 27 31 28 init( 32 29 repositories: RepositoriesFeature.State = .init(), ··· 622 619 623 620 case .commandPalette(.delegate(.refreshWorktrees)): 624 621 return .send(.repositories(.refreshWorktrees)) 622 + 623 + case .commandPalette(.delegate(.ghosttyCommand(let action))): 624 + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 625 + return .none 626 + } 627 + return .run { _ in 628 + await terminalClient.send(.performBindingAction(worktree, action: action)) 629 + } 625 630 626 631 case .commandPalette(.delegate(.openPullRequest(let worktreeID))): 627 632 return .send(.repositories(.pullRequestAction(worktreeID, .openOnGithub)))
+7
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 30 30 case removeWorktree(Worktree.ID, Repository.ID) 31 31 case archiveWorktree(Worktree.ID, Repository.ID) 32 32 case refreshWorktrees 33 + case ghosttyCommand(String) 33 34 case openPullRequest(Worktree.ID) 34 35 case markPullRequestReady(Worktree.ID) 35 36 case mergePullRequest(Worktree.ID) ··· 47 48 switch kind { 48 49 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 49 50 return true 51 + case .ghosttyCommand: 52 + return false 50 53 case .openPullRequest, 51 54 .markPullRequestReady, 52 55 .mergePullRequest, ··· 69 72 switch kind { 70 73 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 71 74 return true 75 + case .ghosttyCommand: 76 + return false 72 77 case .openPullRequest, 73 78 .markPullRequestReady, 74 79 .mergePullRequest, ··· 100 105 return AppShortcuts.newWorktree 101 106 case .refreshWorktrees: 102 107 return AppShortcuts.refreshWorktrees 108 + case .ghosttyCommand: 109 + return nil 103 110 case .openPullRequest: 104 111 return AppShortcuts.openPullRequest 105 112 case .markPullRequestReady,
+28 -2
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 39 39 case removeWorktree(Worktree.ID, Repository.ID) 40 40 case archiveWorktree(Worktree.ID, Repository.ID) 41 41 case refreshWorktrees 42 + case ghosttyCommand(String) 42 43 case openPullRequest(Worktree.ID) 43 44 case markPullRequestReady(Worktree.ID) 44 45 case mergePullRequest(Worktree.ID) ··· 160 161 } 161 162 162 163 static func commandPaletteItems( 163 - from repositories: RepositoriesFeature.State 164 + from repositories: RepositoriesFeature.State, 165 + ghosttyCommands: [GhosttyCommand] = [] 164 166 ) -> [CommandPaletteItem] { 165 167 var items: [CommandPaletteItem] = [ 166 168 CommandPaletteItem( ··· 194 196 kind: .refreshWorktrees 195 197 ), 196 198 ] 199 + if repositories.selectedWorktreeID != nil { 200 + items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 201 + } 197 202 if let selectedWorktreeID = repositories.selectedWorktreeID, 198 203 let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), 199 204 let pullRequest = repositories.worktreeInfo(for: selectedWorktreeID)?.pullRequest, ··· 408 413 #endif 409 414 410 415 private enum CommandPaletteItemID { 416 + static let ghosttyPrefix = "ghostty." 411 417 static let globalCheckForUpdates = "global.check-for-updates" 412 418 static let globalOpenSettings = "global.open-settings" 413 419 static let globalOpenRepository = "global.open-repository" ··· 428 434 "worktree.\(worktreeID).select" 429 435 } 430 436 437 + static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem.ID { 438 + "\(ghosttyPrefix)\(command.action)|\(command.title)" 439 + } 440 + 431 441 static func pullRequestIDs(repositoryID: Repository.ID) -> [CommandPaletteItem.ID] { 432 442 [ 433 443 pullRequestOpen(repositoryID), ··· 524 534 return .archiveWorktree(worktreeID, repositoryID) 525 535 case .refreshWorktrees: 526 536 return .refreshWorktrees 537 + case .ghosttyCommand(let action): 538 + return .ghosttyCommand(action) 527 539 case .openPullRequest, 528 540 .markPullRequestReady, 529 541 .mergePullRequest, ··· 567 579 .openRepository, 568 580 .removeWorktree, 569 581 .archiveWorktree, 570 - .refreshWorktrees: 582 + .refreshWorktrees, 583 + .ghosttyCommand: 571 584 return nil 572 585 #if DEBUG 573 586 case .debugTestToast: 574 587 return nil 575 588 #endif 589 + } 590 + } 591 + 592 + private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { 593 + commands.map { command in 594 + let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) 595 + return CommandPaletteItem( 596 + id: CommandPaletteItemID.ghosttyCommand(command), 597 + title: command.title, 598 + subtitle: subtitle.isEmpty ? nil : subtitle, 599 + kind: .ghosttyCommand(command.action), 600 + priorityTier: CommandPaletteItem.defaultPriorityTier + 100 601 + ) 576 602 } 577 603 } 578 604
+6
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 341 341 private var badge: String? { 342 342 switch row.kind { 343 343 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 344 + .ghosttyCommand, 344 345 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 345 346 .copyCiFailureLogs, 346 347 .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: ··· 368 369 return "plus" 369 370 case .refreshWorktrees: 370 371 return "arrow.clockwise" 372 + case .ghosttyCommand: 373 + return "terminal" 371 374 case .openPullRequest: 372 375 return "arrow.up.right.square" 373 376 case .markPullRequestReady: ··· 400 403 private var emphasis: Bool { 401 404 switch row.kind { 402 405 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 406 + .ghosttyCommand, 403 407 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 404 408 .copyCiFailureLogs, 405 409 .rerunFailedJobs, .openFailingCheckDetails: ··· 492 496 base = "New Worktree" 493 497 case .refreshWorktrees: 494 498 base = "Refresh Worktrees" 499 + case .ghosttyCommand: 500 + base = row.title 495 501 case .removeWorktree: 496 502 base = "Remove \(row.title)" 497 503 case .archiveWorktree:
+13
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 22 22 if handleTabCommand(command) { 23 23 return 24 24 } 25 + if handleBindingActionCommand(command) { 26 + return 27 + } 25 28 if handleSearchCommand(command) { 26 29 return 27 30 } ··· 65 68 state(for: worktree).navigateSearchOnFocusedSurface(.previous) 66 69 case .endSearch(let worktree): 67 70 state(for: worktree).performBindingActionOnFocusedSurface("end_search") 71 + default: 72 + return false 73 + } 74 + return true 75 + } 76 + 77 + private func handleBindingActionCommand(_ command: TerminalClient.Command) -> Bool { 78 + switch command { 79 + case .performBindingAction(let worktree, let action): 80 + state(for: worktree).performBindingActionOnFocusedSurface(action) 68 81 default: 69 82 return false 70 83 }
+22 -5
supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift
··· 4 4 struct GhosttySurfaceSearchOverlay: View { 5 5 let surfaceView: GhosttySurfaceView 6 6 @Bindable var state: GhosttySurfaceState 7 + @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 7 8 8 9 @State private var searchText: String 9 10 @State private var corner: GhosttySearchCorner = .topRight ··· 48 49 Button { 49 50 navigateSearch(.next) 50 51 } label: { 51 - SearchButtonLabel(title: "Next", shortcut: "⌘G", systemImage: "chevron.up") 52 + SearchButtonLabel( 53 + title: "Next", 54 + shortcut: ghosttyShortcuts.display(for: "search:next"), 55 + systemImage: "chevron.up" 56 + ) 52 57 } 53 58 .buttonStyle(GhosttySearchButtonStyle()) 54 59 55 60 Button { 56 61 navigateSearch(.previous) 57 62 } label: { 58 - SearchButtonLabel(title: "Previous", shortcut: "⇧⌘G", systemImage: "chevron.down") 63 + SearchButtonLabel( 64 + title: "Previous", 65 + shortcut: ghosttyShortcuts.display(for: "search:previous"), 66 + systemImage: "chevron.down" 67 + ) 59 68 } 60 69 .buttonStyle(GhosttySearchButtonStyle()) 61 70 62 71 Button { 63 72 closeSearch() 64 73 } label: { 65 - SearchButtonLabel(title: "Close", shortcut: "⇧⌘F", systemImage: "xmark") 74 + SearchButtonLabel( 75 + title: "Close", 76 + shortcut: ghosttyShortcuts.display(for: "end_search"), 77 + systemImage: "xmark" 78 + ) 66 79 } 67 80 .buttonStyle(GhosttySearchButtonStyle()) 68 81 } ··· 245 258 246 259 private struct SearchButtonLabel: View { 247 260 let title: String 248 - let shortcut: String 261 + let shortcut: String? 249 262 let systemImage: String 250 263 251 264 var body: some View { 252 265 Label { 253 - Text("\(title) \(Text("(\(shortcut))").foregroundColor(.secondary.opacity(0.7)))") 266 + if let shortcut { 267 + Text("\(title) \(Text("(\(shortcut))").foregroundColor(.secondary.opacity(0.7)))") 268 + } else { 269 + Text(title) 270 + } 254 271 } icon: { 255 272 Image(systemName: systemImage) 256 273 .accessibilityHidden(true)
+29
supacode/Infrastructure/Ghostty/GhosttyCommand.swift
··· 1 + import GhosttyKit 2 + 3 + struct GhosttyCommand: Equatable, Sendable { 4 + let title: String 5 + let description: String 6 + let action: String 7 + let actionKey: String 8 + 9 + init( 10 + title: String, 11 + description: String, 12 + action: String, 13 + actionKey: String 14 + ) { 15 + self.title = title 16 + self.description = description 17 + self.action = action 18 + self.actionKey = actionKey 19 + } 20 + 21 + init(cValue: ghostty_command_s) { 22 + self.init( 23 + title: String(cString: cValue.title), 24 + description: String(cString: cValue.description), 25 + action: String(cString: cValue.action), 26 + actionKey: String(cString: cValue.action_key) 27 + ) 28 + } 29 + }
+12
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 452 452 return Self.keyboardShortcut(for: trigger) 453 453 } 454 454 455 + func commandPaletteEntries() -> [GhosttyCommand] { 456 + guard let config else { return [] } 457 + var value = ghostty_config_command_list_s() 458 + let key = "command-palette-entry" 459 + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) else { 460 + return [] 461 + } 462 + guard value.len > 0, let commands = value.commands else { return [] } 463 + let buffer = UnsafeBufferPointer(start: commands, count: Int(value.len)) 464 + return buffer.map(GhosttyCommand.init(cValue:)) 465 + } 466 + 455 467 func focusFollowsMouse() -> Bool { 456 468 guard let config else { return false } 457 469 var value = false
+5
supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift
··· 19 19 generation += 1 20 20 } 21 21 22 + var commandPaletteEntries: [GhosttyCommand] { 23 + _ = generation 24 + return runtime.commandPaletteEntries() 25 + } 26 + 22 27 func keyboardShortcut(for action: String) -> KeyboardShortcut? { 23 28 _ = generation 24 29 return runtime.keyboardShortcut(for: action)
+34
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 79 79 await store.receive(\.updates.checkForUpdates) 80 80 } 81 81 82 + @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToTerminalClient() async { 83 + let worktree = makeWorktree( 84 + id: "/tmp/repo-ghostty/wt-1", 85 + name: "wt-1", 86 + repoRoot: "/tmp/repo-ghostty" 87 + ) 88 + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) 89 + var repositoriesState = RepositoriesFeature.State() 90 + repositoriesState.repositories = [repository] 91 + repositoriesState.selection = .worktree(worktree.id) 92 + let sent = LockIsolated<[TerminalClient.Command]>([]) 93 + let store = TestStore( 94 + initialState: AppFeature.State( 95 + repositories: repositoriesState, 96 + settings: SettingsFeature.State() 97 + ) 98 + ) { 99 + AppFeature() 100 + } withDependencies: { 101 + $0.terminalClient.send = { command in 102 + sent.withValue { $0.append(command) } 103 + } 104 + } 105 + 106 + await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) 107 + await store.finish() 108 + 109 + #expect( 110 + sent.value == [ 111 + .performBindingAction(worktree, action: "goto_split:right") 112 + ] 113 + ) 114 + } 115 + 82 116 @Test(.dependencies) func closePullRequestDispatchesAction() async { 83 117 let store = TestStore(initialState: AppFeature.State()) { 84 118 AppFeature()
+125
supacodeTests/CommandPaletteFeatureTests.swift
··· 58 58 #expect(ids.contains { $0.contains("wt-pending") } == false) 59 59 } 60 60 61 + @Test func commandPaletteItems_includeGhosttyCommandsWhenWorktreeSelected() { 62 + let rootPath = "/tmp/repo" 63 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 64 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 65 + var state = RepositoriesFeature.State(repositories: [repository]) 66 + state.selection = .worktree(worktree.id) 67 + 68 + let items = CommandPaletteFeature.commandPaletteItems( 69 + from: state, 70 + ghosttyCommands: [ 71 + GhosttyCommand( 72 + title: "Focus Split Right", 73 + description: "Focus the split to the right.", 74 + action: "goto_split:right", 75 + actionKey: "goto_split" 76 + ), 77 + ] 78 + ) 79 + 80 + let ghosttyItem = items.first { 81 + if case .ghosttyCommand(let action) = $0.kind { 82 + return action == "goto_split:right" 83 + } 84 + return false 85 + } 86 + 87 + #expect(ghosttyItem?.title == "Focus Split Right") 88 + #expect(ghosttyItem?.subtitle == "Focus the split to the right.") 89 + } 90 + 91 + @Test func commandPaletteItems_omitGhosttyCommandsWithoutSelectedWorktree() { 92 + let items = CommandPaletteFeature.commandPaletteItems( 93 + from: RepositoriesFeature.State(), 94 + ghosttyCommands: [ 95 + GhosttyCommand( 96 + title: "Focus Split Right", 97 + description: "", 98 + action: "goto_split:right", 99 + actionKey: "goto_split" 100 + ), 101 + ] 102 + ) 103 + 104 + #expect( 105 + items.contains { 106 + if case .ghosttyCommand = $0.kind { 107 + return true 108 + } 109 + return false 110 + } == false 111 + ) 112 + } 113 + 114 + @Test func emptyQueryHidesGhosttyCommands() { 115 + let ghosttyItem = CommandPaletteItem( 116 + id: "ghostty.goto_split:right|Focus Split Right", 117 + title: "Focus Split Right", 118 + subtitle: nil, 119 + kind: .ghosttyCommand("goto_split:right") 120 + ) 121 + let prAction = CommandPaletteItem( 122 + id: "pr.open", 123 + title: "Open PR on GitHub", 124 + subtitle: "PR title", 125 + kind: .openPullRequest("wt-1"), 126 + priorityTier: 2 127 + ) 128 + 129 + let result = CommandPaletteFeature.filterItems( 130 + items: [ghosttyItem, prAction], 131 + query: "" 132 + ) 133 + 134 + #expect(!result.contains { $0.id == ghosttyItem.id }) 135 + #expect(result.contains { $0.id == prAction.id }) 136 + } 137 + 61 138 @Test func commandPaletteItems_omitsSubActionsForMainWorktree() { 62 139 let rootPath = "/tmp/repo" 63 140 let main = makeWorktree( ··· 545 622 ) 546 623 } 547 624 625 + @Test func supacodeItemsBeatGhosttyItemsWhenScoresTie() { 626 + let supacodeItem = CommandPaletteItem( 627 + id: "global.open-settings", 628 + title: "Open Settings", 629 + subtitle: nil, 630 + kind: .openSettings 631 + ) 632 + let ghosttyItem = CommandPaletteItem( 633 + id: "ghostty.open-settings|Open Settings", 634 + title: "Open Settings", 635 + subtitle: nil, 636 + kind: .ghosttyCommand("open_settings"), 637 + priorityTier: CommandPaletteItem.defaultPriorityTier + 100 638 + ) 639 + 640 + expectNoDifference( 641 + CommandPaletteFeature.filterItems( 642 + items: [ghosttyItem, supacodeItem], 643 + query: "open settings" 644 + ), 645 + [supacodeItem, ghosttyItem] 646 + ) 647 + } 648 + 548 649 // MARK: - Unified Ranking Tests 549 650 550 651 @Test func worktreeOutranksGlobalWhenBetterMatch() { ··· 874 975 $0.recencyByItemID[item.id] = now.timeIntervalSince1970 875 976 } 876 977 await store.receive(.delegate(.openRepository)) 978 + } 979 + 980 + @Test func activateGhosttyCommandDispatchesDelegate() async { 981 + let now = Date(timeIntervalSince1970: 7_654_321) 982 + let item = CommandPaletteItem( 983 + id: "ghostty.goto_split:right|Focus Split Right", 984 + title: "Focus Split Right", 985 + subtitle: nil, 986 + kind: .ghosttyCommand("goto_split:right") 987 + ) 988 + var state = CommandPaletteFeature.State() 989 + state.isPresented = true 990 + let store = TestStore(initialState: state) { 991 + CommandPaletteFeature() 992 + } 993 + store.dependencies.date = .constant(now) 994 + 995 + await store.send(.activateItem(item)) { 996 + $0.isPresented = false 997 + $0.query = "" 998 + $0.selectedIndex = nil 999 + $0.recencyByItemID[item.id] = now.timeIntervalSince1970 1000 + } 1001 + await store.receive(.delegate(.ghosttyCommand("goto_split:right"))) 877 1002 } 878 1003 } 879 1004