From 3997f3feee14aa354b60e2f980e99d7d6e095d5a Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 25 Feb 2026 08:24:05 +0100 Subject: [PATCH] updates --- oAI/Services/EmailHandlerService.swift | 50 +++++--- oAI/Services/MCPService.swift | 148 ++++++++++++++++++++++ oAI/Services/SettingsService.swift | 34 +++++ oAI/ViewModels/ChatViewModel.swift | 112 ++++++++-------- oAI/Views/Main/ChatView.swift | 44 ++----- oAI/Views/Screens/BashApprovalSheet.swift | 128 +++++++++++++++++++ oAI/Views/Screens/SettingsView.swift | 93 ++++++++++++++ 7 files changed, 504 insertions(+), 105 deletions(-) create mode 100644 oAI/Views/Screens/BashApprovalSheet.swift diff --git a/oAI/Services/EmailHandlerService.swift b/oAI/Services/EmailHandlerService.swift index f0a75da..8480dd9 100644 --- a/oAI/Services/EmailHandlerService.swift +++ b/oAI/Services/EmailHandlerService.swift @@ -273,7 +273,7 @@ final class EmailHandlerService { --- - Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email. + Please provide a complete, well-formatted response to this email. Write the reply directly — do not wrap it in code fences or markdown blocks. """ return prompt @@ -299,7 +299,8 @@ final class EmailHandlerService { - Be professional and courteous - Keep responses concise and relevant - Use proper email etiquette - - Format your response using Markdown (it will be converted to HTML) + - Format your response using Markdown (bold, lists, headings are welcome) + - Do NOT wrap your response in code fences or ```html blocks — write the email body directly - If you need information from files, you have read-only access via MCP tools - Never claim to write, modify, or delete files (read-only access) - Sign emails appropriately @@ -412,27 +413,46 @@ final class EmailHandlerService { } private func markdownToHTML(_ markdown: String) -> String { - // Basic markdown to HTML conversion - // TODO: Use proper markdown parser for production - var html = markdown + var text = markdown.trimmingCharacters(in: .whitespacesAndNewlines) - // Paragraphs - html = html.replacingOccurrences(of: "\n\n", with: "

") - html = "

\(html)

" + // Strip outer code fence wrapping the entire response + // Models sometimes wrap their reply in ```html ... ``` or ``` ... ``` + let lines = text.components(separatedBy: "\n") + if lines.count >= 2 { + let first = lines[0].trimmingCharacters(in: .whitespaces) + let last = lines[lines.count - 1].trimmingCharacters(in: .whitespaces) + if (first == "```" || first.hasPrefix("```")) && last == "```" { + text = lines.dropFirst().dropLast().joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + // Strip any remaining fenced code blocks (preserve content, remove fences) + text = text.replacingOccurrences( + of: #"```[a-z]*\n([\s\S]*?)```"#, + with: "$1", + options: .regularExpression + ) // Bold - html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "$1", options: .regularExpression) + text = text.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "$1", options: .regularExpression) - // Italic - html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "$1", options: .regularExpression) + // Italic (avoid matching bold's leftover *) + text = text.replacingOccurrences(of: #"(?$1", options: .regularExpression) // Inline code - html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "$1", options: .regularExpression) + text = text.replacingOccurrences(of: #"`([^`\n]+)`"#, with: "$1", options: .regularExpression) - // Line breaks - html = html.replacingOccurrences(of: "\n", with: "
") + // Split into paragraphs on double newlines, wrap each in

+ let paragraphs = text.components(separatedBy: "\n\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { para in + let withBreaks = para.replacingOccurrences(of: "\n", with: "
") + return "

\(withBreaks)

" + } - return html + return paragraphs.joined(separator: "\n") } // MARK: - Error Handling diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index de4ea95..0e9c9ee 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -112,6 +112,18 @@ class MCPService { private let anytypeService = AnytypeMCPService.shared private let paperlessService = PaperlessService.shared + // MARK: - Bash Approval State + + struct PendingBashCommand: Identifiable { + let id = UUID() + let command: String + let workingDirectory: String + } + + private(set) var pendingBashCommand: PendingBashCommand? = nil + private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil + private(set) var bashSessionApproved: Bool = false + // MARK: - Tool Schema Generation func getToolSchemas(onlineMode: Bool = false) -> [Tool] { @@ -220,6 +232,21 @@ class MCPService { tools.append(contentsOf: paperlessService.getToolSchemas()) } + // Add bash_execute tool when bash is enabled + if settings.bashEnabled { + let workDir = settings.bashWorkingDirectory + let timeout = settings.bashTimeout + let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : "" + tools.append(makeTool( + name: "bash_execute", + description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)", + properties: [ + "command": prop("string", "The shell command to execute") + ], + required: ["command"] + )) + } + // Add web_search tool when online mode is active // (OpenRouter handles search natively via :online model suffix, so excluded here) if onlineMode { @@ -346,6 +373,20 @@ class MCPService { } return copyFile(source: source, destination: destination) + case "bash_execute": + guard settings.bashEnabled else { + return ["error": "Bash execution is disabled. Enable it in Settings > MCP."] + } + guard let command = args["command"] as? String, !command.isEmpty else { + return ["error": "Missing required parameter: command"] + } + let workDir = settings.bashWorkingDirectory + if settings.bashRequireApproval { + return await executeBashWithApproval(command: command, workingDirectory: workDir) + } else { + return await runBashCommand(command, workingDirectory: workDir) + } + case "web_search": let query = args["query"] as? String ?? "" guard !query.isEmpty else { @@ -706,6 +747,113 @@ class MCPService { return ["success": true, "source": resolvedSrc, "destination": resolvedDst] } + // MARK: - Bash Execution + + private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] { + // If the user already approved all commands for this session, skip the UI + if bashSessionApproved { + return await runBashCommand(command, workingDirectory: workingDirectory) + } + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory) + self.pendingBashContinuation = continuation + } + } + } + + func approvePendingBashCommand(forSession: Bool = false) { + guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return } + pendingBashCommand = nil + pendingBashContinuation = nil + if forSession { + bashSessionApproved = true + } + Task.detached(priority: .userInitiated) { + let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory) + cont.resume(returning: result) + } + } + + func denyPendingBashCommand() { + guard pendingBashCommand != nil else { return } + pendingBashCommand = nil + pendingBashContinuation?.resume(returning: ["error": "User denied command execution"]) + pendingBashContinuation = nil + } + + func resetBashSessionApproval() { + bashSessionApproved = false + } + + private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] { + let timeoutSeconds = settings.bashTimeout + let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath + Log.mcp.info("bash_execute: \(command)") + + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-c", command] + + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue { + process.currentDirectoryURL = URL(fileURLWithPath: workDir) + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + var timedOut = false + let timeoutItem = DispatchWorkItem { + if process.isRunning { + timedOut = true + process.terminate() + } + } + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem) + + do { + try process.run() + process.waitUntilExit() + } catch { + timeoutItem.cancel() + Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)") + continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"]) + return + } + + timeoutItem.cancel() + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let exitCode = Int(process.terminationStatus) + + Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)") + + var result: [String: Any] = ["exit_code": exitCode] + if !stdout.isEmpty { + let maxOut = 20_000 + result["stdout"] = stdout.count > maxOut + ? String(stdout.prefix(maxOut)) + "\n... (output truncated)" + : stdout + } + if !stderr.isEmpty { + result["stderr"] = String(stderr.prefix(5_000)) + } + if timedOut { + result["timed_out"] = true + result["note"] = "Command terminated after \(timeoutSeconds)s timeout" + } + + continuation.resume(returning: result) + } + } + } + // MARK: - Gitignore Support /// Reload gitignore rules for all allowed folders diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 76743cf..e95deee 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -447,6 +447,40 @@ class SettingsService { return !key.isEmpty } + // MARK: - Bash Execution Settings + + var bashEnabled: Bool { + get { cache["bashEnabled"] == "true" } + set { + cache["bashEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "bashEnabled", value: String(newValue)) + } + } + + var bashRequireApproval: Bool { + get { cache["bashRequireApproval"].map { $0 == "true" } ?? true } + set { + cache["bashRequireApproval"] = String(newValue) + DatabaseService.shared.setSetting(key: "bashRequireApproval", value: String(newValue)) + } + } + + var bashWorkingDirectory: String { + get { cache["bashWorkingDirectory"] ?? "~" } + set { + cache["bashWorkingDirectory"] = newValue + DatabaseService.shared.setSetting(key: "bashWorkingDirectory", value: newValue) + } + } + + var bashTimeout: Int { + get { cache["bashTimeout"].flatMap(Int.init) ?? 30 } + set { + cache["bashTimeout"] = String(newValue) + DatabaseService.shared.setSetting(key: "bashTimeout", value: String(newValue)) + } + } + // MARK: - Paperless-NGX Settings var paperlessEnabled: Bool { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 1d05eff..7441563 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -56,7 +56,7 @@ class ChatViewModel { var modelInfoTarget: ModelInfo? = nil var commandHistory: [String] = [] var historyIndex: Int = 0 - var isAutoContinuing: Bool = false + private var silentContinuePrompt: String? = nil // Save tracking var currentConversationId: UUID? = nil @@ -67,7 +67,6 @@ class ChatViewModel { let chatCount = messages.filter { $0.role != .system }.count return chatCount > 0 && chatCount != savedMessageCount } - var autoContinueCountdown: Int = 0 // MARK: - Auto-Save Tracking @@ -78,7 +77,6 @@ class ChatViewModel { // MARK: - Private State private var streamingTask: Task? - private var autoContinueTask: Task? private let settings = SettingsService.shared private let providerRegistry = ProviderRegistry.shared @@ -354,59 +352,21 @@ Don't narrate future actions ("Let me...") - just use the tools. streamingTask?.cancel() streamingTask = nil isGenerating = false - cancelAutoContinue() // Also cancel any pending auto-continue + silentContinuePrompt = nil } func startAutoContinue() { - isAutoContinuing = true - autoContinueCountdown = 5 - - autoContinueTask = Task { @MainActor in - // Countdown from 5 to 1 - for i in (1...5).reversed() { - if Task.isCancelled { - isAutoContinuing = false - return - } - autoContinueCountdown = i - try? await Task.sleep(for: .seconds(1)) - } - - // Continue the task - isAutoContinuing = false - autoContinueCountdown = 0 - - let continuePrompt = "Please continue from where you left off." - - // Add user message - let userMessage = Message( - role: .user, - content: continuePrompt, - tokens: nil, - cost: nil, - timestamp: Date(), - attachments: nil, - responseTime: nil, - wasInterrupted: false, - modelId: selectedModel?.id - ) - messages.append(userMessage) - - // Continue generation - generateAIResponse(to: continuePrompt, attachments: nil) + showSystemMessage("↩ Continuing…") + silentContinuePrompt = "Please continue from where you left off." + Task { @MainActor in + generateAIResponse(to: "", attachments: nil) } } - func cancelAutoContinue() { - autoContinueTask?.cancel() - autoContinueTask = nil - isAutoContinuing = false - autoContinueCountdown = 0 - } - func clearChat() { messages.removeAll() sessionStats.reset() + MCPService.shared.resetBashSessionApproval() showSystemMessage("Chat cleared") } @@ -419,6 +379,7 @@ Don't narrate future actions ("Let me...") - just use the tools. messages.removeAll() sessionStats.reset() + MCPService.shared.resetBashSessionApproval() messages = loadedMessages // Rebuild session stats from loaded messages @@ -450,6 +411,7 @@ Don't narrate future actions ("Let me...") - just use the tools. currentProvider = newProvider settings.defaultModel = model.id settings.defaultProvider = newProvider + MCPService.shared.resetBashSessionApproval() } private func inferProvider(from modelId: String) -> Settings.Provider? { @@ -784,8 +746,9 @@ Don't narrate future actions ("Let me...") - just use the tools. let mcp = MCPService.shared let mcpActive = mcpEnabled || settings.mcpEnabled let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured + let bashActive = settings.bashEnabled let modelSupportTools = selectedModel?.capabilities.tools ?? false - if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) { + if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) { generateAIResponseWithTools(provider: provider, modelId: modelId) return } @@ -898,7 +861,7 @@ Don't narrate future actions ("Let me...") - just use the tools. temperature: chatRequest.temperature, imageGeneration: true ) - let response = try await provider.chat(request: nonStreamRequest) + let response = try await withOverloadedRetry { try await provider.chat(request: nonStreamRequest) } let responseTime = Date().timeIntervalSince(startTime) if let index = messages.firstIndex(where: { $0.id == messageId }) { @@ -1304,6 +1267,12 @@ Don't narrate future actions ("Let me...") - just use the tools. return ["role": msg.role.rawValue, "content": msg.content] } + // If this is a silent auto-continue, inject the prompt into the API call only + if let continuePrompt = silentContinuePrompt { + apiMessages.append(["role": "user", "content": continuePrompt]) + silentContinuePrompt = nil + } + let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit var finalContent = "" var totalUsage: ChatResponse.Usage? @@ -1315,13 +1284,15 @@ Don't narrate future actions ("Let me...") - just use the tools. break } - let response = try await provider.chatWithToolMessages( - model: effectiveModelId, - messages: apiMessages, - tools: tools, - maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil, - temperature: settings.temperature > 0 ? settings.temperature : nil - ) + let response = try await withOverloadedRetry { + try await provider.chatWithToolMessages( + model: effectiveModelId, + messages: apiMessages, + tools: tools, + maxTokens: settings.maxTokens > 0 ? settings.maxTokens : nil, + temperature: settings.temperature > 0 ? settings.temperature : nil + ) + } if let usage = response.usage { totalUsage = usage } @@ -1536,6 +1507,11 @@ Don't narrate future actions ("Let me...") - just use the tools. return "Server error. The provider may be experiencing issues. Try again shortly." } + // Overloaded / 529 + if desc.contains("529") || desc.lowercased().contains("overloaded") { + return "API is overloaded. Please try again shortly." + } + // Timeout patterns if desc.lowercased().contains("timed out") || desc.lowercased().contains("timeout") { return "Request timed out. Try a shorter message or different model." @@ -1545,6 +1521,30 @@ Don't narrate future actions ("Let me...") - just use the tools. return desc } + /// Retry an async operation on overloaded (529) errors with exponential backoff. + private func withOverloadedRetry(maxAttempts: Int = 4, operation: () async throws -> T) async throws -> T { + var attempt = 0 + while true { + do { + return try await operation() + } catch { + let desc = error.localizedDescription + let isOverloaded = desc.contains("529") || desc.lowercased().contains("overloaded") + attempt += 1 + if isOverloaded && attempt < maxAttempts && !Task.isCancelled { + let delay = Double(1 << attempt) // 2s, 4s, 8s + Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") + await MainActor.run { + showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") + } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } else { + throw error + } + } + } + } + // MARK: - Helpers private func showModelInfo(_ model: ModelInfo) { diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index bff85f3..ceb06cf 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -81,40 +81,6 @@ struct ChatView: View { } } - // Auto-continue countdown banner - if viewModel.isAutoContinuing { - HStack(spacing: 12) { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(0.7) - - VStack(alignment: .leading, spacing: 2) { - Text(ThinkingVerbs.random()) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.oaiPrimary) - Text("Continuing in \(viewModel.autoContinueCountdown)s") - .font(.system(size: 11)) - .foregroundColor(.oaiSecondary) - } - - Spacer() - - Button("Cancel") { - viewModel.cancelAutoContinue() - } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(.red) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.blue.opacity(0.1)) - .overlay( - Rectangle() - .stroke(Color.blue.opacity(0.3), lineWidth: 1) - ) - } - // Input bar InputBar( text: $viewModel.inputText, @@ -140,6 +106,16 @@ struct ChatView: View { .sheet(isPresented: $viewModel.showSkills) { AgentSkillsView() } + .sheet(item: Binding( + get: { MCPService.shared.pendingBashCommand }, + set: { _ in } + )) { pending in + BashApprovalSheet( + pending: pending, + onApprove: { forSession in MCPService.shared.approvePendingBashCommand(forSession: forSession) }, + onDeny: { MCPService.shared.denyPendingBashCommand() } + ) + } } } diff --git a/oAI/Views/Screens/BashApprovalSheet.swift b/oAI/Views/Screens/BashApprovalSheet.swift new file mode 100644 index 0000000..97f3899 --- /dev/null +++ b/oAI/Views/Screens/BashApprovalSheet.swift @@ -0,0 +1,128 @@ +// +// BashApprovalSheet.swift +// oAI +// +// Approval UI for AI-requested bash commands +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import SwiftUI + +struct BashApprovalSheet: View { + let pending: MCPService.PendingBashCommand + let onApprove: (_ forSession: Bool) -> Void + let onDeny: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack(spacing: 12) { + Image(systemName: "terminal.fill") + .font(.title2) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text("Allow Shell Command?") + .font(.system(size: 17, weight: .semibold)) + Text("The AI wants to run the following command") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + } + + // Command display + VStack(alignment: .leading, spacing: 6) { + Text("COMMAND") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + ScrollView { + Text(pending.command) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(12) + } + .frame(maxHeight: 180) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } + + // Working directory + HStack(spacing: 6) { + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text("Working directory:") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text(pending.workingDirectory) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + + // Warning banner + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.system(size: 13)) + .padding(.top, 1) + Text("Shell commands have full access to your system. Only approve commands you understand and trust.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Buttons + HStack(spacing: 8) { + Button("Deny") { + onDeny() + } + .buttonStyle(.bordered) + .tint(.red) + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + Button("Allow Once") { + onApprove(false) + } + .buttonStyle(.bordered) + .tint(.orange) + + Button("Allow for Session") { + onApprove(true) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .keyboardShortcut(.return, modifiers: []) + } + } + .padding(24) + .frame(width: 480) + } +} diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 43253d5..7f299f7 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -528,6 +528,99 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } // Anytype integration UI hidden (work in progress — see AnytypeMCPService.swift) + + // MARK: Bash Execution + Divider() + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "terminal.fill") + .font(.title2) + .foregroundStyle(.orange) + Text("Bash Execution") + .font(.system(size: 18, weight: .semibold)) + } + Text("Allow the AI to run shell commands on your machine. Commands are executed via /bin/zsh. Enable approval mode to review each command before it runs.") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.bottom, 4) + + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Status") + formSection { + row("Enable Bash Execution") { + Toggle("", isOn: $settingsService.bashEnabled) + .toggleStyle(.switch) + } + } + } + + HStack(spacing: 4) { + Image(systemName: settingsService.bashEnabled ? "checkmark.circle.fill" : "circle") + .foregroundStyle(settingsService.bashEnabled ? .orange : .secondary) + .font(.system(size: 13)) + Text(settingsService.bashEnabled ? "Active — AI can run shell commands" : "Disabled") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + + if settingsService.bashEnabled { + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Settings") + formSection { + row("Require Approval") { + Toggle("", isOn: $settingsService.bashRequireApproval) + .toggleStyle(.switch) + } + rowDivider() + row("Working Directory") { + TextField("~", text: $settingsService.bashWorkingDirectory) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + .multilineTextAlignment(.trailing) + .frame(width: 200) + } + rowDivider() + row("Timeout (seconds)") { + HStack(spacing: 8) { + Stepper("", value: $settingsService.bashTimeout, in: 5...300, step: 5) + .labelsHidden() + Text("\(settingsService.bashTimeout)s") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + } + } + } + + if settingsService.bashRequireApproval { + HStack(spacing: 6) { + Image(systemName: "hand.raised.fill") + .font(.system(size: 12)) + .foregroundStyle(.orange) + Text("Each command will require your approval before running.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + } else { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundStyle(.red) + .padding(.top, 1) + Text("Auto-execute mode: commands run without approval. Use with caution.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 4) + } + } } // MARK: - Appearance Tab