From 4974a2a83a93f86a03731cc980b3f8a988beba2c Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 11 Mar 2026 07:40:21 -0700 Subject: [PATCH 1/4] Add repo action prompt controls --- .../Command Palette/CommandPalette.swift | 15 +- .../TerminalCommandPalette.swift | 78 ++++ .../Terminal/RepoPromptSplitButton.swift | 129 ++++++ .../Terminal/TerminalController.swift | 104 +++++ .../Terminal/TerminalRepoPrompt.swift | 391 ++++++++++++++++++ .../TitlebarTabsTahoeTerminalWindow.swift | 13 + .../Features/Worktrunk/WorktrunkToolbar.swift | 15 +- macos/Tests/TerminalRepoPromptTests.swift | 226 ++++++++++ 8 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/Features/Terminal/RepoPromptSplitButton.swift create mode 100644 macos/Sources/Features/Terminal/TerminalRepoPrompt.swift create mode 100644 macos/Tests/TerminalRepoPromptTests.swift diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 6875698f67..8467b5c96e 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -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 @@ -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 @@ -53,6 +56,7 @@ struct CommandOption: Identifiable, Hashable { self.sortKey = sortKey self.dismissOnSelect = dismissOnSelect self.pinned = pinned + self.isEnabled = isEnabled self.action = action } @@ -128,6 +132,7 @@ struct CommandPaletteView: View { isPresented = false break } + guard selectedOption.isEnabled else { break } if selectedOption.dismissOnSelect { isPresented = false } @@ -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 } @@ -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) @@ -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) @@ -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 } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 9798a85f5f..7eccef0478 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine import GhosttyKit struct TerminalCommandPaletteView: View { @@ -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 { @@ -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. @@ -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") @@ -204,6 +213,60 @@ struct TerminalCommandPaletteView: View { (NSApp.delegate as? AppDelegate)?.worktrunkStore } + private var worktrunkStoreChangePublisher: AnyPublisher { + guard let worktrunkStore else { + return Empty().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 [] } @@ -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 [] } diff --git a/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift b/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift new file mode 100644 index 0000000000..2a1e246431 --- /dev/null +++ b/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift @@ -0,0 +1,129 @@ +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 + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 91d52ab721..e9cdb4e732 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -230,6 +230,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr private let tabSwitchRefreshThrottle: TimeInterval = 0.15 private var pendingTabSwitchRefresh: DispatchWorkItem? private var lastTabSwitchSurfaceID: UUID? + private var repoPromptRefreshTask: Task? + private(set) var repoPromptResolution: TerminalRepoPromptResolution = .disabled(.noFocusedTerminal) private(set) var worktreeTabRootPath: String? { didSet { syncWorktreeTabTitle() } @@ -1552,10 +1554,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr syncAppearance(.init(config)) openTabsModel.refresh(for: window) + refreshRepoPromptResolution() } private func installWorktrunkSidebarSync() { guard worktrunkSidebarSyncCancellables.isEmpty else { return } + let worktrunkStore = (NSApp.delegate as? AppDelegate)?.worktrunkStore worktrunkSidebarState.$columnVisibility .removeDuplicates() @@ -1589,6 +1593,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self?.syncWorktrunkSidebarSelectionToTabGroup(selection) } .store(in: &worktrunkSidebarSyncCancellables) + + worktrunkStore?.objectWillChange + .debounce(for: .milliseconds(100), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.refreshRepoPromptResolution() + } + .store(in: &worktrunkSidebarSyncCancellables) } private func syncWorktrunkSidebarVisibilityToTabGroup(_ visibility: NavigationSplitViewVisibility) { @@ -1961,6 +1972,97 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + func refreshRepoPromptResolution() { + repoPromptRefreshTask?.cancel() + repoPromptRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + let worktrunkStore = (NSApp.delegate as? AppDelegate)?.worktrunkStore + let resolution = await TerminalRepoPrompt.resolve( + pwd: self.focusedSurface?.pwd, + worktrunkStore: worktrunkStore + ) + guard !Task.isCancelled else { return } + self.repoPromptResolution = resolution + self.refreshRepoPromptToolbarItems() + } + } + + private func refreshRepoPromptToolbarItems() { + guard let toolbar = window?.toolbar else { return } + for item in toolbar.items { + guard item.itemIdentifier == .repoPrompt, + let segmented = item.view as? NSSegmentedControl else { continue } + RepoPromptSplitButton.update(segmented, resolution: repoPromptResolution) + } + } + + func insertRepoPrompt(_ requestedAction: TerminalRepoPromptAction) { + Task { @MainActor [weak self] in + guard let self else { return } + let worktrunkStore = (NSApp.delegate as? AppDelegate)?.worktrunkStore + let resolution = await TerminalRepoPrompt.resolve( + pwd: self.focusedSurface?.pwd, + worktrunkStore: worktrunkStore + ) + self.repoPromptResolution = resolution + self.refreshRepoPromptToolbarItems() + + guard case .ready(let readyState) = resolution else { return } + guard let surface = self.focusedSurface?.surfaceModel else { return } + + let action = requestedAction == .smart ? readyState.primaryAction : requestedAction + guard readyState.supports(action) else { return } + surface.sendText(TerminalRepoPrompt.prompt(for: action, readyState: readyState)) + } + } + + @objc func repoPromptToolbarAction(_ sender: Any?) { + if let segmented = sender as? NSSegmentedControl, segmented.selectedSegment == 1 { + if let menu = segmented.menu(forSegment: 1) { + let screenRect = segmented.window?.convertToScreen( + segmented.convert(segmented.bounds, to: nil) + ) ?? .zero + let origin = NSPoint(x: screenRect.minX, y: screenRect.minY) + menu.popUp(positioning: nil, at: origin, in: nil) + } + return + } + + insertRepoPrompt(.smart) + } + + @objc func insertSmartRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.smart) + } + + @objc func insertCommitRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.commit) + } + + @objc func insertCommitAndPushRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.commitAndPush) + } + + @objc func insertOpenPRRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.openPR) + } + + @objc func insertPushRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.push) + } + + @objc func insertPushAndOpenPRRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.pushAndOpenPR) + } + + @objc func insertPushAndUpdatePRRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.pushAndUpdatePR) + } + + @objc func insertUpdatePRRepoPrompt(_ sender: Any?) { + insertRepoPrompt(.updatePR) + } + private func currentEditorPath() -> String? { // Prefer selected worktree path from sidebar, fall back to focused surface pwd switch worktrunkSidebarState.selection { @@ -2300,10 +2402,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) + refreshRepoPromptResolution() } override func pwdDidChange(to: URL?) { super.pwdDidChange(to: to) + refreshRepoPromptResolution() if #available(macOS 26.0, *) { guard let to else { return } Task { @MainActor in diff --git a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift new file mode 100644 index 0000000000..699ea6ce92 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift @@ -0,0 +1,391 @@ +import Foundation + +enum TerminalRepoPromptAction: String, CaseIterable { + case smart + case commit + case commitAndPush + case push + case pushAndOpenPR + case openPR + case pushAndUpdatePR + case updatePR + + static let menuActions: [TerminalRepoPromptAction] = [ + .commit, + .push, + .openPR, + .updatePR, + ] + + var title: String { + switch self { + case .smart: return "Smart" + case .commit: return "Commit" + case .commitAndPush: return "Commit + Push" + case .push: return "Push" + case .pushAndOpenPR: return "Push + Open PR" + case .openPR: return "Open PR" + case .pushAndUpdatePR: return "Push + Update PR" + case .updatePR: return "Update PR" + } + } + + var paletteTitle: String { + "Repo: \(title)" + } +} + +struct TerminalRepoPromptActionState: Equatable { + let action: TerminalRepoPromptAction + let isAvailable: Bool + let description: String +} + +struct TerminalRepoPromptShortcutState: Equatable { + let action: TerminalRepoPromptAction + let description: String +} + +enum TerminalRepoPromptDisabledReason: Equatable { + case noFocusedTerminal + case noGitRepo + case detachedHead + case notGitHubRepo + case ghUnavailable(String) + + var title: String { + switch self { + case .noFocusedTerminal: + return "No Focused Terminal" + case .noGitRepo: + return "Not in a Git Repo" + case .detachedHead: + return "Detached HEAD" + case .notGitHubRepo: + return "Not a GitHub Repo" + case .ghUnavailable: + return "Unavailable" + } + } + + var description: String { + switch self { + case .noFocusedTerminal: + return "Focus a terminal first." + case .noGitRepo: + return "The focused terminal is not inside a git repository." + case .detachedHead: + return "Switch to a branch first." + case .notGitHubRepo: + return "The current repository is not backed by GitHub." + case .ghUnavailable(let detail): + return detail + } + } +} + +struct TerminalRepoPromptSnapshot: Equatable { + let repoRoot: String + let branch: String + let sessions: [AISession] + let hasDirtyChanges: Bool + let openPR: PRStatus? + let gitTracking: WorktrunkStore.GitTracking? +} + +struct TerminalRepoPromptReadyState: Equatable { + let snapshot: TerminalRepoPromptSnapshot + let primaryAction: TerminalRepoPromptAction + let shortcutAction: TerminalRepoPromptShortcutState? + let actionStates: [TerminalRepoPromptActionState] + + func state(for action: TerminalRepoPromptAction) -> TerminalRepoPromptActionState? { + actionStates.first(where: { $0.action == action }) + } + + func supports(_ action: TerminalRepoPromptAction) -> Bool { + if action == .smart { return true } + if shortcutAction?.action == action { return true } + return state(for: action)?.isAvailable == true + } +} + +enum TerminalRepoPromptResolution: Equatable { + case disabled(TerminalRepoPromptDisabledReason) + case ready(TerminalRepoPromptReadyState) +} + +enum TerminalRepoPrompt { + static func classify(snapshot: TerminalRepoPromptSnapshot) -> TerminalRepoPromptReadyState { + let actionStates = actionStates(for: snapshot) + let primaryAction = actionStates.first(where: \.isAvailable)?.action ?? .commit + let shortcutAction = shortcutAction(for: snapshot, primaryAction: primaryAction) + + return .init( + snapshot: snapshot, + primaryAction: primaryAction, + shortcutAction: shortcutAction, + actionStates: actionStates + ) + } + + private static func shortcutAction( + for snapshot: TerminalRepoPromptSnapshot, + primaryAction: TerminalRepoPromptAction + ) -> TerminalRepoPromptShortcutState? { + let hasOpenPR = snapshot.openPR != nil + let hasUpstream = snapshot.gitTracking?.hasUpstream ?? false + let aheadCount = snapshot.gitTracking?.ahead ?? 0 + let needsPush = !hasUpstream || aheadCount > 0 + + switch primaryAction { + case .commit: + return .init( + action: .commitAndPush, + description: "Create one commit, then push the current branch." + ) + + case .push where hasOpenPR: + return .init( + action: .pushAndUpdatePR, + description: "Push the branch, then update the existing PR if needed." + ) + + case .push where needsPush: + return .init( + action: .pushAndOpenPR, + description: "Push the branch, then open a PR." + ) + + default: + return nil + } + } + + private static func actionStates( + for snapshot: TerminalRepoPromptSnapshot + ) -> [TerminalRepoPromptActionState] { + let hasDirtyChanges = snapshot.hasDirtyChanges + let hasOpenPR = snapshot.openPR != nil + let hasUpstream = snapshot.gitTracking?.hasUpstream ?? false + let aheadCount = snapshot.gitTracking?.ahead ?? 0 + let needsPush = !hasUpstream || aheadCount > 0 + + return TerminalRepoPromptAction.menuActions.map { action in + switch action { + case .commit: + if hasDirtyChanges { + return .init( + action: action, + isAvailable: true, + description: "Create one commit from the current working tree changes." + ) + } + + return .init( + action: action, + isAvailable: false, + description: "No uncommitted changes." + ) + + case .push: + if hasDirtyChanges { + return .init( + action: action, + isAvailable: false, + description: "Commit changes first." + ) + } + + if needsPush { + let description = if hasOpenPR { + "Push the current branch to the existing PR branch." + } else if !hasUpstream { + "Push the current branch and set upstream if needed." + } else { + "Push the current branch." + } + + return .init( + action: action, + isAvailable: true, + description: description + ) + } + + return .init( + action: action, + isAvailable: false, + description: "Nothing to push." + ) + + case .openPR: + if hasDirtyChanges { + return .init( + action: action, + isAvailable: false, + description: "Commit changes first." + ) + } + + if hasOpenPR { + return .init( + action: action, + isAvailable: false, + description: "A PR is already open for this branch." + ) + } + + if !hasUpstream || aheadCount > 0 { + return .init( + action: action, + isAvailable: false, + description: "Push the branch first." + ) + } + + return .init( + action: action, + isAvailable: true, + description: "Open a PR for the current branch." + ) + + case .updatePR: + if snapshot.openPR == nil { + return .init( + action: action, + isAvailable: false, + description: "No open PR for this branch." + ) + } + + if hasDirtyChanges { + return .init( + action: action, + isAvailable: false, + description: "Commit changes first." + ) + } + + if needsPush { + return .init( + action: action, + isAvailable: false, + description: "Push latest commits first." + ) + } + + return .init( + action: action, + isAvailable: true, + description: "Update the existing PR text if needed." + ) + + case .commitAndPush, .pushAndOpenPR, .pushAndUpdatePR: + preconditionFailure("combo actions are not base menu actions") + + case .smart: + preconditionFailure("smart is not a menu action") + } + } + } + + static func prompt( + for action: TerminalRepoPromptAction, + readyState: TerminalRepoPromptReadyState + ) -> String { + let snapshot = readyState.snapshot + let resolvedAction = action == .smart ? readyState.primaryAction : action + let location = "In \(snapshot.repoRoot) on branch \(snapshot.branch)" + let existingPR = snapshot.openPR.map { "PR #\($0.number)" } + + let prompt: String = switch resolvedAction { + case .commit: + "\(location), create exactly one appropriate commit for the current changes. Do not push or open or update a PR. If blocked, say why and stop." + + case .commitAndPush: + "\(location), create exactly one appropriate commit for the current changes, then push the branch. Do not open or update a PR. If blocked, say why and stop." + + case .push: + "\(location), push the current branch and set upstream if needed. Do not create or update a PR. If there is nothing to push, say so and stop." + + case .pushAndOpenPR: + "\(location), push the current branch and set upstream if needed, then open a PR. Generate a clear title from the branch and changes, and write the PR summary with no markdown headers. If blocked, say why and stop." + + case .openPR: + "\(location), open a PR for the current branch. Generate a clear title from the branch and changes, and write the PR summary with no markdown headers. Do not create extra commits unless needed to unblock the PR. If blocked, say why and stop." + + case .pushAndUpdatePR: + "\(location), push the current branch and set upstream if needed, then update \(existingPR ?? "the existing PR") if needed. Keep the PR summary free of markdown headers. Do not create a new PR. If blocked, say why and stop." + + case .updatePR: + "\(location), update \(existingPR ?? "the existing PR") if needed. Keep the PR summary free of markdown headers. Do not push or create commits or create a new PR. If the PR text is already correct, say so and stop." + + case .smart: + preconditionFailure("smart must resolve to a concrete action") + } + + return prompt + "\n" + } + + @MainActor + static func resolve( + pwd: String?, + worktrunkStore: WorktrunkStore?, + gitDiffStore: GitDiffStore = GitDiffStore() + ) async -> TerminalRepoPromptResolution { + guard let pwd, !pwd.isEmpty else { + return .disabled(.noFocusedTerminal) + } + + guard let repoRoot = await gitDiffStore.repoRoot(for: pwd) else { + return .disabled(.noGitRepo) + } + + guard let branch = await gitDiffStore.currentBranch(repoRoot: repoRoot) else { + return .disabled(.detachedHead) + } + + let sessions = worktrunkStore?.sessions(for: repoRoot) ?? [] + + let hasDirtyChanges: Bool + do { + hasDirtyChanges = try await gitDiffStore.statusEntries(repoRoot: repoRoot).isEmpty == false + } catch { + return .disabled(.ghUnavailable(error.localizedDescription)) + } + + do { + _ = try await GHClient.getRepoInfo(repoPath: repoRoot) + } catch let ghError as GHClientError { + switch ghError { + case .notGitHubRepo: + return .disabled(.notGitHubRepo) + default: + return .disabled(.ghUnavailable(ghError.localizedDescription)) + } + } catch { + return .disabled(.ghUnavailable(error.localizedDescription)) + } + + let openPR: PRStatus? + do { + let pr = try await GHClient.prForBranch(repoPath: repoRoot, branch: branch) + openPR = pr?.isOpen == true ? pr : nil + } catch let ghError as GHClientError { + return .disabled(.ghUnavailable(ghError.localizedDescription)) + } catch { + return .disabled(.ghUnavailable(error.localizedDescription)) + } + + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: repoRoot, + branch: branch, + sessions: sessions, + hasDirtyChanges: hasDirtyChanges, + openPR: openPR, + gitTracking: worktrunkStore?.gitTracking(for: repoRoot) + ) + return .ready(classify(snapshot: snapshot)) + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a18309380d..a19bda8ece 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -297,6 +297,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool .flexibleSpace, .space, .openInEditor, + .repoPrompt, ] } @@ -308,6 +309,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool .title, .flexibleSpace, .openInEditor, + .repoPrompt, ] } @@ -342,6 +344,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool return item case .openInEditor: return makeOpenInEditorItem() + case .repoPrompt: + return makeRepoPromptItem() default: return NSToolbarItem(itemIdentifier: itemIdentifier) } @@ -366,6 +370,15 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool return item } + private func makeRepoPromptItem() -> NSToolbarItem { + let controller = windowController as? TerminalController + let item = NSToolbarItem(itemIdentifier: .repoPrompt) + item.label = "Repo Action" + item.toolTip = "Type the next repo prompt into the current AI session" + item.view = RepoPromptSplitButton.make(target: controller) + return item + } + // MARK: SwiftUI class ViewModel: ObservableObject { diff --git a/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift b/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift index 0c20f21e67..cc878ab5cd 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkToolbar.swift @@ -46,11 +46,11 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor] + [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor, .repoPrompt] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor] + [.toggleSidebar, .sidebarTrackingSeparator, .worktrunkTitleText, .flexibleSpace, .openInEditor, .repoPrompt] } func toolbar( @@ -86,6 +86,8 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { return item case .openInEditor: return makeOpenInEditorItem() + case .repoPrompt: + return makeRepoPromptItem() default: return NSToolbarItem(itemIdentifier: itemIdentifier) } @@ -108,6 +110,14 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { return item } + private func makeRepoPromptItem() -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: .repoPrompt) + item.label = "Repo Action" + item.toolTip = "Type the next repo prompt into the current AI session" + item.view = RepoPromptSplitButton.make(target: targetController) + return item + } + private func updateTitleAttributes() { let text = titleTextField.stringValue.isEmpty ? " " : titleTextField.stringValue let baseFont = titleFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) @@ -122,6 +132,7 @@ final class WorktrunkToolbar: NSToolbar, NSToolbarDelegate { extension NSToolbarItem.Identifier { static let worktrunkTitleText = NSToolbarItem.Identifier("WorktrunkTitleText") static let openInEditor = NSToolbarItem.Identifier("OpenInEditor") + static let repoPrompt = NSToolbarItem.Identifier("RepoPrompt") } /// A split button for the "Open in Editor" toolbar item. diff --git a/macos/Tests/TerminalRepoPromptTests.swift b/macos/Tests/TerminalRepoPromptTests.swift new file mode 100644 index 0000000000..68c102531c --- /dev/null +++ b/macos/Tests/TerminalRepoPromptTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import Ghostree + +struct TerminalRepoPromptTests { + private func session(source: SessionSource = .codex) -> AISession { + .init( + id: "session-1", + source: source, + worktreePath: "/tmp/repo", + cwd: "/tmp/repo", + timestamp: Date(timeIntervalSince1970: 123), + snippet: "Working", + sourcePath: "/tmp/session.jsonl", + messageCount: 3 + ) + } + + private func openPR() -> PRStatus { + .init( + number: 42, + title: "Refine prompt actions", + headRefName: "feature/repo-prompts", + state: "OPEN", + url: "https://github.com/sidequery/ghostree/pull/42", + checks: [], + updatedAt: Date(timeIntervalSince1970: 200), + fetchedAt: Date(timeIntervalSince1970: 201) + ) + } + + @Test func dirtySnapshotResolvesToCommit() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: true, + openPR: nil, + gitTracking: nil + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .commit) + #expect(ready.shortcutAction?.action == .commitAndPush) + #expect(ready.state(for: .commit)?.isAvailable == true) + #expect(ready.state(for: .push)?.description == "Commit changes first.") + #expect(ready.state(for: .openPR)?.description == "Commit changes first.") + #expect(ready.state(for: .updatePR)?.description == "No open PR for this branch.") + } + + @Test func dirtySnapshotWithoutKnownSessionsStillResolves() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [], + hasDirtyChanges: true, + openPR: nil, + gitTracking: nil + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .commit) + #expect(ready.shortcutAction?.action == .commitAndPush) + #expect(ready.state(for: .commit)?.isAvailable == true) + } + + @Test func cleanSnapshotWithoutPRResolvesToPushBeforeOpenPR() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: nil, + gitTracking: .init( + hasUpstream: true, + ahead: 1, + behind: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + totalChangesCount: 0, + lineAdditions: 0, + lineDeletions: 0 + ) + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .push) + #expect(ready.shortcutAction?.action == .pushAndOpenPR) + #expect(ready.state(for: .push)?.isAvailable == true) + #expect(ready.state(for: .openPR)?.description == "Push the branch first.") + } + + @Test func cleanSnapshotWithRemoteBranchResolvesToOpenPR() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: nil, + gitTracking: .init( + hasUpstream: true, + ahead: 0, + behind: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + totalChangesCount: 0, + lineAdditions: 0, + lineDeletions: 0 + ) + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .openPR) + #expect(ready.shortcutAction == nil) + #expect(ready.state(for: .openPR)?.isAvailable == true) + #expect(ready.state(for: .push)?.description == "Nothing to push.") + } + + @Test func cleanSnapshotWithOpenPRAndAheadResolvesToPush() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: openPR(), + gitTracking: .init( + hasUpstream: true, + ahead: 2, + behind: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + totalChangesCount: 0, + lineAdditions: 0, + lineDeletions: 0 + ) + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .push) + #expect(ready.shortcutAction?.action == .pushAndUpdatePR) + #expect(ready.state(for: .updatePR)?.description == "Push latest commits first.") + } + + @Test func cleanSnapshotWithOpenPRAndNoPushNeededResolvesToUpdatePR() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: openPR(), + gitTracking: .init( + hasUpstream: true, + ahead: 0, + behind: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + totalChangesCount: 0, + lineAdditions: 0, + lineDeletions: 0 + ) + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .updatePR) + #expect(ready.shortcutAction == nil) + #expect(ready.state(for: .updatePR)?.isAvailable == true) + #expect(ready.state(for: .push)?.description == "Nothing to push.") + } + + @Test func openPRPromptIsCompactAndIncludesHeaderRule() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session(source: .claude), session(source: .codex)], + hasDirtyChanges: false, + openPR: nil, + gitTracking: nil + ) + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + + let prompt = TerminalRepoPrompt.prompt(for: .openPR, readyState: ready) + + #expect(prompt.contains("In /tmp/repo on branch feature/repo-prompts")) + #expect(prompt.contains("write the PR summary with no markdown headers")) + #expect(!prompt.contains("Known AI session sources")) + #expect(!prompt.contains("Resolved action:")) + } + + @Test func commitAndPushPromptIncludesPushStep() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: true, + openPR: nil, + gitTracking: nil + ) + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + + let prompt = TerminalRepoPrompt.prompt(for: .commitAndPush, readyState: ready) + + #expect(prompt.contains("create exactly one appropriate commit")) + #expect(prompt.contains("then push the branch")) + } + + @Test func updatePRPromptIncludesExistingPRContext() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: openPR(), + gitTracking: nil + ) + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + + let prompt = TerminalRepoPrompt.prompt(for: .updatePR, readyState: ready) + + #expect(prompt.contains("update PR #42 if needed")) + #expect(prompt.contains("Do not push or create commits or create a new PR.")) + } +} From bc3d3be5dec8c5a04cff44c28effccd88bf4e80c Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 11 Mar 2026 07:50:38 -0700 Subject: [PATCH 2/4] Handle unknown repo tracking state --- .../Terminal/TerminalRepoPrompt.swift | 39 +++++++++++++++++-- macos/Tests/TerminalRepoPromptTests.swift | 34 ++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift index 699ea6ce92..96db5bb931 100644 --- a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift +++ b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift @@ -118,7 +118,7 @@ enum TerminalRepoPromptResolution: Equatable { enum TerminalRepoPrompt { static func classify(snapshot: TerminalRepoPromptSnapshot) -> TerminalRepoPromptReadyState { let actionStates = actionStates(for: snapshot) - let primaryAction = actionStates.first(where: \.isAvailable)?.action ?? .commit + let primaryAction = primaryAction(for: snapshot) let shortcutAction = shortcutAction(for: snapshot, primaryAction: primaryAction) return .init( @@ -129,14 +129,32 @@ enum TerminalRepoPrompt { ) } + private static func primaryAction(for snapshot: TerminalRepoPromptSnapshot) -> TerminalRepoPromptAction { + if snapshot.hasDirtyChanges { + return .commit + } + + let trackingKnown = snapshot.gitTracking != nil + let hasUpstream = snapshot.gitTracking?.hasUpstream ?? false + let aheadCount = snapshot.gitTracking?.ahead ?? 0 + let needsPush = trackingKnown && (!hasUpstream || aheadCount > 0) + + if snapshot.openPR != nil { + return needsPush ? .push : .updatePR + } + + return needsPush ? .push : .openPR + } + private static func shortcutAction( for snapshot: TerminalRepoPromptSnapshot, primaryAction: TerminalRepoPromptAction ) -> TerminalRepoPromptShortcutState? { let hasOpenPR = snapshot.openPR != nil + let trackingKnown = snapshot.gitTracking != nil let hasUpstream = snapshot.gitTracking?.hasUpstream ?? false let aheadCount = snapshot.gitTracking?.ahead ?? 0 - let needsPush = !hasUpstream || aheadCount > 0 + let needsPush = trackingKnown && (!hasUpstream || aheadCount > 0) switch primaryAction { case .commit: @@ -167,9 +185,10 @@ enum TerminalRepoPrompt { ) -> [TerminalRepoPromptActionState] { let hasDirtyChanges = snapshot.hasDirtyChanges let hasOpenPR = snapshot.openPR != nil + let trackingKnown = snapshot.gitTracking != nil let hasUpstream = snapshot.gitTracking?.hasUpstream ?? false let aheadCount = snapshot.gitTracking?.ahead ?? 0 - let needsPush = !hasUpstream || aheadCount > 0 + let needsPush = trackingKnown && (!hasUpstream || aheadCount > 0) return TerminalRepoPromptAction.menuActions.map { action in switch action { @@ -197,6 +216,20 @@ enum TerminalRepoPrompt { ) } + if !trackingKnown { + let description = if hasOpenPR { + "Push the current branch to the existing PR branch if needed." + } else { + "Push the current branch and set upstream if needed." + } + + return .init( + action: action, + isAvailable: true, + description: description + ) + } + if needsPush { let description = if hasOpenPR { "Push the current branch to the existing PR branch." diff --git a/macos/Tests/TerminalRepoPromptTests.swift b/macos/Tests/TerminalRepoPromptTests.swift index 68c102531c..65186e0747 100644 --- a/macos/Tests/TerminalRepoPromptTests.swift +++ b/macos/Tests/TerminalRepoPromptTests.swift @@ -118,6 +118,23 @@ struct TerminalRepoPromptTests { #expect(ready.state(for: .push)?.description == "Nothing to push.") } + @Test func cleanSnapshotWithoutTrackingResolvesToOpenPR() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: nil, + gitTracking: nil + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .openPR) + #expect(ready.shortcutAction == nil) + #expect(ready.state(for: .openPR)?.isAvailable == true) + #expect(ready.state(for: .push)?.isAvailable == true) + } + @Test func cleanSnapshotWithOpenPRAndAheadResolvesToPush() { let snapshot = TerminalRepoPromptSnapshot( repoRoot: "/tmp/repo", @@ -171,6 +188,23 @@ struct TerminalRepoPromptTests { #expect(ready.state(for: .push)?.description == "Nothing to push.") } + @Test func cleanSnapshotWithOpenPRAndUnknownTrackingResolvesToUpdatePR() { + let snapshot = TerminalRepoPromptSnapshot( + repoRoot: "/tmp/repo", + branch: "feature/repo-prompts", + sessions: [session()], + hasDirtyChanges: false, + openPR: openPR(), + gitTracking: nil + ) + + let ready = TerminalRepoPrompt.classify(snapshot: snapshot) + #expect(ready.primaryAction == .updatePR) + #expect(ready.shortcutAction == nil) + #expect(ready.state(for: .updatePR)?.isAvailable == true) + #expect(ready.state(for: .push)?.isAvailable == true) + } + @Test func openPRPromptIsCompactAndIncludesHeaderRule() { let snapshot = TerminalRepoPromptSnapshot( repoRoot: "/tmp/repo", From efaf0484f92fbd90eafe097b35507e5a41ec1bf5 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 11 Mar 2026 08:55:56 -0700 Subject: [PATCH 3/4] Fix repo prompt CI regressions --- macos/Sources/Features/Terminal/RepoPromptSplitButton.swift | 2 ++ macos/Sources/Features/Terminal/TerminalRepoPrompt.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift b/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift index 2a1e246431..5c5d7da872 100644 --- a/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift +++ b/macos/Sources/Features/Terminal/RepoPromptSplitButton.swift @@ -1,3 +1,4 @@ +#if os(macOS) import AppKit enum RepoPromptSplitButton { @@ -127,3 +128,4 @@ enum RepoPromptSplitButton { return item } } +#endif diff --git a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift index 96db5bb931..ab8e1e0edd 100644 --- a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift +++ b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift @@ -269,7 +269,7 @@ enum TerminalRepoPrompt { ) } - if !hasUpstream || aheadCount > 0 { + if trackingKnown && (!hasUpstream || aheadCount > 0) { return .init( action: action, isAvailable: false, From a18574ebe9536168ba4579201413bce7d3cd2a81 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Wed, 11 Mar 2026 17:54:33 -0700 Subject: [PATCH 4/4] Guard repo prompt types for iOS builds --- macos/Sources/Features/Terminal/TerminalRepoPrompt.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift index ab8e1e0edd..049752e6cd 100644 --- a/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift +++ b/macos/Sources/Features/Terminal/TerminalRepoPrompt.swift @@ -1,3 +1,4 @@ +#if os(macOS) import Foundation enum TerminalRepoPromptAction: String, CaseIterable { @@ -422,3 +423,4 @@ enum TerminalRepoPrompt { return .ready(classify(snapshot: snapshot)) } } +#endif