Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions macos/Sources/Features/Command Palette/CommandPalette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ struct CommandOption: Identifiable, Hashable {
let dismissOnSelect: Bool
/// If true, this option is always visible even when the query doesn't match.
let pinned: Bool
/// If false, this option is visible but cannot be executed.
let isEnabled: Bool
/// The action to perform when this option is selected.
let action: () -> Void

Expand All @@ -40,6 +42,7 @@ struct CommandOption: Identifiable, Hashable {
sortKey: AnySortKey? = nil,
dismissOnSelect: Bool = true,
pinned: Bool = false,
isEnabled: Bool = true,
action: @escaping () -> Void
) {
self.title = title
Expand All @@ -53,6 +56,7 @@ struct CommandOption: Identifiable, Hashable {
self.sortKey = sortKey
self.dismissOnSelect = dismissOnSelect
self.pinned = pinned
self.isEnabled = isEnabled
self.action = action
}

Expand Down Expand Up @@ -128,6 +132,7 @@ struct CommandPaletteView: View {
isPresented = false
break
}
guard selectedOption.isEnabled else { break }
if selectedOption.dismissOnSelect {
isPresented = false
}
Expand Down Expand Up @@ -173,6 +178,7 @@ struct CommandPaletteView: View {
options: filteredOptions,
selectedIndex: $selectedIndex,
hoveredOptionID: $hoveredOptionID) { option in
guard option.isEnabled else { return }
if option.dismissOnSelect {
isPresented = false
}
Expand Down Expand Up @@ -370,13 +376,16 @@ private struct CommandRow: View {

if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.foregroundStyle(option.isEnabled
? (option.emphasis ? Color.accentColor : Color.secondary)
: Color.secondary.opacity(0.6))
.font(.system(size: 14, weight: .medium))
}

VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
.foregroundStyle(option.isEnabled ? Color.primary : Color.secondary)

if let subtitle = option.subtitle {
Text(subtitle)
Expand Down Expand Up @@ -406,7 +415,7 @@ private struct CommandRow: View {
.padding(8)
.contentShape(Rectangle())
.background(
isSelected
isSelected && option.isEnabled
? Color.accentColor.opacity(0.2)
: (hoveredID == option.id
? Color.secondary.opacity(0.2)
Expand All @@ -420,6 +429,8 @@ private struct CommandRow: View {
}
.help(option.description ?? "")
.buttonStyle(.plain)
.disabled(!option.isEnabled)
.opacity(option.isEnabled ? 1 : 0.7)
.onHover { hovering in
hoveredID = hovering ? option.id : nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import Combine
import GhosttyKit

struct TerminalCommandPaletteView: View {
Expand Down Expand Up @@ -27,6 +28,7 @@ struct TerminalCommandPaletteView: View {
}

@State private var worktrunkMode: WorktrunkPaletteMode = .root
@State private var repoPromptResolution: TerminalRepoPromptResolution = .disabled(.noFocusedTerminal)

var body: some View {
ZStack {
Expand Down Expand Up @@ -66,8 +68,14 @@ struct TerminalCommandPaletteView: View {
DispatchQueue.main.async {
surfaceView.window?.makeFirstResponder(surfaceView)
}
} else {
refreshRepoPromptResolution()
}
}
.onReceive(worktrunkStoreChangePublisher) { _ in
guard isPresented else { return }
refreshRepoPromptResolution()
}
}

/// All commands available in the command palette, combining update and terminal options.
Expand All @@ -77,6 +85,7 @@ struct TerminalCommandPaletteView: View {
var options: [CommandOption] = []
// Updates always appear first
options.append(contentsOf: updateOptions)
options.append(contentsOf: githubOptions)

let rest = (worktrunkRootOptions + jumpOptions + terminalOptions).sorted { a, b in
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
Expand Down Expand Up @@ -204,6 +213,60 @@ struct TerminalCommandPaletteView: View {
(NSApp.delegate as? AppDelegate)?.worktrunkStore
}

private var worktrunkStoreChangePublisher: AnyPublisher<Void, Never> {
guard let worktrunkStore else {
return Empty<Void, Never>().eraseToAnyPublisher()
}

return worktrunkStore.objectWillChange
.debounce(for: .milliseconds(100), scheduler: RunLoop.main)
.map { _ in () }
.eraseToAnyPublisher()
}

private var githubOptions: [CommandOption] {
switch repoPromptResolution {
case .disabled(let reason):
return TerminalRepoPromptAction.menuActions.map { action in
CommandOption(
title: action.paletteTitle,
description: reason.description,
leadingIcon: "arrow.trianglehead.branch",
dismissOnSelect: false,
isEnabled: false
) {}
}

case .ready(let readyState):
var options: [CommandOption] = []

if let shortcut = readyState.shortcutAction {
options.append(CommandOption(
title: shortcut.action.paletteTitle,
description: shortcut.description,
leadingIcon: "arrow.trianglehead.branch"
) {
terminalController?.insertRepoPrompt(shortcut.action)
})
}

options.append(contentsOf: readyState.actionStates.map { state in
CommandOption(
title: state.action.paletteTitle,
description: state.description,
leadingIcon: "arrow.trianglehead.branch",
emphasis: state.action == readyState.primaryAction,
dismissOnSelect: state.isAvailable,
isEnabled: state.isAvailable
) {
terminalController?.insertRepoPrompt(state.action)
}
})

return options
}
}

private var worktrunkRootOptions: [CommandOption] {
guard terminalController != nil, worktrunkStore != nil else { return [] }

Expand All @@ -219,6 +282,21 @@ struct TerminalCommandPaletteView: View {
return [newWorktree]
}

private func refreshRepoPromptResolution() {
guard let terminalController else {
repoPromptResolution = .disabled(.noFocusedTerminal)
return
}

Task { @MainActor in
let resolution = await TerminalRepoPrompt.resolve(
pwd: terminalController.focusedSurface?.pwd,
worktrunkStore: worktrunkStore
)
repoPromptResolution = resolution
}
}

private var worktrunkPickRepoOptions: [CommandOption] {
guard terminalController != nil, let store = worktrunkStore else { return [] }

Expand Down
131 changes: 131 additions & 0 deletions macos/Sources/Features/Terminal/RepoPromptSplitButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#if os(macOS)
import AppKit

enum RepoPromptSplitButton {
private static let fallbackImage = NSImage(
systemSymbolName: "arrow.trianglehead.branch",
accessibilityDescription: "Repo action"
) ?? NSImage()

static func make(target: TerminalController?) -> NSSegmentedControl {
let segmented = NSSegmentedControl()
segmented.segmentCount = 2
segmented.trackingMode = .momentary
segmented.segmentStyle = .separated

segmented.setImage(fallbackImage, forSegment: 0)
segmented.setImageScaling(.scaleProportionallyDown, forSegment: 0)
segmented.setLabel("Action", forSegment: 0)
segmented.setWidth(0, forSegment: 0)
segmented.setWidth(22, forSegment: 1)
segmented.setShowsMenuIndicator(true, forSegment: 1)
segmented.target = target
segmented.action = #selector(TerminalController.repoPromptToolbarAction(_:))

update(segmented, resolution: target?.repoPromptResolution ?? .disabled(.noFocusedTerminal))
return segmented
}

static func update(
_ segmented: NSSegmentedControl,
resolution: TerminalRepoPromptResolution
) {
segmented.setToolTip("Type a repo workflow prompt into the current AI session", forSegment: 0)
segmented.setToolTip("Choose a repo workflow prompt", forSegment: 1)

switch resolution {
case .disabled(let reason):
segmented.isEnabled = false
segmented.setLabel("Action", forSegment: 0)
segmented.toolTip = reason.description
segmented.setToolTip(reason.description, forSegment: 0)
segmented.setToolTip(reason.description, forSegment: 1)
segmented.setMenu(disabledMenu(reason: reason), forSegment: 1)

case .ready(let readyState):
segmented.isEnabled = true
segmented.setLabel(readyState.primaryAction.title, forSegment: 0)
let primaryDescription = readyState.state(for: readyState.primaryAction)?.description
?? "Type a repo workflow prompt into the current AI session."
segmented.toolTip = primaryDescription
segmented.setToolTip(primaryDescription, forSegment: 0)
segmented.setToolTip("Choose a repo workflow prompt", forSegment: 1)
segmented.setMenu(menu(for: readyState, target: segmented.target), forSegment: 1)
}
}

private static func disabledMenu(reason: TerminalRepoPromptDisabledReason) -> NSMenu {
let menu = NSMenu()
let item = NSMenuItem(title: reason.title, action: nil, keyEquivalent: "")
item.isEnabled = false
item.toolTip = reason.description
menu.addItem(item)
return menu
}

private static func menu(
for readyState: TerminalRepoPromptReadyState,
target: AnyObject?
) -> NSMenu {
let menu = NSMenu()
if let shortcut = readyState.shortcutAction {
menu.addItem(shortcutItem(for: shortcut, target: target))
menu.addItem(.separator())
}
for state in readyState.actionStates {
menu.addItem(item(for: state, target: target))
}
return menu
}

private static func selector(for action: TerminalRepoPromptAction) -> Selector {
switch action {
case .smart:
return #selector(TerminalController.insertSmartRepoPrompt(_:))
case .commit:
return #selector(TerminalController.insertCommitRepoPrompt(_:))
case .commitAndPush:
return #selector(TerminalController.insertCommitAndPushRepoPrompt(_:))
case .push:
return #selector(TerminalController.insertPushRepoPrompt(_:))
case .pushAndOpenPR:
return #selector(TerminalController.insertPushAndOpenPRRepoPrompt(_:))
case .openPR:
return #selector(TerminalController.insertOpenPRRepoPrompt(_:))
case .pushAndUpdatePR:
return #selector(TerminalController.insertPushAndUpdatePRRepoPrompt(_:))
case .updatePR:
return #selector(TerminalController.insertUpdatePRRepoPrompt(_:))
}
}

private static func shortcutItem(
for shortcut: TerminalRepoPromptShortcutState,
target: AnyObject?
) -> NSMenuItem {
let item = NSMenuItem(
title: shortcut.action.title,
action: selector(for: shortcut.action),
keyEquivalent: ""
)
item.target = target
item.toolTip = shortcut.description
return item
}

private static func item(
for state: TerminalRepoPromptActionState,
target: AnyObject?
) -> NSMenuItem {
let item = NSMenuItem(
title: state.action.title,
action: state.isAvailable ? selector(for: state.action) : nil,
keyEquivalent: ""
)
item.target = target
item.isEnabled = state.isAvailable
item.toolTip = state.description
return item
}
}
#endif
Loading
Loading