import SwiftUI import XcodeInspector import ConversationServiceProvider import ComposableArchitecture import Terminal struct RunInTerminalToolView: View { let tool: AgentToolCall let command: String? let explanation: String? let isBackground: Bool? let chat: StoreOf private var title: String = "Run command in terminal" @AppStorage(\.chatFontSize) var chatFontSize init(tool: AgentToolCall, chat: StoreOf) { self.tool = tool self.chat = chat if let input = tool.invokeParams?.input as? [String: AnyCodable] { self.command = input["command"]?.value as? String self.explanation = input["explanation"]?.value as? String self.isBackground = input["isBackground"]?.value as? Bool self.title = (isBackground != nil && isBackground!) ? "Run command in background terminal" : "Run command in terminal" } else { self.command = nil self.explanation = nil self.isBackground = nil } } var terminalSession: TerminalSession? { return TerminalSessionManager.shared.getSession(for: tool.id) } var statusIcon: some View { Group { switch tool.status { case .running: ProgressView() .controlSize(.small) .scaleEffect(0.7) case .completed: Image(systemName: "checkmark") .foregroundColor(.green.opacity(0.5)) case .error: Image(systemName: "xmark.circle") .foregroundColor(.red.opacity(0.5)) case .cancelled: Image(systemName: "slash.circle") .foregroundColor(.gray.opacity(0.5)) case .waitForConfirmation: EmptyView() } } } var body: some View { WithPerceptionTracking { if tool.status == .waitForConfirmation || terminalSession != nil { VStack { Text(self.title) .font(.system(size: chatFontSize)) .fontWeight(.semibold) .foregroundStyle(.primary) .background(Color.clear) .frame(maxWidth: .infinity, alignment: .leading) toolView } .padding(8) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) } else { toolView } } } var toolView: some View { WithPerceptionTracking { VStack { if command != nil { HStack(spacing: 4) { statusIcon .frame(width: 16, height: 16) ThemedMarkdownText(text: command!, chat: chat) .font(.system(.body, design: .monospaced)) .padding(.horizontal, 8) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(NSColor.textBackgroundColor)) } } else { Text("Invalid parameter in the toolcall for runInTerminal") } if let terminalSession = terminalSession { XTermView( terminalSession: terminalSession, onTerminalInput: terminalSession.handleTerminalInput ) .frame(minHeight: 200, maxHeight: 400) } else if tool.status == .waitForConfirmation { ThemedMarkdownText(text: explanation ?? "", chat: chat) .frame(maxWidth: .infinity, alignment: .leading) HStack { Button("Continue") { chat.send(.toolCallStarted(tool.id)) Task { let projectURL = await XcodeInspector.shared.safe.realtimeActiveProjectURL let currentDirectory = projectURL?.path ?? "" let session = TerminalSessionManager.shared.createSession(for: tool.id) if isBackground == true { session.executeCommand( currentDirectory: currentDirectory, command: command!) { result in // do nothing } chat.send(.toolCallCompleted(tool.id, "Command is running in terminal with ID=\(tool.id)")) } else { session.executeCommand( currentDirectory: currentDirectory, command: command!) { result in chat.send(.toolCallCompleted(tool.id, result.output)) } } } } .buttonStyle(BorderedProminentButtonStyle()) Button("Cancel") { chat.send(.toolCallCancelled(tool.id)) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) } } } } }