Skip to content
Merged
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
14 changes: 14 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ public final class AppState {
/// Called to open a terminal at a session's primary worktree path.
public var onOpenTerminal: ((UUID) -> Void)?

/// Called when the user clicks a quick action button on a session card
/// (e.g. "Merge PR", "Rebase & Fix Conflicts"). Receives the session ID
/// and the action chosen; the wired handler injects the corresponding
/// prompt into the session's managed Claude Code terminal.
public var onQuickAction: ((UUID, QuickAction) -> Void)?

/// Called when the sound mute toggle is changed.
public var onSoundMutedChanged: ((Bool) -> Void)?

Expand Down Expand Up @@ -291,6 +297,14 @@ public final class AppState {
terminals[sessionID] ?? []
}

/// Whether a session has a managed Claude Code terminal that quick
/// actions can be dispatched into. The dispatcher in AppDelegate
/// re-checks the surface state before sending; this is the lighter
/// gate the view uses to decide whether to enable the buttons.
public func canDispatchQuickAction(sessionID: UUID) -> Bool {
terminals(for: sessionID).contains(where: { $0.isManaged })
}

public func primaryWorktree(for sessionID: UUID) -> SessionWorktree? {
worktrees[sessionID]?.first(where: { $0.isPrimary }) ?? worktrees[sessionID]?.first
}
Expand Down
20 changes: 20 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/QuickAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// A user-triggered next step that maps 1:1 to a PR status badge on the
/// session card. Selecting the action injects a deterministic prompt into
/// the session's managed Claude Code terminal so the user can act without
/// switching focus into the session.
///
/// Mirrors the auto-respond pipeline (`AutoRespondCoordinator`) but bypasses
/// the per-toggle `AutoRespondSettings` gate — the user clicked, so intent
/// is explicit.
public enum QuickAction: String, Sendable, Equatable {
/// `mergeable == .conflicting` — rebase onto base and resolve conflicts.
case fixConflicts
/// `reviewStatus == .changesRequested` — read review feedback and fix.
case addressChanges
/// `checksPass == .failing` — investigate failing checks and fix.
case fixChecks
/// `isReadyToMerge` — merge the PR.
case mergePR
}
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ public final class TmuxBackend {
sharedSurface
}

/// Whether `id` has a live tmux-window binding. Used by callers that
/// want to gate a send/destroy/makeActive on "this terminal is actually
/// wired up" without relying on the throwing dispatch path.
public func isRegistered(id: UUID) -> Bool {
bindings[id] != nil
}

// MARK: - Internal helpers

private func ensureRunningServer() throws -> TmuxController {
Expand Down
73 changes: 73 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ public struct SessionDetailView: View {

// Action buttons (not for Manager)
if session.id != AppState.managerSessionID {
quickActionButtons

if appState.vsCodeAvailable, primaryWorktree != nil {
Button {
appState.onOpenInVSCode?(session.id)
Expand Down Expand Up @@ -218,6 +220,77 @@ public struct SessionDetailView: View {
BrandmarkImage()
}

// MARK: - Quick Action Buttons

/// PR-status-aware buttons that inject the appropriate next-step prompt
/// into the session's managed Claude Code terminal. Each button maps
/// 1:1 to a status badge so the affordance is obvious. Hidden when the
/// PR is merged or no status is known; disabled when the session has
/// no managed terminal.
@ViewBuilder
private var quickActionButtons: some View {
if let status = appState.prStatus[session.id], !status.isMerged {
let canDispatch = appState.canDispatchQuickAction(sessionID: session.id)
let disabledHelp = "No managed Claude Code terminal in this session"

if status.mergeable == .conflicting {
Button {
appState.onQuickAction?(session.id, .fixConflicts)
} label: {
Label("Rebase & Fix Conflicts", systemImage: "arrow.triangle.merge")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
.disabled(!canDispatch)
.help(canDispatch ? "Send a rebase + resolve-conflicts prompt to the session terminal" : disabledHelp)
}

if status.reviewStatus == .changesRequested {
Button {
appState.onQuickAction?(session.id, .addressChanges)
} label: {
Label("Address Review", systemImage: "pencil.and.list.clipboard")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
.disabled(!canDispatch)
.help(canDispatch ? "Send a 'read review feedback and fix' prompt to the session terminal" : disabledHelp)
}

if status.checksPass == .failing {
Button {
appState.onQuickAction?(session.id, .fixChecks)
} label: {
Label("Fix Checks", systemImage: "exclamationmark.triangle")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(.red)
.disabled(!canDispatch)
.help(canDispatch ? "Send a 'fix failing CI checks' prompt to the session terminal" : disabledHelp)
}

if status.isReadyToMerge {
Button {
appState.onQuickAction?(session.id, .mergePR)
} label: {
Label("Merge PR", systemImage: "arrow.triangle.pull")
.font(.caption)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(.green)
.disabled(!canDispatch)
.help(canDispatch ? "Send a 'merge this PR' prompt to the session terminal" : disabledHelp)
}
}
}

// MARK: - Terminal Area

@ViewBuilder
Expand Down
5 changes: 5 additions & 0 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
)

// Wire session-card quick action buttons through the same coordinator.
appState.onQuickAction = { [weak self] sessionID, action in
self?.autoRespondCoordinator?.dispatchManual(action: action, sessionID: sessionID)
}

// Initialize allow list service
let allowList = AllowListService(appState: appState, devRoot: devRoot)
self.allowListService = allowList
Expand Down
102 changes: 100 additions & 2 deletions Sources/Crow/App/AutoRespondCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ final class AutoRespondCoordinator {
transition.kind.rawValue, transition.sessionID.uuidString)
return
}
guard TerminalManager.shared.existingSurface(for: terminal.id) != nil else {
guard TerminalRouter.canSend(terminal) else {
NSLog("[AutoRespond] Skipping %@ for session %@: terminal surface not initialized",
transition.kind.rawValue, transition.sessionID.uuidString)
return
Expand All @@ -57,7 +57,43 @@ final class AutoRespondCoordinator {
let prompt = AutoRespondPrompts.build(for: transition, provider: provider)
NSLog("[AutoRespond] Sending %@ prompt to terminal %@ (%d chars)",
transition.kind.rawValue, terminal.id.uuidString, prompt.count)
TerminalManager.shared.send(id: terminal.id, text: prompt)
TerminalRouter.send(terminal, text: prompt)
}

/// Manually dispatch a quick action triggered by a session-card button click.
/// Mirrors `dispatch(_:)` but bypasses the `AutoRespondSettings` toggle —
/// the click is the user's explicit consent. Resolves the PR URL/number
/// from the session's `.pr` link.
func dispatchManual(action: QuickAction, sessionID: UUID) {
let terminals = appState.terminals(for: sessionID)
guard let terminal = terminals.first(where: { $0.isManaged }) else {
NSLog("[QuickAction] Skipping %@ for session %@: no managed terminal",
action.rawValue, sessionID.uuidString)
return
}
guard TerminalRouter.canSend(terminal) else {
NSLog("[QuickAction] Skipping %@ for session %@: terminal surface not initialized",
action.rawValue, sessionID.uuidString)
return
}
guard let prLink = appState.links(for: sessionID).first(where: { $0.linkType == .pr }) else {
NSLog("[QuickAction] Skipping %@ for session %@: no PR link",
action.rawValue, sessionID.uuidString)
return
}

let session = appState.sessions.first(where: { $0.id == sessionID })
let provider = session?.provider ?? .github
let prNumber = QuickActionPrompts.parsePRNumber(from: prLink.url)
let prompt = QuickActionPrompts.build(
action: action,
provider: provider,
prURL: prLink.url,
prNumber: prNumber
)
NSLog("[QuickAction] Sending %@ prompt to terminal %@ (%d chars)",
action.rawValue, terminal.id.uuidString, prompt.count)
TerminalRouter.send(terminal, text: prompt)
}
}

Expand Down Expand Up @@ -107,3 +143,65 @@ enum AutoRespondPrompts {
}
}
}

/// Builds prompts for **manually-triggered** quick actions on a session
/// card. Same single-line + `\n` contract as `AutoRespondPrompts`. The
/// `addressChanges` and `fixChecks` cases delegate to `AutoRespondPrompts`
/// so the auto and manual paths share a single source of truth.
enum QuickActionPrompts {
static func build(action: QuickAction, provider: Provider, prURL: String, prNumber: Int?) -> String {
let prRef = prNumber.map { "PR #\($0)" } ?? "the PR"
let cli = provider == .gitlab ? "glab" : "gh"

switch action {
case .addressChanges:
// Reuse the existing changes-requested prompt verbatim.
let synthetic = PRStatusTransition(
kind: .changesRequested,
sessionID: UUID(), // unused by AutoRespondPrompts.build
prURL: prURL,
prNumber: prNumber
)
return AutoRespondPrompts.build(for: synthetic, provider: provider)

case .fixChecks:
// Reuse the existing checks-failing prompt verbatim. We don't
// know the failing check names from a manual click; the prompt
// tells Claude how to discover them.
let synthetic = PRStatusTransition(
kind: .checksFailing,
sessionID: UUID(),
prURL: prURL,
prNumber: prNumber
)
return AutoRespondPrompts.build(for: synthetic, provider: provider)

case .fixConflicts:
let rebaseHint: String
if provider == .gitlab {
rebaseHint = "Rebase your branch onto the latest target branch (`git fetch origin && git rebase origin/<target>` or `glab mr rebase`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the MR."
} else {
rebaseHint = "Rebase your branch onto the latest base branch (`git fetch origin && git rebase origin/<base>`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the PR."
}
return "Crow detected merge conflicts on \(prRef) (\(prURL)). \(rebaseHint)\n"

case .mergePR:
let mergeHint: String
if provider == .gitlab {
mergeHint = "Run `glab mr view \(prURL)` to verify the MR is in the expected state, then `glab mr merge \(prURL)` to merge. If the project uses a different merge strategy or extra steps, adjust accordingly."
} else {
mergeHint = "Run `\(cli) pr view \(prURL)` to verify the PR is in the expected state, then `\(cli) pr merge \(prURL) --squash --delete-branch` to merge. If the repo uses a different merge strategy, adjust accordingly."
}
return "Merge \(prRef) (\(prURL)). \(mergeHint)\n"
}
}

/// Extract the trailing numeric segment from a PR/MR URL (e.g.
/// `https://github.com/org/repo/pull/123` → `123`,
/// `https://gitlab.example.com/org/repo/-/merge_requests/45` → `45`).
/// Returns nil if the last path component isn't an integer.
static func parsePRNumber(from url: String) -> Int? {
guard let last = url.split(separator: "/").last else { return nil }
return Int(last)
}
}
14 changes: 14 additions & 0 deletions Sources/Crow/App/TerminalRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,18 @@ public enum TerminalRouter {
break
}
}

/// Whether the terminal's backing surface (Ghostty) or window
/// (tmux) is alive enough to receive a `send`. Callers that want to
/// fail-soft when the user hasn't materialized the terminal yet — e.g.
/// auto-respond and the session-card quick action buttons — gate on
/// this instead of relying on the underlying send to throw.
public static func canSend(_ terminal: SessionTerminal) -> Bool {
switch terminal.backend {
case .ghostty:
return TerminalManager.shared.existingSurface(for: terminal.id) != nil
case .tmux:
return TmuxBackend.shared.isRegistered(id: terminal.id)
}
}
}
Loading