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