Skip to content

Commit 4974a2a

Browse files
committed
Add repo action prompt controls
1 parent 8cc6875 commit 4974a2a

8 files changed

Lines changed: 967 additions & 4 deletions

File tree

macos/Sources/Features/Command Palette/CommandPalette.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ struct CommandOption: Identifiable, Hashable {
2525
let dismissOnSelect: Bool
2626
/// If true, this option is always visible even when the query doesn't match.
2727
let pinned: Bool
28+
/// If false, this option is visible but cannot be executed.
29+
let isEnabled: Bool
2830
/// The action to perform when this option is selected.
2931
let action: () -> Void
3032

@@ -40,6 +42,7 @@ struct CommandOption: Identifiable, Hashable {
4042
sortKey: AnySortKey? = nil,
4143
dismissOnSelect: Bool = true,
4244
pinned: Bool = false,
45+
isEnabled: Bool = true,
4346
action: @escaping () -> Void
4447
) {
4548
self.title = title
@@ -53,6 +56,7 @@ struct CommandOption: Identifiable, Hashable {
5356
self.sortKey = sortKey
5457
self.dismissOnSelect = dismissOnSelect
5558
self.pinned = pinned
59+
self.isEnabled = isEnabled
5660
self.action = action
5761
}
5862

@@ -128,6 +132,7 @@ struct CommandPaletteView: View {
128132
isPresented = false
129133
break
130134
}
135+
guard selectedOption.isEnabled else { break }
131136
if selectedOption.dismissOnSelect {
132137
isPresented = false
133138
}
@@ -173,6 +178,7 @@ struct CommandPaletteView: View {
173178
options: filteredOptions,
174179
selectedIndex: $selectedIndex,
175180
hoveredOptionID: $hoveredOptionID) { option in
181+
guard option.isEnabled else { return }
176182
if option.dismissOnSelect {
177183
isPresented = false
178184
}
@@ -370,13 +376,16 @@ private struct CommandRow: View {
370376

371377
if let icon = option.leadingIcon {
372378
Image(systemName: icon)
373-
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
379+
.foregroundStyle(option.isEnabled
380+
? (option.emphasis ? Color.accentColor : Color.secondary)
381+
: Color.secondary.opacity(0.6))
374382
.font(.system(size: 14, weight: .medium))
375383
}
376384

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

381390
if let subtitle = option.subtitle {
382391
Text(subtitle)
@@ -406,7 +415,7 @@ private struct CommandRow: View {
406415
.padding(8)
407416
.contentShape(Rectangle())
408417
.background(
409-
isSelected
418+
isSelected && option.isEnabled
410419
? Color.accentColor.opacity(0.2)
411420
: (hoveredID == option.id
412421
? Color.secondary.opacity(0.2)
@@ -420,6 +429,8 @@ private struct CommandRow: View {
420429
}
421430
.help(option.description ?? "")
422431
.buttonStyle(.plain)
432+
.disabled(!option.isEnabled)
433+
.opacity(option.isEnabled ? 1 : 0.7)
423434
.onHover { hovering in
424435
hoveredID = hovering ? option.id : nil
425436
}

macos/Sources/Features/Command Palette/TerminalCommandPalette.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import Combine
23
import GhosttyKit
34

45
struct TerminalCommandPaletteView: View {
@@ -27,6 +28,7 @@ struct TerminalCommandPaletteView: View {
2728
}
2829

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

3133
var body: some View {
3234
ZStack {
@@ -66,8 +68,14 @@ struct TerminalCommandPaletteView: View {
6668
DispatchQueue.main.async {
6769
surfaceView.window?.makeFirstResponder(surfaceView)
6870
}
71+
} else {
72+
refreshRepoPromptResolution()
6973
}
7074
}
75+
.onReceive(worktrunkStoreChangePublisher) { _ in
76+
guard isPresented else { return }
77+
refreshRepoPromptResolution()
78+
}
7179
}
7280

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

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

216+
private var worktrunkStoreChangePublisher: AnyPublisher<Void, Never> {
217+
guard let worktrunkStore else {
218+
return Empty<Void, Never>().eraseToAnyPublisher()
219+
}
220+
221+
return worktrunkStore.objectWillChange
222+
.debounce(for: .milliseconds(100), scheduler: RunLoop.main)
223+
.map { _ in () }
224+
.eraseToAnyPublisher()
225+
}
226+
227+
private var githubOptions: [CommandOption] {
228+
switch repoPromptResolution {
229+
case .disabled(let reason):
230+
return TerminalRepoPromptAction.menuActions.map { action in
231+
CommandOption(
232+
title: action.paletteTitle,
233+
description: reason.description,
234+
leadingIcon: "arrow.trianglehead.branch",
235+
dismissOnSelect: false,
236+
isEnabled: false
237+
) {}
238+
}
239+
240+
case .ready(let readyState):
241+
var options: [CommandOption] = []
242+
243+
if let shortcut = readyState.shortcutAction {
244+
options.append(CommandOption(
245+
title: shortcut.action.paletteTitle,
246+
description: shortcut.description,
247+
leadingIcon: "arrow.trianglehead.branch"
248+
) {
249+
terminalController?.insertRepoPrompt(shortcut.action)
250+
})
251+
}
252+
253+
options.append(contentsOf: readyState.actionStates.map { state in
254+
CommandOption(
255+
title: state.action.paletteTitle,
256+
description: state.description,
257+
leadingIcon: "arrow.trianglehead.branch",
258+
emphasis: state.action == readyState.primaryAction,
259+
dismissOnSelect: state.isAvailable,
260+
isEnabled: state.isAvailable
261+
) {
262+
terminalController?.insertRepoPrompt(state.action)
263+
}
264+
})
265+
266+
return options
267+
}
268+
}
269+
207270
private var worktrunkRootOptions: [CommandOption] {
208271
guard terminalController != nil, worktrunkStore != nil else { return [] }
209272

@@ -219,6 +282,21 @@ struct TerminalCommandPaletteView: View {
219282
return [newWorktree]
220283
}
221284

285+
private func refreshRepoPromptResolution() {
286+
guard let terminalController else {
287+
repoPromptResolution = .disabled(.noFocusedTerminal)
288+
return
289+
}
290+
291+
Task { @MainActor in
292+
let resolution = await TerminalRepoPrompt.resolve(
293+
pwd: terminalController.focusedSurface?.pwd,
294+
worktrunkStore: worktrunkStore
295+
)
296+
repoPromptResolution = resolution
297+
}
298+
}
299+
222300
private var worktrunkPickRepoOptions: [CommandOption] {
223301
guard terminalController != nil, let store = worktrunkStore else { return [] }
224302

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import AppKit
2+
3+
enum RepoPromptSplitButton {
4+
private static let fallbackImage = NSImage(
5+
systemSymbolName: "arrow.trianglehead.branch",
6+
accessibilityDescription: "Repo action"
7+
) ?? NSImage()
8+
9+
static func make(target: TerminalController?) -> NSSegmentedControl {
10+
let segmented = NSSegmentedControl()
11+
segmented.segmentCount = 2
12+
segmented.trackingMode = .momentary
13+
segmented.segmentStyle = .separated
14+
15+
segmented.setImage(fallbackImage, forSegment: 0)
16+
segmented.setImageScaling(.scaleProportionallyDown, forSegment: 0)
17+
segmented.setLabel("Action", forSegment: 0)
18+
segmented.setWidth(0, forSegment: 0)
19+
segmented.setWidth(22, forSegment: 1)
20+
segmented.setShowsMenuIndicator(true, forSegment: 1)
21+
segmented.target = target
22+
segmented.action = #selector(TerminalController.repoPromptToolbarAction(_:))
23+
24+
update(segmented, resolution: target?.repoPromptResolution ?? .disabled(.noFocusedTerminal))
25+
return segmented
26+
}
27+
28+
static func update(
29+
_ segmented: NSSegmentedControl,
30+
resolution: TerminalRepoPromptResolution
31+
) {
32+
segmented.setToolTip("Type a repo workflow prompt into the current AI session", forSegment: 0)
33+
segmented.setToolTip("Choose a repo workflow prompt", forSegment: 1)
34+
35+
switch resolution {
36+
case .disabled(let reason):
37+
segmented.isEnabled = false
38+
segmented.setLabel("Action", forSegment: 0)
39+
segmented.toolTip = reason.description
40+
segmented.setToolTip(reason.description, forSegment: 0)
41+
segmented.setToolTip(reason.description, forSegment: 1)
42+
segmented.setMenu(disabledMenu(reason: reason), forSegment: 1)
43+
44+
case .ready(let readyState):
45+
segmented.isEnabled = true
46+
segmented.setLabel(readyState.primaryAction.title, forSegment: 0)
47+
let primaryDescription = readyState.state(for: readyState.primaryAction)?.description
48+
?? "Type a repo workflow prompt into the current AI session."
49+
segmented.toolTip = primaryDescription
50+
segmented.setToolTip(primaryDescription, forSegment: 0)
51+
segmented.setToolTip("Choose a repo workflow prompt", forSegment: 1)
52+
segmented.setMenu(menu(for: readyState, target: segmented.target), forSegment: 1)
53+
}
54+
}
55+
56+
private static func disabledMenu(reason: TerminalRepoPromptDisabledReason) -> NSMenu {
57+
let menu = NSMenu()
58+
let item = NSMenuItem(title: reason.title, action: nil, keyEquivalent: "")
59+
item.isEnabled = false
60+
item.toolTip = reason.description
61+
menu.addItem(item)
62+
return menu
63+
}
64+
65+
private static func menu(
66+
for readyState: TerminalRepoPromptReadyState,
67+
target: AnyObject?
68+
) -> NSMenu {
69+
let menu = NSMenu()
70+
if let shortcut = readyState.shortcutAction {
71+
menu.addItem(shortcutItem(for: shortcut, target: target))
72+
menu.addItem(.separator())
73+
}
74+
for state in readyState.actionStates {
75+
menu.addItem(item(for: state, target: target))
76+
}
77+
return menu
78+
}
79+
80+
private static func selector(for action: TerminalRepoPromptAction) -> Selector {
81+
switch action {
82+
case .smart:
83+
return #selector(TerminalController.insertSmartRepoPrompt(_:))
84+
case .commit:
85+
return #selector(TerminalController.insertCommitRepoPrompt(_:))
86+
case .commitAndPush:
87+
return #selector(TerminalController.insertCommitAndPushRepoPrompt(_:))
88+
case .push:
89+
return #selector(TerminalController.insertPushRepoPrompt(_:))
90+
case .pushAndOpenPR:
91+
return #selector(TerminalController.insertPushAndOpenPRRepoPrompt(_:))
92+
case .openPR:
93+
return #selector(TerminalController.insertOpenPRRepoPrompt(_:))
94+
case .pushAndUpdatePR:
95+
return #selector(TerminalController.insertPushAndUpdatePRRepoPrompt(_:))
96+
case .updatePR:
97+
return #selector(TerminalController.insertUpdatePRRepoPrompt(_:))
98+
}
99+
}
100+
101+
private static func shortcutItem(
102+
for shortcut: TerminalRepoPromptShortcutState,
103+
target: AnyObject?
104+
) -> NSMenuItem {
105+
let item = NSMenuItem(
106+
title: shortcut.action.title,
107+
action: selector(for: shortcut.action),
108+
keyEquivalent: ""
109+
)
110+
item.target = target
111+
item.toolTip = shortcut.description
112+
return item
113+
}
114+
115+
private static func item(
116+
for state: TerminalRepoPromptActionState,
117+
target: AnyObject?
118+
) -> NSMenuItem {
119+
let item = NSMenuItem(
120+
title: state.action.title,
121+
action: state.isAvailable ? selector(for: state.action) : nil,
122+
keyEquivalent: ""
123+
)
124+
item.target = target
125+
item.isEnabled = state.isAvailable
126+
item.toolTip = state.description
127+
return item
128+
}
129+
}

0 commit comments

Comments
 (0)