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
38 changes: 38 additions & 0 deletions Muxy/Models/VCSTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ final class VCSTabState {
var isCommitting = false
var isPushing = false
var isPulling = false
var isGeneratingCommitMessage = false
var isSwitchingBranch = false
var isLoadingBranches = false
var statusMessage: String?
Expand Down Expand Up @@ -182,6 +183,7 @@ final class VCSTabState {
@ObservationIgnored private var commitLogTask: Task<Void, Never>?
@ObservationIgnored private var prListTask: Task<Void, Never>?
@ObservationIgnored private var prAutoSyncTask: Task<Void, Never>?
@ObservationIgnored private var aiGenerationTask: Task<Void, Never>?
@ObservationIgnored private var watcher: FileSystemWatcher?
@ObservationIgnored nonisolated(unsafe) private var remoteChangeObserver: NSObjectProtocol?
@ObservationIgnored private var isRefreshing = false
Expand Down Expand Up @@ -659,6 +661,42 @@ final class VCSTabState {
}
}

func generateCommitMessageWithAI() {
guard hasAnyChanges else {
showStatus("No changes to summarize.", isError: true)
return
}
if isGeneratingCommitMessage { return }
isGeneratingCommitMessage = true
let path = projectPath
let branch = branchName
aiGenerationTask?.cancel()
aiGenerationTask = Task { [weak self] in
do {
let message = try await AIAssistantService.generateCommitMessage(
repoPath: path,
branch: branch
)
guard let self, !Task.isCancelled else { return }
commitMessage = message
} catch is CancellationError {
return
} catch {
guard let self, !Task.isCancelled else { return }
showStatus(errorText(error), isError: true)
}
guard let self else { return }
isGeneratingCommitMessage = false
aiGenerationTask = nil
}
}

func cancelCommitMessageGeneration() {
aiGenerationTask?.cancel()
aiGenerationTask = nil
isGeneratingCommitMessage = false
}

func push() {
isPushing = true
Task { [weak self] in
Expand Down
65 changes: 65 additions & 0 deletions Muxy/Services/AIAssistant/AIAssistantPrompts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

enum AIAssistantTask {
case commitMessage
case pullRequest
}

enum AIAssistantPrompts {
static let defaultCommitUserPrompt = """
Write a concise, conventional git commit message for the staged changes below.
- Subject line in imperative mood, max 72 characters.
- Optional body, wrapped at 72 characters, explaining the why.
- Do not include code fences, quotes, or any extra commentary.
"""

static let defaultPullRequestUserPrompt = """
Write a pull request title and short description for the diff below.
- Title: short, imperative, max 70 characters.
- Description: 1-3 short bullet points or sentences focused on the why.
- Do not include code fences or extra commentary.
"""

static func systemPrompt(for task: AIAssistantTask) -> String {
switch task {
case .commitMessage:
"""
You are an assistant that generates git commit messages.
Output ONLY the commit message in plain text. No code fences, no preamble, no JSON.
Subject line first, then a blank line, then optional body. No trailing notes.
"""
case .pullRequest:
"""
You are an assistant that generates pull request metadata.
Output ONLY a single JSON object with exactly two string keys: "title" and "body".
No code fences, no preamble, no trailing text. Example:
{"title": "Fix crash on launch", "body": "Avoid blocking DNS by removing hostName lookup."}
"""
}
}

static func composedPrompt(
for task: AIAssistantTask,
userPrompt: String,
diff: String,
branch: String?,
baseBranch: String?
) -> String {
let trimmedUser = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
var sections: [String] = [systemPrompt(for: task), trimmedUser]

var contextLines: [String] = []
if let branch, !branch.isEmpty {
contextLines.append("Current branch: \(branch)")
}
if let baseBranch, !baseBranch.isEmpty {
contextLines.append("Base branch: \(baseBranch)")
}
if !contextLines.isEmpty {
sections.append(contextLines.joined(separator: "\n"))
}

sections.append("Diff:\n\(diff)")
return sections.joined(separator: "\n\n")
}
}
54 changes: 54 additions & 0 deletions Muxy/Services/AIAssistant/AIAssistantProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

enum AIAssistantProvider: String, CaseIterable, Identifiable, Codable {
case claude
case codex
case opencode
case custom

var id: String { rawValue }

var displayName: String {
switch self {
case .claude: "Claude Code"
case .codex: "Codex"
case .opencode: "OpenCode"
case .custom: "Custom Command"
}
}

var defaultExecutable: String {
switch self {
case .claude: "claude"
case .codex: "codex"
case .opencode: "opencode"
case .custom: ""
}
}

func builtInArguments(model: String?) -> [String] {
switch self {
case .claude:
var args = ["-p", "--output-format", "text"]
if let model, !model.isEmpty {
args.append(contentsOf: ["--model", model])
}
return args
case .codex:
var args = ["exec", "--skip-git-repo-check"]
if let model, !model.isEmpty {
args.append(contentsOf: ["--model", model])
}
args.append("-")
return args
case .opencode:
var args = ["run"]
if let model, !model.isEmpty {
args.append(contentsOf: ["--model", model])
}
return args
case .custom:
return []
}
}
}
Loading
Loading