diff --git a/Muxy/Models/VCSTabState.swift b/Muxy/Models/VCSTabState.swift index 3253acde..1dbe13ac 100644 --- a/Muxy/Models/VCSTabState.swift +++ b/Muxy/Models/VCSTabState.swift @@ -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? @@ -182,6 +183,7 @@ final class VCSTabState { @ObservationIgnored private var commitLogTask: Task? @ObservationIgnored private var prListTask: Task? @ObservationIgnored private var prAutoSyncTask: Task? + @ObservationIgnored private var aiGenerationTask: Task? @ObservationIgnored private var watcher: FileSystemWatcher? @ObservationIgnored nonisolated(unsafe) private var remoteChangeObserver: NSObjectProtocol? @ObservationIgnored private var isRefreshing = false @@ -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 diff --git a/Muxy/Services/AIAssistant/AIAssistantPrompts.swift b/Muxy/Services/AIAssistant/AIAssistantPrompts.swift new file mode 100644 index 00000000..66160732 --- /dev/null +++ b/Muxy/Services/AIAssistant/AIAssistantPrompts.swift @@ -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") + } +} diff --git a/Muxy/Services/AIAssistant/AIAssistantProvider.swift b/Muxy/Services/AIAssistant/AIAssistantProvider.swift new file mode 100644 index 00000000..e4bec3ee --- /dev/null +++ b/Muxy/Services/AIAssistant/AIAssistantProvider.swift @@ -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 [] + } + } +} diff --git a/Muxy/Services/AIAssistant/AIAssistantRunner.swift b/Muxy/Services/AIAssistant/AIAssistantRunner.swift new file mode 100644 index 00000000..47d5ae24 --- /dev/null +++ b/Muxy/Services/AIAssistant/AIAssistantRunner.swift @@ -0,0 +1,305 @@ +import Foundation + +enum AIAssistantRunnerError: Error, LocalizedError { + case providerNotConfigured(String) + case commandNotFound(String) + case nonZeroExit(status: Int32, stderr: String) + case emptyOutput + case launchFailed(String) + case parsingFailed(String) + case cancelled + + var errorDescription: String? { + switch self { + case let .providerNotConfigured(message): message + case let .commandNotFound(name): + "Could not run \(name). Make sure it is installed and available in your shell's PATH." + case let .nonZeroExit(status, stderr): + stderr.isEmpty ? "Provider exited with status \(status)." : stderr + case .emptyOutput: "Provider returned an empty response." + case let .launchFailed(message): "Failed to start provider: \(message)" + case let .parsingFailed(message): message + case .cancelled: "Generation cancelled." + } + } +} + +struct AIAssistantInvocation { + enum Kind { + case direct(executable: String, arguments: [String]) + case shell(commandLine: String) + } + + let kind: Kind + let displayName: String +} + +enum AIAssistantRunner { + private static let runQueue = DispatchQueue( + label: "app.muxy.ai-assistant", + qos: .userInitiated, + attributes: .concurrent + ) + + private static let stderrDrainQueue = DispatchQueue( + label: "app.muxy.ai-assistant-stderr", + qos: .userInitiated, + attributes: .concurrent + ) + + private static let shellPathSearch = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + ] + + static func resolveInvocation( + provider: AIAssistantProvider, + customCommand: String, + model: String? + ) throws -> AIAssistantInvocation { + if provider == .custom { + let trimmed = customCommand.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw AIAssistantRunnerError.providerNotConfigured( + "Custom command is empty. Configure it in Settings → AI." + ) + } + return AIAssistantInvocation(kind: .shell(commandLine: trimmed), displayName: firstToken(trimmed)) + } + let executable = provider.defaultExecutable + guard let resolved = resolveExecutable(executable) else { + throw AIAssistantRunnerError.commandNotFound(executable) + } + return AIAssistantInvocation( + kind: .direct(executable: resolved, arguments: provider.builtInArguments(model: model)), + displayName: executable + ) + } + + static func run( + invocation: AIAssistantInvocation, + prompt: String, + workingDirectory: String + ) async throws -> String { + let handle = ProcessHandle() + return try await withTaskCancellationHandler { + try await dispatch { + try executeSync( + invocation: invocation, + prompt: prompt, + workingDirectory: workingDirectory, + handle: handle + ) + } + } onCancel: { + handle.terminate() + } + } + + private static func dispatch( + _ work: @escaping @Sendable () throws -> String + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + runQueue.async { + do { + try continuation.resume(returning: work()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func executeSync( + invocation: AIAssistantInvocation, + prompt: String, + workingDirectory: String, + handle: ProcessHandle + ) throws -> String { + let process = Process() + switch invocation.kind { + case let .direct(executable, arguments): + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = enrichedEnvironment() + case let .shell(commandLine): + process.executableURL = URL(fileURLWithPath: userShell()) + process.arguments = ["-l", "-c", commandLine] + } + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + throw AIAssistantRunnerError.launchFailed(error.localizedDescription) + } + + guard handle.attach(process) else { + process.waitUntilExit() + throw AIAssistantRunnerError.cancelled + } + defer { handle.detach() } + + let stderrCollector = AsyncDataCollector() + stderrCollector.start(reading: stderrPipe.fileHandleForReading, on: stderrDrainQueue) + + if let data = prompt.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + try? stdinPipe.fileHandleForWriting.close() + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let stderrData = stderrCollector.wait() + + if handle.wasCancelled { + throw AIAssistantRunnerError.cancelled + } + + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus == 127 || stderr.contains("command not found") { + throw AIAssistantRunnerError.commandNotFound(invocation.displayName) + } + if process.terminationStatus != 0 { + throw AIAssistantRunnerError.nonZeroExit(status: process.terminationStatus, stderr: stderr) + } + let trimmed = stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw AIAssistantRunnerError.emptyOutput + } + return trimmed + } + + private static func resolveExecutable(_ name: String) -> String? { + if let direct = GitProcessRunner.resolveExecutable(name) { + return direct + } + for directory in shellPathSearch { + let path = "\(directory)/\(name)" + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return resolveViaUserShell(name) + } + + private static func resolveViaUserShell(_ name: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: userShell()) + process.arguments = ["-l", "-c", "command -v \(name)"] + let stdoutPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !path.isEmpty, FileManager.default.isExecutableFile(atPath: path) else { return nil } + return path + } + + private static func enrichedEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let existing = environment["PATH"] ?? "" + let extras = shellPathSearch.filter { !existing.contains($0) } + if !extras.isEmpty { + environment["PATH"] = (extras + [existing]).filter { !$0.isEmpty }.joined(separator: ":") + } + return environment + } + + private static func userShell() -> String { + if let shell = ProcessInfo.processInfo.environment["SHELL"], !shell.isEmpty { + return shell + } + guard let pw = getpwuid(getuid()), let shellPtr = pw.pointee.pw_shell else { + return "/bin/zsh" + } + return String(cString: shellPtr) + } + + private static func firstToken(_ command: String) -> String { + command.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? command + } +} + +private final class ProcessHandle: @unchecked Sendable { + private let lock = NSLock() + private var process: Process? + private var cancelled = false + + var wasCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func attach(_ process: Process) -> Bool { + lock.lock() + defer { lock.unlock() } + if cancelled { + terminateRunning(process) + return false + } + self.process = process + return true + } + + func detach() { + lock.lock() + defer { lock.unlock() } + process = nil + } + + func terminate() { + lock.lock() + defer { lock.unlock() } + cancelled = true + guard let process else { return } + terminateRunning(process) + } + + private func terminateRunning(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + } +} + +private final class AsyncDataCollector: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + private let semaphore = DispatchSemaphore(value: 0) + + func start(reading handle: FileHandle, on queue: DispatchQueue) { + queue.async { [self] in + let collected = handle.readDataToEndOfFile() + lock.lock() + data = collected + lock.unlock() + semaphore.signal() + } + } + + func wait() -> Data { + semaphore.wait() + lock.lock() + defer { lock.unlock() } + return data + } +} diff --git a/Muxy/Services/AIAssistant/AIAssistantService.swift b/Muxy/Services/AIAssistant/AIAssistantService.swift new file mode 100644 index 00000000..8200baab --- /dev/null +++ b/Muxy/Services/AIAssistant/AIAssistantService.swift @@ -0,0 +1,201 @@ +import Foundation + +struct AIPullRequestDraft { + let title: String + let body: String +} + +enum AIAssistantServiceError: Error, LocalizedError { + case noChanges + case diffFailed(String) + + var errorDescription: String? { + switch self { + case .noChanges: "No changes to summarize." + case let .diffFailed(message): "Failed to read git diff: \(message)" + } + } +} + +@MainActor +enum AIAssistantService { + private static let diffLineLimit = 4000 + private static let truncationMarker = "\n[diff truncated by Muxy at \(diffLineLimit) lines]\n" + + static func generateCommitMessage( + repoPath: String, + branch: String? + ) async throws -> String { + let settings = AIAssistantSettings.snapshot() + let diff = try await stagedDiff(repoPath: repoPath) + guard !diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AIAssistantServiceError.noChanges + } + let prompt = AIAssistantPrompts.composedPrompt( + for: .commitMessage, + userPrompt: settings.userPrompt(for: .commitMessage), + diff: diff, + branch: branch, + baseBranch: nil + ) + let raw = try await runProvider(prompt: prompt, repoPath: repoPath, settings: settings) + return cleanCommitOutput(raw) + } + + static func generatePullRequest( + repoPath: String, + branch: String?, + baseBranch: String? + ) async throws -> AIPullRequestDraft { + let settings = AIAssistantSettings.snapshot() + let diff = try await branchDiff(repoPath: repoPath, branch: branch, baseBranch: baseBranch) + guard !diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AIAssistantServiceError.noChanges + } + let prompt = AIAssistantPrompts.composedPrompt( + for: .pullRequest, + userPrompt: settings.userPrompt(for: .pullRequest), + diff: diff, + branch: branch, + baseBranch: baseBranch + ) + let raw = try await runProvider(prompt: prompt, repoPath: repoPath, settings: settings) + return try parsePullRequest(raw) + } + + private static func runProvider( + prompt: String, + repoPath: String, + settings: AIAssistantSettingsSnapshot + ) async throws -> String { + let invocation = try AIAssistantRunner.resolveInvocation( + provider: settings.provider, + customCommand: settings.customCommand, + model: settings.model(for: settings.provider) + ) + return try await AIAssistantRunner.run( + invocation: invocation, + prompt: prompt, + workingDirectory: repoPath + ) + } + + private static func stagedDiff(repoPath: String) async throws -> String { + let staged = try await runDiff(repoPath: repoPath, arguments: ["diff", "--cached", "--no-color"]) + if !staged.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return staged + } + return try await runDiff(repoPath: repoPath, arguments: ["diff", "--no-color"]) + } + + private static func branchDiff( + repoPath: String, + branch: String?, + baseBranch: String? + ) async throws -> String { + if let baseBranch, let branch, !baseBranch.isEmpty, !branch.isEmpty, baseBranch != branch { + let range = "\(baseBranch)...\(branch)" + let committed = try await runDiff( + repoPath: repoPath, + arguments: ["diff", "--no-color", range] + ) + let working = try await runDiff(repoPath: repoPath, arguments: ["diff", "--no-color", "HEAD"]) + return [committed, working] + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n") + } + return try await runDiff(repoPath: repoPath, arguments: ["diff", "--no-color", "HEAD"]) + } + + private static func runDiff(repoPath: String, arguments: [String]) async throws -> String { + do { + let result = try await GitProcessRunner.runGit( + repoPath: repoPath, + arguments: arguments, + lineLimit: diffLineLimit + ) + if result.status != 0, !result.stderr.isEmpty { + throw AIAssistantServiceError.diffFailed(result.stderr) + } + return result.truncated ? result.stdout + truncationMarker : result.stdout + } catch let error as AIAssistantServiceError { + throw error + } catch { + throw AIAssistantServiceError.diffFailed(error.localizedDescription) + } + } + + private static func cleanCommitOutput(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("```") { + text = stripCodeFence(text) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func stripCodeFence(_ text: String) -> String { + var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + if let first = lines.first, first.hasPrefix("```") { + lines.removeFirst() + } + if let last = lines.last, last.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + lines.removeLast() + } + return lines.joined(separator: "\n") + } + + private static func parsePullRequest(_ raw: String) throws -> AIPullRequestDraft { + let cleaned = stripCodeFence(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + guard let json = extractJSONObject(from: cleaned) else { + throw AIAssistantRunnerError.parsingFailed("Provider response did not contain valid JSON.") + } + guard let data = json.data(using: .utf8) else { + throw AIAssistantRunnerError.parsingFailed("Could not decode provider response.") + } + do { + let object = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + guard let dict = object as? [String: Any] else { + throw AIAssistantRunnerError.parsingFailed("Expected a JSON object with title and body.") + } + let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (dict["body"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if title.isEmpty { + throw AIAssistantRunnerError.parsingFailed("Provider response missing 'title'.") + } + return AIPullRequestDraft(title: title, body: body) + } catch let error as AIAssistantRunnerError { + throw error + } catch { + throw AIAssistantRunnerError.parsingFailed("Invalid JSON in provider response.") + } + } + + private static func extractJSONObject(from text: String) -> String? { + guard let start = text.firstIndex(of: "{") else { return nil } + var depth = 0 + var inString = false + var escape = false + var index = start + while index < text.endIndex { + let char = text[index] + if escape { + escape = false + } else if char == "\\" { + escape = true + } else if char == "\"" { + inString.toggle() + } else if !inString { + if char == "{" { + depth += 1 + } else if char == "}" { + depth -= 1 + if depth == 0 { + return String(text[start ... index]) + } + } + } + index = text.index(after: index) + } + return nil + } +} diff --git a/Muxy/Services/AIAssistant/AIAssistantSettings.swift b/Muxy/Services/AIAssistant/AIAssistantSettings.swift new file mode 100644 index 00000000..1c505a62 --- /dev/null +++ b/Muxy/Services/AIAssistant/AIAssistantSettings.swift @@ -0,0 +1,59 @@ +import Foundation + +struct AIAssistantSettingsSnapshot { + let provider: AIAssistantProvider + let claudeModel: String? + let codexModel: String? + let opencodeModel: String? + let customCommand: String + let commitPrompt: String? + let prPrompt: String? + + func model(for provider: AIAssistantProvider) -> String? { + switch provider { + case .claude: claudeModel + case .codex: codexModel + case .opencode: opencodeModel + case .custom: nil + } + } + + func userPrompt(for task: AIAssistantTask) -> String { + switch task { + case .commitMessage: + commitPrompt ?? AIAssistantPrompts.defaultCommitUserPrompt + case .pullRequest: + prPrompt ?? AIAssistantPrompts.defaultPullRequestUserPrompt + } + } +} + +enum AIAssistantSettings { + static let providerKey = "muxy.ai.assistant.provider" + static let claudeModelKey = "muxy.ai.assistant.model.claude" + static let codexModelKey = "muxy.ai.assistant.model.codex" + static let opencodeModelKey = "muxy.ai.assistant.model.opencode" + static let customCommandKey = "muxy.ai.assistant.customCommand" + static let commitPromptKey = "muxy.ai.assistant.prompt.commit" + static let prPromptKey = "muxy.ai.assistant.prompt.pr" + + static func snapshot() -> AIAssistantSettingsSnapshot { + let defaults = UserDefaults.standard + let providerRaw = defaults.string(forKey: providerKey) ?? AIAssistantProvider.claude.rawValue + let provider = AIAssistantProvider(rawValue: providerRaw) ?? .claude + return AIAssistantSettingsSnapshot( + provider: provider, + claudeModel: trimmed(defaults.string(forKey: claudeModelKey)), + codexModel: trimmed(defaults.string(forKey: codexModelKey)), + opencodeModel: trimmed(defaults.string(forKey: opencodeModelKey)), + customCommand: defaults.string(forKey: customCommandKey) ?? "", + commitPrompt: trimmed(defaults.string(forKey: commitPromptKey)), + prPrompt: trimmed(defaults.string(forKey: prPromptKey)) + ) + } + + private static func trimmed(_ value: String?) -> String? { + let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return (value?.isEmpty ?? true) ? nil : value + } +} diff --git a/Muxy/Views/Settings/AIAssistantSettingsView.swift b/Muxy/Views/Settings/AIAssistantSettingsView.swift new file mode 100644 index 00000000..b4a922b8 --- /dev/null +++ b/Muxy/Views/Settings/AIAssistantSettingsView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct AIAssistantSettingsView: View { + @AppStorage(AIAssistantSettings.providerKey) private var providerRaw = AIAssistantProvider.claude.rawValue + @AppStorage(AIAssistantSettings.claudeModelKey) private var claudeModel = "" + @AppStorage(AIAssistantSettings.codexModelKey) private var codexModel = "" + @AppStorage(AIAssistantSettings.opencodeModelKey) private var opencodeModel = "" + @AppStorage(AIAssistantSettings.customCommandKey) private var customCommand = "" + @AppStorage(AIAssistantSettings.commitPromptKey) private var commitPrompt = "" + @AppStorage(AIAssistantSettings.prPromptKey) private var prPrompt = "" + + private var provider: AIAssistantProvider { + AIAssistantProvider(rawValue: providerRaw) ?? .claude + } + + private var commitPromptBinding: Binding { + Binding( + get: { commitPrompt.isEmpty ? AIAssistantPrompts.defaultCommitUserPrompt : commitPrompt }, + set: { commitPrompt = $0 } + ) + } + + private var prPromptBinding: Binding { + Binding( + get: { prPrompt.isEmpty ? AIAssistantPrompts.defaultPullRequestUserPrompt : prPrompt }, + set: { prPrompt = $0 } + ) + } + + var body: some View { + SettingsContainer { + SettingsSection( + "Provider", + footer: "Choose the agentic CLI tool used to generate commit messages and pull request drafts. " + + "The tool runs locally with your existing authentication." + ) { + SettingsRow("Tool") { + Picker("", selection: $providerRaw) { + ForEach(AIAssistantProvider.allCases) { provider in + Text(provider.displayName).tag(provider.rawValue) + } + } + .labelsHidden() + .frame(width: SettingsMetrics.controlWidth, alignment: .trailing) + } + + if provider != .custom { + SettingsRow("Model (optional)") { + TextField("Default", text: modelBinding) + .textFieldStyle(.roundedBorder) + .frame(width: SettingsMetrics.controlWidth) + } + } else { + customCommandRow + } + } + + SettingsSection( + "Commit Prompt", + footer: "Guides the model when generating commit messages. Output is plain text." + ) { + promptEditor( + text: commitPromptBinding, + onReset: { commitPrompt = "" } + ) + } + + SettingsSection( + "Pull Request Prompt", + footer: "Guides the model when generating PR title and description. " + + "Output is parsed as JSON; do not change the response format." + ) { + promptEditor( + text: prPromptBinding, + onReset: { prPrompt = "" } + ) + } + } + } + + private var modelBinding: Binding { + switch provider { + case .claude: $claudeModel + case .codex: $codexModel + case .opencode: $opencodeModel + case .custom: .constant("") + } + } + + private var customCommandRow: some View { + VStack(alignment: .leading, spacing: 6) { + SettingsRow("Command") { + TextField("e.g. mytool --quiet", text: $customCommand) + .textFieldStyle(.roundedBorder) + .frame(width: SettingsMetrics.controlWidth) + } + Text( + "Runs through your login shell so PATH and aliases resolve. " + + "Muxy pipes the full prompt to stdin and reads the response from stdout. " + + "Provide arguments that make the tool emit only the response (no banners or progress)." + ) + .font(.system(size: SettingsMetrics.footnoteFontSize)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, SettingsMetrics.horizontalPadding) + .padding(.bottom, 4) + } + } + + private func promptEditor( + text: Binding, + onReset: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: text) + .font(.system(size: SettingsMetrics.footnoteFontSize)) + .scrollContentBackground(.hidden) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .frame(height: 120) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, SettingsMetrics.horizontalPadding) + + HStack { + Spacer() + Button("Reset to default", action: onReset) + .buttonStyle(.borderless) + .controlSize(.small) + } + .padding(.horizontal, SettingsMetrics.horizontalPadding) + .padding(.bottom, 4) + } + } +} diff --git a/Muxy/Views/Settings/SettingsView.swift b/Muxy/Views/Settings/SettingsView.swift index ad5485da..2709ec95 100644 --- a/Muxy/Views/Settings/SettingsView.swift +++ b/Muxy/Views/Settings/SettingsView.swift @@ -15,6 +15,8 @@ struct SettingsView: View { .tabItem { Label("Notifications", systemImage: "bell") } MobileSettingsView() .tabItem { Label("Mobile", systemImage: "iphone") } + AIAssistantSettingsView() + .tabItem { Label("AI", systemImage: "sparkles") } AIUsageSettingsView() .tabItem { Label("AI Usage", systemImage: "chart.bar") } } diff --git a/Muxy/Views/VCS/CreatePRSheet.swift b/Muxy/Views/VCS/CreatePRSheet.swift index 6329fff0..54ef1a62 100644 --- a/Muxy/Views/VCS/CreatePRSheet.swift +++ b/Muxy/Views/VCS/CreatePRSheet.swift @@ -24,8 +24,12 @@ struct CreatePRForm: View { _ draft: Bool ) -> Void let onCancel: () -> Void + let onGenerateAI: ((_ baseBranch: String) async throws -> AIPullRequestDraft)? @State private var didLoadRemoteBranches = false + @State private var isGeneratingAI = false + @State private var aiError: String? + @State private var aiTask: Task? private var availableBaseBranches: [String] { if !context.remoteBranches.isEmpty { @@ -218,7 +222,13 @@ struct CreatePRForm: View { private var titleField: some View { VStack(alignment: .leading, spacing: UIMetrics.spacing2) { - fieldLabel("Title") + HStack(spacing: UIMetrics.spacing2) { + fieldLabel("Title") + Spacer(minLength: 0) + if onGenerateAI != nil { + aiGenerateButton + } + } ThemedTextField( text: $title, placeholder: "Short summary of the change", @@ -226,9 +236,79 @@ struct CreatePRForm: View { onSubmit: { if canSubmit, !inProgress { submit() } } ) .focused($titleFocused) + if let aiError { + Text(aiError) + .font(.system(size: UIMetrics.fontFootnote)) + .foregroundStyle(MuxyTheme.diffRemoveFg) + .fixedSize(horizontal: false, vertical: true) + } } } + private var aiGenerateButton: some View { + Button { + if isGeneratingAI { + cancelAIGeneration() + } else { + generateWithAI() + } + } label: { + HStack(spacing: UIMetrics.spacing2) { + if isGeneratingAI { + ProgressView().controlSize(.mini) + Image(systemName: "xmark.circle.fill") + .font(.system(size: UIMetrics.fontCaption)) + } else { + Image(systemName: "sparkles") + .font(.system(size: UIMetrics.fontCaption, weight: .semibold)) + } + Text(isGeneratingAI ? "Cancel" : "Generate with AI") + .font(.system(size: UIMetrics.fontFootnote, weight: .medium)) + } + .foregroundStyle(MuxyTheme.accent) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(inProgress) + .help(isGeneratingAI ? "Cancel generation" : "Generate title and description from the diff") + } + + private func generateWithAI() { + guard let onGenerateAI else { return } + guard !baseBranch.isEmpty else { + aiError = "Select a target branch first." + return + } + isGeneratingAI = true + aiError = nil + let base = baseBranch + aiTask?.cancel() + aiTask = Task { @MainActor in + do { + let draft = try await onGenerateAI(base) + guard !Task.isCancelled else { return } + title = draft.title + bodyText = draft.body + isGeneratingAI = false + aiTask = nil + } catch is CancellationError { + isGeneratingAI = false + aiTask = nil + } catch { + guard !Task.isCancelled else { return } + aiError = error.localizedDescription + isGeneratingAI = false + aiTask = nil + } + } + } + + private func cancelAIGeneration() { + aiTask?.cancel() + aiTask = nil + isGeneratingAI = false + } + private var descriptionField: some View { VStack(alignment: .leading, spacing: UIMetrics.spacing2) { fieldLabel("Description") diff --git a/Muxy/Views/VCS/VCSTabView.swift b/Muxy/Views/VCS/VCSTabView.swift index e2122b11..da976621 100644 --- a/Muxy/Views/VCS/VCSTabView.swift +++ b/Muxy/Views/VCS/VCSTabView.swift @@ -427,6 +427,15 @@ struct VCSTabView: View { onCancel: { state.openPullRequestError = nil showInlinePRForm = false + }, + onGenerateAI: { base in + let path = state.projectPath + let branch = state.branchName + return try await AIAssistantService.generatePullRequest( + repoPath: path, + branch: branch, + baseBranch: base + ) } ) } @@ -456,6 +465,13 @@ struct VCSTabView: View { } return .ignored } + + HStack { + Spacer() + aiCommitButton + } + .padding(.trailing, UIMetrics.spacing3) + .padding(.top, UIMetrics.scaled(4)) } .background(MuxyTheme.surface, in: RoundedRectangle(cornerRadius: UIMetrics.radiusMD)) .overlay(RoundedRectangle(cornerRadius: UIMetrics.radiusMD).stroke(MuxyTheme.border, lineWidth: 1)) @@ -470,6 +486,36 @@ struct VCSTabView: View { .background(MuxyTheme.bg) } + private var aiCommitButton: some View { + let isGenerating = state.isGeneratingCommitMessage + let canGenerate = !isGenerating && state.hasAnyChanges + return Button { + if isGenerating { + state.cancelCommitMessageGeneration() + } else { + state.generateCommitMessageWithAI() + } + } label: { + HStack(spacing: UIMetrics.spacing2) { + if isGenerating { + ProgressView().controlSize(.mini) + Image(systemName: "xmark.circle.fill") + .font(.system(size: UIMetrics.fontCaption)) + Text("Cancel") + .font(.system(size: UIMetrics.fontFootnote, weight: .medium)) + } else { + Image(systemName: "sparkles") + .font(.system(size: UIMetrics.scaled(12), weight: .semibold)) + } + } + .foregroundStyle(canGenerate || isGenerating ? MuxyTheme.accent : MuxyTheme.fgDim) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!isGenerating && !canGenerate) + .help(isGenerating ? "Cancel generation" : "Generate commit message with AI") + } + private var commitButton: some View { Button { state.commit()