Compare commits
4 Commits
v2.3.2-bug
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3997f3feee | |||
| 914d608d35 | |||
| 11017ee7fa | |||
| d386888359 |
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 551 KiB |
@@ -279,7 +279,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = "2.3.2-bugfix";
|
||||
MARKETING_VERSION = 2.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -323,7 +323,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = "2.3.2-bugfix";
|
||||
MARKETING_VERSION = 2.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -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: "</p><p>")
|
||||
html = "<p>\(html)</p>"
|
||||
// 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: "<strong>$1</strong>", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
|
||||
|
||||
// Italic
|
||||
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
|
||||
// Italic (avoid matching bold's leftover *)
|
||||
text = text.replacingOccurrences(of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#, with: "<em>$1</em>", options: .regularExpression)
|
||||
|
||||
// Inline code
|
||||
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"`([^`\n]+)`"#, with: "<code>$1</code>", options: .regularExpression)
|
||||
|
||||
// Line breaks
|
||||
html = html.replacingOccurrences(of: "\n", with: "<br>")
|
||||
// Split into paragraphs on double newlines, wrap each in <p>
|
||||
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: "<br>")
|
||||
return "<p>\(withBreaks)</p>"
|
||||
}
|
||||
|
||||
return html
|
||||
return paragraphs.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Error Handling
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var autoContinueTask: Task<Void, Never>?
|
||||
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
|
||||
showSystemMessage("↩ Continuing…")
|
||||
silentContinuePrompt = "Please continue from where you left off."
|
||||
Task { @MainActor in
|
||||
generateAIResponse(to: "", attachments: nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
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<T>(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) {
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
oAI/Views/Screens/BashApprovalSheet.swift
Normal file
128
oAI/Views/Screens/BashApprovalSheet.swift
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ struct CommandDetail: Identifiable {
|
||||
let brief: String
|
||||
let detail: String
|
||||
let examples: [String]
|
||||
var shortcut: String? = nil
|
||||
}
|
||||
|
||||
struct CommandCategory: Identifiable {
|
||||
@@ -50,13 +51,15 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/history",
|
||||
brief: "View command history",
|
||||
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
||||
examples: ["/history"]
|
||||
examples: ["/history"],
|
||||
shortcut: "⌘H"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/clear",
|
||||
brief: "Clear chat history",
|
||||
detail: "Removes all messages from the current session and resets the conversation. This does not delete saved conversations.",
|
||||
examples: ["/clear"]
|
||||
examples: ["/clear"],
|
||||
shortcut: "⌘K"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/retry",
|
||||
@@ -82,7 +85,8 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/model",
|
||||
brief: "Select AI model",
|
||||
detail: "Opens the model selector to browse and choose from available models. The list depends on your active provider and API key.",
|
||||
examples: ["/model"]
|
||||
examples: ["/model"],
|
||||
shortcut: "⌘M"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/provider [name]",
|
||||
@@ -114,13 +118,15 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/load",
|
||||
brief: "Load saved conversation",
|
||||
detail: "Opens the conversation list to browse and load a previously saved conversation. Replaces the current chat.",
|
||||
examples: ["/load"]
|
||||
examples: ["/load"],
|
||||
shortcut: "⌘L"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/list",
|
||||
brief: "List saved conversations",
|
||||
detail: "Opens the conversation list showing all saved conversations with their dates and message counts.",
|
||||
examples: ["/list"]
|
||||
examples: ["/list"],
|
||||
shortcut: "⌘L"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/delete <name>",
|
||||
@@ -178,7 +184,8 @@ private let helpCategories: [CommandCategory] = [
|
||||
command: "/config",
|
||||
brief: "Open settings",
|
||||
detail: "Opens the settings panel where you can configure providers, API keys, MCP permissions, appearance, and more. Also available via /settings.",
|
||||
examples: ["/config", "/settings"]
|
||||
examples: ["/config", "/settings"],
|
||||
shortcut: "⌘,"
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/stats",
|
||||
@@ -461,6 +468,15 @@ private struct CommandRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if let shortcut = command.shortcut {
|
||||
Text(shortcut)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user