import ChatService import ChatTab import Combine import ComposableArchitecture import ConversationServiceProvider import GitHubCopilotService import SharedUIComponents import SwiftUI struct ProgressAgentRound: View { let rounds: [AgentRound] let chat: StoreOf var isStreaming: Bool = false var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { ForEach(Array(rounds.enumerated()), id: \.element.roundId) { roundIndex, round in let isLastRound = roundIndex == rounds.count - 1 VStack(alignment: .leading, spacing: 8) { ForEach(Array(round.thinking.enumerated()), id: \.offset) { entryIndex, entry in ThinkingView( thinking: entry, isStreaming: isStreaming && isLastRound && entryIndex == round.thinking.count - 1 ) } if !round.reply.isEmpty { ThemedMarkdownText(text: round.reply, chat: chat) } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } if let subAgentRounds = round.subAgentRounds, !subAgentRounds.isEmpty { SubAgentRounds(rounds: subAgentRounds, chat: chat) } } } } .foregroundStyle(.secondary) } } } struct SubAgentRounds: View { let rounds: [AgentRound] let chat: StoreOf @Environment(\.colorScheme) var colorScheme var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { ForEach(rounds, id: \.roundId) { round in VStack(alignment: .leading, spacing: 8) { ForEach(Array(round.thinking.enumerated()), id: \.offset) { _, entry in ThinkingView(thinking: entry, isStreaming: false) } if !round.reply.isEmpty { ThemedMarkdownText(text: round.reply, chat: chat) } if let toolCalls = round.toolCalls, !toolCalls.isEmpty { ProgressToolCalls(tools: toolCalls, chat: chat) } } } } .frame(maxWidth: .infinity, alignment: .leading) .scaledPadding(.horizontal, 16) .scaledPadding(.vertical, 12) .background(RoundedRectangle(cornerRadius: 8).fill(Color("SubagentTurnBackground"))) } } } struct ProgressToolCalls: View { let tools: [AgentToolCall] let chat: StoreOf var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 4) { ForEach(tools) { tool in if tool.name == ToolName.runInTerminal.rawValue && (tool.invokeParams != nil || tool.input != nil) { RunInTerminalToolView(tool: tool, chat: chat) } else if tool.invokeParams != nil && tool.status == .waitForConfirmation { ToolConfirmationView(tool: tool, chat: chat) } else if tool.isToolcallingLoopContinueTool { // ignore rendering for internal tool calling loop continue tool } else { ToolStatusItemView(tool: tool) } } } } } } struct ToolConfirmationView: View { let tool: AgentToolCall let chat: StoreOf @AppStorage(\.chatFontSize) var chatFontSize private var toolName: String { tool.name } private var titleText: String { tool.title ?? "" } private var mcpServerName: String? { ToolAutoApprovalManager.extractMCPServerName(from: titleText) } private var conversationId: String { tool.invokeParams?.conversationId ?? "" } private var invokeMessage: String { tool.invokeParams?.message ?? "" } private var isSensitiveFileOperation: Bool { ToolAutoApprovalManager.isSensitiveFileOperation(message: invokeMessage) } private var sensitiveFileInfo: ToolAutoApprovalManager.SensitiveFileConfirmationInfo { ToolAutoApprovalManager.extractSensitiveFileConfirmationInfo(from: invokeMessage) } private var shouldShowMCPSplitButton: Bool { mcpServerName != nil && !conversationId.isEmpty } private var shouldShowSensitiveFileSplitButton: Bool { mcpServerName == nil && isSensitiveFileOperation && !conversationId.isEmpty } @ViewBuilder private var confirmationActionView: some View { if FeatureFlagNotifierImpl.shared.featureFlags.agentModeAutoApproval && CopilotPolicyNotifierImpl.shared.copilotPolicy.agentModeAutoApprovalEnabled { if tool.isToolcallingLoopContinueTool { continueButton } else if shouldShowSensitiveFileSplitButton { sensitiveFileSplitButton } else if shouldShowMCPSplitButton, let serverName = mcpServerName { mcpSplitButton(serverName: serverName) } else { allowButton } } else { legacyAllowOrContinueButton } } private var continueButton: some View { Button(action: { chat.send(.toolCallAccepted(tool.id)) }) { Text("Continue") .scaledFont(.body) } .buttonStyle(.borderedProminent) } private var allowButton: some View { Button(action: { chat.send(.toolCallAccepted(tool.id)) }) { Text("Allow") .scaledFont(.body) } .buttonStyle(.borderedProminent) } private var legacyAllowOrContinueButton: some View { Button(action: { chat.send(.toolCallAccepted(tool.id)) }) { Text(tool.isToolcallingLoopContinueTool ? "Continue" : "Allow") .scaledFont(.body) } .buttonStyle(.borderedProminent) } private var sensitiveFileMenuItems: [SplitButtonMenuItem] { var items: [SplitButtonMenuItem] = [] items.append( SplitButtonMenuItem(title: "Allow in this Session") { chat.send( .toolCallAcceptedWithApproval( tool.id, .sensitiveFile( scope: .session(conversationId), toolName: toolName, description: sensitiveFileInfo.description, pattern: sensitiveFileInfo.pattern ) ) ) } ) let defaultPatterns = ["**/.github/instructions/*", "**/github-copilot/**/*", "outside-workspace"] if let pattern = sensitiveFileInfo.pattern, !pattern.isEmpty, !defaultPatterns.contains(pattern) { items.append( SplitButtonMenuItem(title: "Always Allow") { chat.send( .toolCallAcceptedWithApproval( tool.id, .sensitiveFile( scope: .global, toolName: toolName, description: sensitiveFileInfo.description, pattern: pattern ) ) ) } ) } items.append(.divider()) items.append( SplitButtonMenuItem(title: "Configure Auto Approve...") { chat.send(.openAutoApproveSettings) } ) return items } private var sensitiveFileSplitButton: some View { SplitButton( title: "Allow", isDisabled: false, primaryAction: { chat.send(.toolCallAccepted(tool.id)) }, menuItems: sensitiveFileMenuItems, style: .prominent ) } private func mcpMenuItems(serverName: String) -> [SplitButtonMenuItem] { var items: [SplitButtonMenuItem] = [] items.append( SplitButtonMenuItem(title: "Allow \(toolName) in this Session") { chat.send( .toolCallAcceptedWithApproval( tool.id, .mcpTool( scope: .session(conversationId), serverName: serverName, toolName: toolName ) ) ) } ) items.append( SplitButtonMenuItem(title: "Always Allow \(toolName)") { chat.send( .toolCallAcceptedWithApproval( tool.id, .mcpTool( scope: .global, serverName: serverName, toolName: toolName ) ) ) } ) items.append(.divider()) items.append( SplitButtonMenuItem(title: "Allow tools from \(serverName) in this Session") { chat.send( .toolCallAcceptedWithApproval( tool.id, .mcpServer( scope: .session(conversationId), serverName: serverName ) ) ) } ) items.append( SplitButtonMenuItem(title: "Always Allow tools from \(serverName)") { chat.send( .toolCallAcceptedWithApproval( tool.id, .mcpServer( scope: .global, serverName: serverName ) ) ) } ) items.append(.divider()) items.append( SplitButtonMenuItem(title: "Configure Auto Approve...") { chat.send(.openAutoApproveSettings) } ) return items } private func mcpSplitButton(serverName: String) -> some View { SplitButton( title: "Allow", isDisabled: false, primaryAction: { chat.send(.toolCallAccepted(tool.id)) }, menuItems: mcpMenuItems(serverName: serverName), style: .prominent ) } var body: some View { WithPerceptionTracking { VStack(alignment: .leading, spacing: 8) { if let title = tool.title { ToolConfirmationTitleView(title: title, fontWeight: .semibold) } else { GenericToolTitleView(toolStatus: "Run", toolName: tool.name, fontWeight: .semibold) } ThemedMarkdownText(text: tool.invokeParams?.message ?? "", chat: chat) .frame(maxWidth: .infinity, alignment: .leading) HStack { Button(action: { chat.send(.toolCallCancelled(tool.id)) }) { Text(tool.isToolcallingLoopContinueTool ? "Cancel" : "Skip") .scaledFont(.body) } confirmationActionView } .frame(maxWidth: .infinity, alignment: .leading) .scaledPadding(.top, 4) } .scaledPadding(8) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) } } } struct ToolConfirmationTitleView: View { var title: String var fontWeight: Font.Weight = .regular @AppStorage(\.chatFontSize) var chatFontSize var body: some View { HStack(spacing: 4) { Text(title) .textSelection(.enabled) .scaledFont(size: chatFontSize, weight: fontWeight) .foregroundStyle(.primary) .background(Color.clear) } .frame(maxWidth: .infinity, alignment: .leading) } } struct GenericToolTitleView: View { var toolStatus: String var toolName: String var fontWeight: Font.Weight = .regular @AppStorage(\.chatFontSize) var chatFontSize var body: some View { HStack(spacing: 4) { Text(toolStatus) .textSelection(.enabled) .scaledFont(size: chatFontSize - 1, weight: fontWeight) .foregroundStyle(.primary) .background(Color.clear) Text(toolName) .textSelection(.enabled) .scaledFont(size: chatFontSize - 1, weight: fontWeight) .foregroundStyle(.primary) .scaledPadding(.vertical, 2) .scaledPadding(.horizontal, 4) .background(Color("ToolTitleHighlightBgColor")) .cornerRadius(4) .overlay( RoundedRectangle(cornerRadius: 4) .inset(by: 0.5) .stroke(Color(nsColor: .separatorColor), lineWidth: 1) ) } .frame(maxWidth: .infinity, alignment: .leading) } } struct ProgressAgentRound_Preview: PreviewProvider { static let agentRounds: [AgentRound] = [ .init(roundId: 1, reply: "this is agent step", toolCalls: [ // Completed read file .init( id: "toolcall_001", name: ServerToolName.readFile.rawValue, progressMessage: "Read src/AppDelegate.swift", status: .completed), // Completed file search with results .init( id: "toolcall_002", name: ServerToolName.findFiles.rawValue, progressMessage: "Searched for files matching query: **/*.swift", status: .completed, resultDetails: [ .fileLocation(.init(uri: "file:///src/App.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 10, character: 0)))), .fileLocation(.init(uri: "file:///src/Model.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 5, character: 0)))), .fileLocation(.init(uri: "file:///src/ViewModel.swift", range: .init(start: .init(line: 0, character: 0), end: .init(line: 8, character: 0)))), ]), // Completed create file (expandable) .init( id: "toolcall_003", name: ToolName.createFile.rawValue, progressMessage: "Created src/NewFeature.swift", status: .completed, result: [.text("```swift\nstruct NewFeature {\n var name: String\n}\n```")]), // Completed replace string (expandable) .init( id: "toolcall_004", name: ServerToolName.replaceString.rawValue, progressMessage: "Edited src/Config.swift", status: .completed, result: [.text("```diff\n- let version = \"1.0\"\n+ let version = \"2.0\"\n```")]), // Running tool .init( id: "toolcall_005", name: ServerToolName.codebase.rawValue, progressMessage: "Searching codebase for references", status: .running), // Error tool .init( id: "toolcall_006", name: ServerToolName.readFile.rawValue, progressMessage: "Read missing_file.swift", status: .error, error: "File not found"), ]), ] static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") ProgressAgentRound(rounds: agentRounds, chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) })) .frame(width: 400, height: 500) } }