     1→import SwiftUI
     2→
     3→struct AIChatView: View {
     4→    @EnvironmentObject var obsidianService: ObsidianService
     5→    @EnvironmentObject var lmService: LMGatewayService
     6→    @EnvironmentObject var aiContextService: AIContextService
     7→    @EnvironmentObject var connectionService: SmartConnectionService
     8→    @EnvironmentObject var vaultIndexService: VaultIndexService
     9→    @EnvironmentObject var vaultAgent: VaultAgentService
    10→    @EnvironmentObject var toolSelector: AgentToolSelector
    11→    @EnvironmentObject var toolExecutor: AgentToolExecutor
    12→    @ObservedObject private var multiAgent = DualAgentProcessor.shared
    13→
    14→    @State private var messages: [ChatMessage] = []
    15→    @State private var showCommandSuggestions = false
    16→    @State private var inputText = ""
    17→    @State private var isProcessing = false
    18→    @State private var showContextPicker = false
    19→    @State private var selectedNotes: [ObsidianFile] = []
    20→    @State private var loadedContext: String = ""
    21→    @State private var showCopiedToast = false
    22→
    23→    // Auto-context states
    24→    @State private var autoContextEnabled = true
    25→    @State private var discoveredNotes: [VaultIndex.IndexedNote] = []
    26→    @State private var isDiscovering = false
    27→
    28→    // Streaming states
    29→    @State private var isStreaming = false
    30→    @State private var streamingMessageId: UUID?
    31→
    32→    // Multi-agent states
    33→    @AppStorage("aiChat_useMultiAgent") private var useMultiAgent = false
    34→    @AppStorage("aiChat_useIntelligentTools") private var useIntelligentTools = true
    35→    @State private var showMultiAgentPicker = false
    36→
    37→    // Computed scroll trigger - consolidates multiple onChange handlers
    38→    private var scrollTrigger: String {
    39→        "\(messages.count)-\(isProcessing)-\(isStreaming)-\(messages.last?.content.count ?? 0)"
    40→    }
    41→
    42→    var body: some View {
    43→        NavigationStack {
    44→            ZStack {
    45→                VStack(spacing: 0) {
    46→                    // Context Banner (manual selection)
    47→                    if !selectedNotes.isEmpty {
    48→                        contextBanner
    49→                    }
    50→
    51→                    // Auto-discovered context banner
    52→                    if !discoveredNotes.isEmpty && selectedNotes.isEmpty {
    53→                        autoContextBanner
    54→                    }
    55→
    56→                    // Messages
    57→                    messagesList
    58→
    59→                    // Input
    60→                    inputBar
    61→                }
    62→
    63→                // Toast
    64→                if showCopiedToast {
    65→                    VStack {
    66→                        toastView
    67→                            .padding(.top, 60)
    68→                        Spacer()
    69→                    }
    70→                    .transition(.move(edge: .top).combined(with: .opacity))
    71→                }
    72→            }
    73→            .navigationTitle("AI Chat")
    74→            .navigationBarTitleDisplayMode(.inline)
    75→            .toolbar {
    76→                ToolbarItem(placement: .navigationBarLeading) {
    77→                    if !messages.isEmpty {
    78→                        Button(action: clearChat) {
    79→                            Image(systemName: "trash")
    80→                                .foregroundColor(.red)
    81→                        }
    82→                    }
    83→                }
    84→
    85→                ToolbarItem(placement: .principal) {
    86→                    HStack(spacing: 6) {
    87→                        Circle()
    88→                            .fill(useMultiAgent && multiAgent.canUseMultiAgent ? Color.purple : Color.green)
    89→                            .frame(width: 8, height: 8)
    90→
    91→                        if useMultiAgent && multiAgent.canUseMultiAgent {
    92→                            Text("Multi-Agent (\(multiAgent.agentCount))")
    93→                                .font(.caption)
    94→                                .foregroundColor(.purple)
    95→                        } else {
    96→                            Text(lmService.selectedModel.components(separatedBy: "/").last ?? "AI")
    97→                                .font(.caption)
    98→                                .foregroundColor(.secondary)
    99→                        }
   100→                    }
   101→                }
   102→
   103→                ToolbarItem(placement: .navigationBarTrailing) {
   104→                    HStack(spacing: 12) {
   105→                        // Multi-agent toggle (only show if agents are available)
   106→                        if multiAgent.canUseMultiAgent {
   107→                            Button(action: { useMultiAgent.toggle() }) {
   108→                                Image(systemName: useMultiAgent ? "cpu.fill" : "cpu")
   109→                                    .foregroundColor(useMultiAgent ? .purple : .secondary)
   110→                            }
   111→                        }
   112→
   113→                        // Auto-context toggle
   114→                        Button(action: { autoContextEnabled.toggle() }) {
   115→                            Image(systemName: autoContextEnabled ? "sparkles" : "sparkles.slash")
   116→                                .foregroundColor(autoContextEnabled ? .purple : .secondary)
   117→                        }
   118→
   119→                        // Manual context picker
   120→                        Button(action: { showContextPicker = true }) {
   121→                            HStack(spacing: 4) {
   122→                                Image(systemName: "doc.badge.plus")
   123→                                if !selectedNotes.isEmpty {
   124→                                    Text("\(selectedNotes.count)")
   125→                                        .font(.caption2)
   126→                                        .fontWeight(.bold)
   127→                                        .foregroundColor(.white)
   128→                                        .padding(.horizontal, 5)
   129→                                        .padding(.vertical, 2)
   130→                                        .background(Circle().fill(Color.purple))
   131→                                }
   132→                            }
   133→                        }
   134→                    }
   135→                }
   136→            }
   137→            .sheet(isPresented: $showContextPicker) {
   138→                NotePicker(selectedNotes: $selectedNotes, onConfirm: loadContext)
   139→            }
   140→        }
   141→    }
   142→
   143→    // MARK: - Subviews
   144→
   145→    private var contextBanner: some View {
   146→        VStack(spacing: 0) {
   147→            ScrollView(.horizontal, showsIndicators: false) {
   148→                HStack(spacing: 8) {
   149→                    ForEach(selectedNotes) { note in
   150→                        HStack(spacing: 6) {
   151→                            Image(systemName: "doc.text.fill")
   152→                                .font(.caption2)
   153→                                .foregroundColor(.purple)
   154→
   155→                            Text(note.displayName)
   156→                                .font(.caption)
   157→                                .fontWeight(.medium)
   158→                                .lineLimit(1)
   159→
   160→                            Button(action: { removeNote(note) }) {
   161→                                Image(systemName: "xmark.circle.fill")
   162→                                    .font(.caption)
   163→                                    .foregroundColor(.secondary)
   164→                            }
   165→                        }
   166→                        .padding(.horizontal, 10)
   167→                        .padding(.vertical, 6)
   168→                        .background(Color.purple.opacity(0.12))
   169→                        .cornerRadius(16)
   170→                    }
   171→                }
   172→                .padding(.horizontal)
   173→                .padding(.vertical, 10)
   174→            }
   175→
   176→            Divider()
   177→        }
   178→        .background(Color(.systemBackground))
   179→    }
   180→
   181→    private var autoContextBanner: some View {
   182→        VStack(spacing: 0) {
   183→            HStack(spacing: 8) {
   184→                Image(systemName: "sparkles")
   185→                    .font(.caption)
   186→                    .foregroundColor(.purple)
   187→
   188→                Text("Auto-discovered:")
   189→                    .font(.caption)
   190→                    .foregroundColor(.secondary)
   191→
   192→                ScrollView(.horizontal, showsIndicators: false) {
   193→                    HStack(spacing: 6) {
   194→                        ForEach(discoveredNotes) { note in
   195→                            Text(note.title)
   196→                                .font(.caption2)
   197→                                .fontWeight(.medium)
   198→                                .padding(.horizontal, 8)
   199→                                .padding(.vertical, 4)
   200→                                .background(Color.purple.opacity(0.15))
   201→                                .cornerRadius(10)
   202→                        }
   203→                    }
   204→                }
   205→
   206→                Button(action: { discoveredNotes = [] }) {
   207→                    Image(systemName: "xmark.circle.fill")
   208→                        .font(.caption)
   209→                        .foregroundColor(.secondary)
   210→                }
   211→            }
   212→            .padding(.horizontal)
   213→            .padding(.vertical, 8)
   214→
   215→            Divider()
   216→        }
   217→        .background(Color.purple.opacity(0.05))
   218→    }
   219→
   220→    private var messagesList: some View {
   221→        ScrollViewReader { proxy in
   222→            ScrollView {
   223→                LazyVStack(spacing: 16) {
   224→                    if messages.isEmpty {
   225→                        emptyStateView
   226→                            .padding(.top, 40)
   227→                    }
   228→
   229→                    ForEach(messages) { message in
   230→                        MessageBubble(
   231→                            message: message,
   232→                            isStreaming: isStreaming && message.id == streamingMessageId,
   233→                            onCopy: {
   234→                                copyMessage(message.content)
   235→                            },
   236→                            onRegenerate: message.role == .assistant ? {
   237→                                regenerateResponse(for: message)
   238→                            } : nil
   239→                        )
   240→                        .id(message.id)
   241→                    }
   242→
   243→                    // Show typing indicator only when processing but not yet streaming
   244→                    if isProcessing && !isStreaming {
   245→                        HStack {
   246→                            TypingIndicator()
   247→                            Spacer()
   248→                        }
   249→                        .padding(.horizontal)
   250→                        .id("typing")
   251→                    }
   252→                }
   253→                .padding()
   254→            }
   255→            .onChange(of: scrollTrigger) { _, _ in
   256→                scrollToBottom(proxy: proxy)
   257→            }
   258→        }
   259→    }
   260→
   261→    private func scrollToBottom(proxy: ScrollViewProxy) {
   262→        // Use shorter animation during streaming for smoother UX
   263→        let duration = isStreaming ? 0.1 : 0.3
   264→
   265→        withAnimation(.easeOut(duration: duration)) {
   266→            if isProcessing && !isStreaming {
   267→                proxy.scrollTo("typing", anchor: .bottom)
   268→            } else if let lastMessage = messages.last {
   269→                proxy.scrollTo(lastMessage.id, anchor: .bottom)
   270→            }
   271→        }
   272→    }
   273→
   274→    private var emptyStateView: some View {
   275→        VStack(spacing: 24) {
   276→            ZStack {
   277→                Circle()
   278→                    .fill(
   279→                        LinearGradient(
   280→                            colors: [Color.purple.opacity(0.3), Color.blue.opacity(0.2)],
   281→                            startPoint: .topLeading,
   282→                            endPoint: .bottomTrailing
   283→                        )
   284→                    )
   285→                    .frame(width: 100, height: 100)
   286→
   287→                Image(systemName: "sparkles")
   288→                    .font(.system(size: 44))
   289→                    .foregroundStyle(
   290→                        LinearGradient(
   291→                            colors: [.purple, .blue],
   292→                            startPoint: .topLeading,
   293→                            endPoint: .bottomTrailing
   294→                        )
   295→                    )
   296→            }
   297→
   298→            VStack(spacing: 8) {
   299→                Text("AI Assistant")
   300→                    .font(.title2)
   301→                    .fontWeight(.semibold)
   302→
   303→                Text("Ask questions about your notes or get help with analysis")
   304→                    .font(.subheadline)
   305→                    .foregroundColor(.secondary)
   306→                    .multilineTextAlignment(.center)
   307→                    .padding(.horizontal)
   308→            }
   309→
   310→            // Suggested prompts
   311→            VStack(spacing: 10) {
   312→                ForEach(suggestedQuestions, id: \.self) { question in
   313→                    Button(action: { inputText = question }) {
   314→                        HStack {
   315→                            Text(question)
   316→                                .font(.subheadline)
   317→                                .foregroundColor(.primary)
   318→                                .multilineTextAlignment(.leading)
   319→
   320→                            Spacer()
   321→
   322→                            Image(systemName: "arrow.up.circle.fill")
   323→                                .foregroundColor(.purple)
   324→                        }
   325→                        .padding(.horizontal, 14)
   326→                        .padding(.vertical, 12)
   327→                        .background(Color(.systemGray6))
   328→                        .cornerRadius(12)
   329→                    }
   330→                    .buttonStyle(.plain)
   331→                }
   332→            }
   333→            .padding(.horizontal)
   334→        }
   335→    }
   336→
   337→    private var suggestedQuestions: [String] {
   338→        if selectedNotes.isEmpty {
   339→            return [
   340→                "/help - Show available commands",
   341→                "/folders - List all folders",
   342→                "/analyze - Get vault overview",
   343→                "/search <query> - Semantic search"
   344→            ]
   345→        } else {
   346→            return [
   347→                "Summarize these notes",
   348→                "What are the main themes?",
   349→                "Find connections between topics",
   350→                "Generate action items"
   351→            ]
   352→        }
   353→    }
   354→
   355→    private var inputBar: some View {
   356→        VStack(spacing: 0) {
   357→            Divider()
   358→
   359→            HStack(alignment: .bottom, spacing: 12) {
   360→                // Text field
   361→                HStack(alignment: .bottom, spacing: 8) {
   362→                    TextField("Ask anything...", text: $inputText, axis: .vertical)
   363→                        .textFieldStyle(.plain)
   364→                        .lineLimit(1...6)
   365→                        .padding(.vertical, 10)
   366→                        .padding(.leading, 14)
   367→
   368→                    if !inputText.isEmpty {
   369→                        Button(action: { inputText = "" }) {
   370→                            Image(systemName: "xmark.circle.fill")
   371→                                .foregroundColor(.secondary)
   372→                        }
   373→                        .padding(.trailing, 8)
   374→                        .padding(.bottom, 10)
   375→                    }
   376→                }
   377→                .background(Color(.systemGray6))
   378→                .cornerRadius(22)
   379→
   380→                // Send button
   381→                Button(action: sendMessage) {
   382→                    ZStack {
   383→                        Circle()
   384→                            .fill(canSend ? Color.purple : Color.gray.opacity(0.3))
   385→                            .frame(width: 44, height: 44)
   386→
   387→                        Image(systemName: "arrow.up")
   388→                            .font(.system(size: 18, weight: .semibold))
   389→                            .foregroundColor(canSend ? .white : .gray)
   390→                    }
   391→                }
   392→                .disabled(!canSend)
   393→            }
   394→            .padding(.horizontal)
   395→            .padding(.vertical, 12)
   396→            .background(Color(.systemBackground))
   397→        }
   398→    }
   399→
   400→    private var toastView: some View {
   401→        HStack(spacing: 8) {
   402→            Image(systemName: "checkmark.circle.fill")
   403→                .foregroundColor(.green)
   404→
   405→            Text("Copied to clipboard")
   406→                .font(.subheadline)
   407→                .fontWeight(.medium)
   408→        }
   409→        .padding(.horizontal, 16)
   410→        .padding(.vertical, 10)
   411→        .background(
   412→            Capsule()
   413→                .fill(Color(.systemBackground))
   414→                .shadow(color: .black.opacity(0.15), radius: 10, y: 5)
   415→        )
   416→    }
   417→
   418→    private var canSend: Bool {
   419→        !inputText.trimmingCharacters(in: .whitespaces).isEmpty && !isProcessing
   420→    }
   421→
   422→    // MARK: - Actions
   423→
   424→    private func sendMessage() {
   425→        let userMessage = inputText.trimmingCharacters(in: .whitespaces)
   426→        guard !userMessage.isEmpty else { return }
   427→
   428→        messages.append(ChatMessage(role: .user, content: userMessage))
   429→        inputText = ""
   430→        isProcessing = true
   431→
   432→        // Check for explicit slash commands first (backward compatibility)
   433→        if let command = AgentCommand.parse(userMessage) {
   434→            processAgentCommand(command, userMessage: userMessage)
   435→            return
   436→        }
   437→
   438→        // Check for intelligent tool selection (if enabled)
   439→        if useIntelligentTools {
   440→            Task {
   441→                await processWithToolSelection(userMessage)
   442→            }
   443→            return
   444→        }
   445→
   446→        Task {
   447→            do {
   448→                var contextToUse = loadedContext
   449→
   450→                // Auto-discover context if enabled and no manual selection
   451→                if autoContextEnabled && selectedNotes.isEmpty {
   452→                    isDiscovering = true
   453→                    discoveredNotes = await aiContextService.discoverContext(for: userMessage)
   454→                    if !discoveredNotes.isEmpty {
   455→                        contextToUse = await aiContextService.loadSuggestedContent()
   456→                    }
   457→                    isDiscovering = false
   458→                }
   459→
   460→                var conversationMessages: [ChatMessage] = []
   461→
   462→                // System message with context
   463→                var systemContent = "You are a helpful AI assistant analyzing Obsidian notes. Be concise but thorough. Use markdown formatting for better readability."
   464→                if !contextToUse.isEmpty {
   465→                    systemContent += "\n\nContext from relevant notes in user's Obsidian vault:\n\n\(contextToUse)"
   466→                    systemContent += "\n\nInstructions: Answer based on the context provided above when relevant. If the context doesn't contain relevant information, you can still provide a helpful general answer."
   467→                }
   468→                conversationMessages.append(ChatMessage(role: .system, content: systemContent))
   469→
   470→                // Add conversation history (last 10 messages)
   471→                let recentMessages = messages.suffix(10)
   472→                conversationMessages.append(contentsOf: recentMessages)
   473→
   474→                // Create empty AI message for streaming/response
   475→                let aiMessageId = UUID()
   476→                await MainActor.run {
   477→                    messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
   478→                    streamingMessageId = aiMessageId
   479→                    isStreaming = true
   480→                }
   481→
   482→                // Use multi-agent or streaming based on settings
   483→                if useMultiAgent && multiAgent.canUseMultiAgent {
   484→                    // Multi-agent mode (non-streaming, parallel processing)
   485→                    await MainActor.run {
   486→                        if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   487→                            messages[index].content = "🔄 Processing with \(multiAgent.agentCount) agents..."
   488→                        }
   489→                    }
   490→
   491→                    // Build full prompt with context
   492→                    let fullPrompt = conversationMessages.map { msg in
   493→                        "[\(msg.role.rawValue.uppercased())]\n\(msg.content)"
   494→                    }.joined(separator: "\n\n---\n\n")
   495→
   496→                    let result = try await multiAgent.analyze(
   497→                        content: fullPrompt,
   498→                        task: .custom(
   499→                            prompt: userMessage,
   500→                            systemPrompt: systemContent
   501→                        )
   502→                    )
   503→
   504→                    await MainActor.run {
   505→                        if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   506→                            var response = result.mergedResponse
   507→                            // Add source attribution
   508→                            if result.agentCount > 1 {
   509→                                response += "\n\n---\n*Response from \(result.sourceDescription) in \(String(format: "%.1f", result.processingTime))s*"
   510→                            }
   511→                            messages[index].content = response
   512→                        }
   513→                        isStreaming = false
   514→                        streamingMessageId = nil
   515→                        isProcessing = false
   516→                    }
   517→                } else {
   518→                    // Streaming mode (single agent, token by token)
   519→                    try await lmService.streamMessage(
   520→                        messages: conversationMessages,
   521→                        onToken: { token in
   522→                            // Update message content in real-time
   523→                            if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   524→                                messages[index].content += token
   525→                            }
   526→                        }
   527→                    )
   528→
   529→                    await MainActor.run {
   530→                        isStreaming = false
   531→                        streamingMessageId = nil
   532→                        isProcessing = false
   533→                    }
   534→                }
   535→            } catch {
   536→                await MainActor.run {
   537→                    // If streaming was in progress, update the message with error
   538→                    if let msgId = streamingMessageId,
   539→                       let index = messages.firstIndex(where: { $0.id == msgId }) {
   540→                        if messages[index].content.isEmpty {
   541→                            messages[index].content = "Sorry, I encountered an error: \(error.localizedDescription)"
   542→                        } else {
   543→                            messages[index].content += "\n\n⚠ Error: \(error.localizedDescription)"
   544→                        }
   545→                    } else {
   546→                        messages.append(ChatMessage(
   547→                            role: .assistant,
   548→                            content: "Sorry, I encountered an error: \(error.localizedDescription)"
   549→                        ))
   550→                    }
   551→                    isStreaming = false
   552→                    streamingMessageId = nil
   553→                    isProcessing = false
   554→                    isDiscovering = false
   555→                }
   556→            }
   557→        }
   558→    }
   559→
   560→    // MARK: - Intelligent Tool Selection
   561→
   562→    /// Process message with intelligent tool selection
   563→    private func processWithToolSelection(_ userMessage: String) async {
   564→        Log.info("🤖 [Chat] Processing with intelligent tool selection", category: .ai)
   565→
   566→        // Create AI message placeholder
   567→        let aiMessageId = UUID()
   568→        await MainActor.run {
   569→            messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
   570→            streamingMessageId = aiMessageId
   571→            isStreaming = true
   572→        }
   573→
   574→        // Step 1: Check if a tool is needed
   575→        await MainActor.run {
   576→            if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   577→                messages[index].content = "🔍 Analyzing request..."
   578→            }
   579→        }
   580→
   581→        let toolCall = await toolSelector.analyzeForTools(userMessage)
   582→
   583→        if let tool = toolCall {
   584→            // Step 2: Execute the tool
   585→            Log.info("🤖 [Chat] Tool selected: \(tool.tool)", category: .ai)
   586→
   587→            await MainActor.run {
   588→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   589→                    let toolDesc = toolExecutor.getToolDescription(tool.tool) ?? tool.tool
   590→                    messages[index].content = "⚙ Executing: \(toolDesc)..."
   591→                }
   592→            }
   593→
   594→            let result = await toolExecutor.execute(tool)
   595→
   596→            // Step 3: Show tool result directly (or optionally enhance with AI)
   597→            await MainActor.run {
   598→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   599→                    if result.success {
   600→                        messages[index].content = result.output
   601→                    } else {
   602→                        messages[index].content = "❌ \(result.output)"
   603→                    }
   604→                }
   605→                isStreaming = false
   606→                streamingMessageId = nil
   607→                isProcessing = false
   608→            }
   609→
   610→        } else {
   611→            // No tool needed - process as regular conversation
   612→            Log.info("🤖 [Chat] No tool needed, processing as regular message", category: .ai)
   613→            await processRegularMessage(userMessage, aiMessageId: aiMessageId)
   614→        }
   615→    }
   616→
   617→    /// Process a regular message (no tool) with context and AI
   618→    private func processRegularMessage(_ userMessage: String, aiMessageId: UUID) async {
   619→        do {
   620→            var contextToUse = loadedContext
   621→
   622→            // Auto-discover context if enabled and no manual selection
   623→            if autoContextEnabled && selectedNotes.isEmpty {
   624→                await MainActor.run { isDiscovering = true }
   625→                let discovered = await aiContextService.discoverContext(for: userMessage)
   626→                await MainActor.run {
   627→                    discoveredNotes = discovered
   628→                    isDiscovering = false
   629→                }
   630→                if !discovered.isEmpty {
   631→                    contextToUse = await aiContextService.loadSuggestedContent()
   632→                }
   633→            }
   634→
   635→            var conversationMessages: [ChatMessage] = []
   636→
   637→            // System message with context
   638→            var systemContent = "You are a helpful AI assistant analyzing Obsidian notes. Be concise but thorough. Use markdown formatting for better readability."
   639→            if !contextToUse.isEmpty {
   640→                systemContent += "\n\nContext from relevant notes in user's Obsidian vault:\n\n\(contextToUse)"
   641→                systemContent += "\n\nInstructions: Answer based on the context provided above when relevant."
   642→            }
   643→            conversationMessages.append(ChatMessage(role: .system, content: systemContent))
   644→
   645→            // Add conversation history (last 10 messages)
   646→            let recentMessages = messages.suffix(10)
   647→            conversationMessages.append(contentsOf: recentMessages)
   648→
   649→            // Use streaming
   650→            await MainActor.run {
   651→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   652→                    messages[index].content = ""
   653→                }
   654→            }
   655→
   656→            // Stream response using callback
   657→            try await lmService.streamMessage(
   658→                messages: conversationMessages,
   659→                onToken: { token in
   660→                    if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   661→                        messages[index].content += token
   662→                    }
   663→                }
   664→            )
   665→
   666→            await MainActor.run {
   667→                isStreaming = false
   668→                streamingMessageId = nil
   669→                isProcessing = false
   670→            }
   671→
   672→        } catch {
   673→            await MainActor.run {
   674→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   675→                    messages[index].content = "Error: \(error.localizedDescription)"
   676→                }
   677→                isStreaming = false
   678→                streamingMessageId = nil
   679→                isProcessing = false
   680→            }
   681→        }
   682→    }
   683→
   684→    // MARK: - Agent Commands
   685→
   686→    private func processAgentCommand(_ command: AgentCommand, userMessage: String) {
   687→        Task {
   688→            // Create AI message for response
   689→            let aiMessageId = UUID()
   690→            await MainActor.run {
   691→                messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
   692→                streamingMessageId = aiMessageId
   693→                isStreaming = true
   694→            }
   695→
   696→            // Show processing indicator
   697→            await MainActor.run {
   698→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   699→                    messages[index].content = "Processing `\(command.rawValue)` command..."
   700→                }
   701→            }
   702→
   703→            // Extract argument from user message
   704→            let argument = AgentCommand.extractArgument(from: userMessage)
   705→
   706→            // Process command with or without argument
   707→            let response: String
   708→            if let arg = argument, !arg.isEmpty {
   709→                response = await vaultAgent.processCommandWithArgument(command, argument: arg)
   710→            } else {
   711→                response = await vaultAgent.processCommand(command)
   712→            }
   713→
   714→            // Update message with response
   715→            await MainActor.run {
   716→                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
   717→                    messages[index].content = response
   718→                }
   719→                isStreaming = false
   720→                streamingMessageId = nil
   721→                isProcessing = false
   722→            }
   723→        }
   724→    }
   725→
   726→    // MARK: - Context Loading
   727→
   728→    private func loadContext() {
   729→        guard !selectedNotes.isEmpty else {
   730→            loadedContext = ""
   731→            return
   732→        }
   733→
   734→        Task {
   735→            var contextParts: [String] = []
   736→
   737→            for note in selectedNotes {
   738→                if let content = try? await obsidianService.getFileContents(note.path) {
   739→                    contextParts.append("## \(note.displayName)\n\n\(content)")
   740→                }
   741→            }
   742→
   743→            await MainActor.run {
   744→                loadedContext = contextParts.joined(separator: "\n\n---\n\n")
   745→            }
   746→        }
   747→    }
   748→
   749→    private func removeNote(_ note: ObsidianFile) {
   750→        selectedNotes.removeAll { $0.id == note.id }
   751→        loadContext()
   752→    }
   753→
   754→    private func clearChat() {
   755→        messages.removeAll()
   756→        loadedContext = ""
   757→        selectedNotes.removeAll()
   758→        discoveredNotes.removeAll()
   759→        aiContextService.clearContext()
   760→    }
   761→
   762→    private func copyMessage(_ content: String) {
   763→        UIPasteboard.general.string = content
   764→        withAnimation {
   765→            showCopiedToast = true
   766→        }
   767→        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
   768→            withAnimation {
   769→                showCopiedToast = false
   770→            }
   771→        }
   772→    }
   773→
   774→    private func regenerateResponse(for message: ChatMessage) {
   775→        // Find the index of this assistant message
   776→        guard let assistantIndex = messages.firstIndex(where: { $0.id == message.id }),
   777→              assistantIndex > 0 else { return }
   778→
   779→        // Find the preceding user message
   780→        var userMessageIndex = assistantIndex - 1
   781→        while userMessageIndex >= 0 && messages[userMessageIndex].role != .user {
   782→            userMessageIndex -= 1
   783→        }
   784→
   785→        guard userMessageIndex >= 0 else { return }
   786→
   787→        // Remove the assistant message we're regenerating
   788→        messages.remove(at: assistantIndex)
   789→
   790→        // Re-send the user message to get a new response
   791→        let userContent = messages[userMessageIndex].content
   792→        inputText = userContent
   793→        messages.remove(at: userMessageIndex)
   794→        sendMessage()
   795→    }
   796→}
   797→
   798→// MARK: - Note Picker
   799→
   800→struct NotePicker: View {
   801→    @EnvironmentObject var obsidianService: ObsidianService
   802→
   803→    @Binding var selectedNotes: [ObsidianFile]
   804→    let onConfirm: () -> Void
   805→
   806→    @Environment(\.dismiss) var dismiss
   807→    @State private var availableNotes: [ObsidianFile] = []
   808→    @State private var isLoading = true
   809→    @State private var searchText = ""
   810→
   811→    var filteredNotes: [ObsidianFile] {
   812→        // availableNotes already contains only .md files from listAllMarkdownFiles()
   813→        if searchText.isEmpty {
   814→            return availableNotes
   815→        }
   816→        return availableNotes.filter {
   817→            $0.name.localizedCaseInsensitiveContains(searchText) ||
   818→            $0.path.localizedCaseInsensitiveContains(searchText)
   819→        }
   820→    }
   821→
   822→    var body: some View {
   823→        NavigationStack {
   824→            Group {
   825→                if isLoading {
   826→                    VStack(spacing: 16) {
   827→                        ProgressView()
   828→                        Text("Loading notes...")
   829→                            .font(.subheadline)
   830→                            .foregroundColor(.secondary)
   831→                    }
   832→                } else if filteredNotes.isEmpty {
   833→                    VStack(spacing: 16) {
   834→                        Image(systemName: "doc.text")
   835→                            .font(.system(size: 48))
   836→                            .foregroundColor(.secondary)
   837→
   838→                        Text("No notes found")
   839→                            .font(.headline)
   840→                    }
   841→                } else {
   842→                    List {
   843→                        ForEach(filteredNotes) { note in
   844→                            Button(action: { toggleSelection(note) }) {
   845→                                HStack(spacing: 12) {
   846→                                    ZStack {
   847→                                        RoundedRectangle(cornerRadius: 6)
   848→                                            .fill(isSelected(note) ? Color.purple : Color(.systemGray6))
   849→                                            .frame(width: 28, height: 28)
   850→
   851→                                        Image(systemName: isSelected(note) ? "checkmark" : "doc.text")
   852→                                            .font(.system(size: 12, weight: .semibold))
   853→                                            .foregroundColor(isSelected(note) ? .white : .purple)
   854→                                    }
   855→
   856→                                    VStack(alignment: .leading, spacing: 2) {
   857→                                        Text(note.displayName)
   858→                                            .font(.body)
   859→                                            .fontWeight(.medium)
   860→                                            .foregroundColor(.primary)
   861→
   862→                                        Text(note.path)
   863→                                            .font(.caption)
   864→                                            .foregroundColor(.secondary)
   865→                                    }
   866→
   867→                                    Spacer()
   868→                                }
   869→                            }
   870→                            .listRowBackground(isSelected(note) ? Color.purple.opacity(0.1) : Color.clear)
   871→                        }
   872→                    }
   873→                    .listStyle(.plain)
   874→                }
   875→            }
   876→            .searchable(text: $searchText, prompt: "Search notes...")
   877→            .navigationTitle("Add Context")
   878→            .navigationBarTitleDisplayMode(.inline)
   879→            .toolbar {
   880→                ToolbarItem(placement: .navigationBarLeading) {
   881→                    Button("Cancel") {
   882→                        dismiss()
   883→                    }
   884→                }
   885→
   886→                ToolbarItem(placement: .navigationBarTrailing) {
   887→                    Button(action: {
   888→                        onConfirm()
   889→                        dismiss()
   890→                    }) {
   891→                        Text("Add")
   892→                            .fontWeight(.semibold)
   893→                        if !selectedNotes.isEmpty {
   894→                            Text("(\(selectedNotes.count))")
   895→                        }
   896→                    }
   897→                    .disabled(selectedNotes.isEmpty)
   898→                }
   899→            }
   900→            .task {
   901→                await loadNotes()
   902→            }
   903→        }
   904→    }
   905→
   906→    private func isSelected(_ note: ObsidianFile) -> Bool {
   907→        selectedNotes.contains { $0.id == note.id }
   908→    }
   909→
   910→    private func toggleSelection(_ note: ObsidianFile) {
   911→        if isSelected(note) {
   912→            selectedNotes.removeAll { $0.id == note.id }
   913→        } else {
   914→            selectedNotes.append(note)
   915→        }
   916→    }
   917→
   918→    private func loadNotes() async {
   919→        isLoading = true
   920→        do {
   921→            // Use recursive listing to get ALL markdown files from vault
   922→            availableNotes = try await obsidianService.listAllMarkdownFiles()
   923→        } catch {
   924→            availableNotes = []
   925→        }
   926→        isLoading = false
   927→    }
   928→}
   929→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import SwiftUI

struct AIChatView: View {
    @EnvironmentObject var obsidianService: ObsidianService
    @EnvironmentObject var lmService: LMGatewayService
    @EnvironmentObject var aiContextService: AIContextService
    @EnvironmentObject var connectionService: SmartConnectionService
    @EnvironmentObject var vaultIndexService: VaultIndexService
    @EnvironmentObject var vaultAgent: VaultAgentService
    @EnvironmentObject var toolSelector: AgentToolSelector
    @EnvironmentObject var toolExecutor: AgentToolExecutor
    @ObservedObject private var multiAgent = DualAgentProcessor.shared

    @State private var messages: [ChatMessage] = []
    @State private var showCommandSuggestions = false
    @State private var inputText = ""
    @State private var isProcessing = false
    @State private var showContextPicker = false
    @State private var selectedNotes: [ObsidianFile] = []
    @State private var loadedContext: String = ""
    @State private var showCopiedToast = false

    // Auto-context states
    @State private var autoContextEnabled = true
    @State private var discoveredNotes: [VaultIndex.IndexedNote] = []
    @State private var isDiscovering = false

    // Streaming states
    @State private var isStreaming = false
    @State private var streamingMessageId: UUID?

    // Multi-agent states
    @AppStorage("aiChat_useMultiAgent") private var useMultiAgent = false
    @AppStorage("aiChat_useIntelligentTools") private var useIntelligentTools = true
    @State private var showMultiAgentPicker = false

    // Computed scroll trigger - consolidates multiple onChange handlers
    private var scrollTrigger: String {
        "\(messages.count)-\(isProcessing)-\(isStreaming)-\(messages.last?.content.count ?? 0)"
    }

    var body: some View {
        NavigationStack {
            ZStack {
                VStack(spacing: 0) {
                    // Context Banner (manual selection)
                    if !selectedNotes.isEmpty {
                        contextBanner
                    }

                    // Auto-discovered context banner
                    if !discoveredNotes.isEmpty && selectedNotes.isEmpty {
                        autoContextBanner
                    }

                    // Messages
                    messagesList

                    // Input
                    inputBar
                }

                // Toast
                if showCopiedToast {
                    VStack {
                        toastView
                            .padding(.top, 60)
                        Spacer()
                    }
                    .transition(.move(edge: .top).combined(with: .opacity))
                }
            }
            .navigationTitle("AI Chat")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    if !messages.isEmpty {
                        Button(action: clearChat) {
                            Image(systemName: "trash")
                                .foregroundColor(.red)
                        }
                    }
                }

                ToolbarItem(placement: .principal) {
                    HStack(spacing: 6) {
                        Circle()
                            .fill(useMultiAgent && multiAgent.canUseMultiAgent ? Color.purple : Color.green)
                            .frame(width: 8, height: 8)

                        if useMultiAgent && multiAgent.canUseMultiAgent {
                            Text("Multi-Agent (\(multiAgent.agentCount))")
                                .font(.caption)
                                .foregroundColor(.purple)
                        } else {
                            Text(lmService.selectedModel.components(separatedBy: "/").last ?? "AI")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }

                ToolbarItem(placement: .navigationBarTrailing) {
                    HStack(spacing: 12) {
                        // Multi-agent toggle (only show if agents are available)
                        if multiAgent.canUseMultiAgent {
                            Button(action: { useMultiAgent.toggle() }) {
                                Image(systemName: useMultiAgent ? "cpu.fill" : "cpu")
                                    .foregroundColor(useMultiAgent ? .purple : .secondary)
                            }
                        }

                        // Auto-context toggle
                        Button(action: { autoContextEnabled.toggle() }) {
                            Image(systemName: autoContextEnabled ? "sparkles" : "sparkles.slash")
                                .foregroundColor(autoContextEnabled ? .purple : .secondary)
                        }

                        // Manual context picker
                        Button(action: { showContextPicker = true }) {
                            HStack(spacing: 4) {
                                Image(systemName: "doc.badge.plus")
                                if !selectedNotes.isEmpty {
                                    Text("\(selectedNotes.count)")
                                        .font(.caption2)
                                        .fontWeight(.bold)
                                        .foregroundColor(.white)
                                        .padding(.horizontal, 5)
                                        .padding(.vertical, 2)
                                        .background(Circle().fill(Color.purple))
                                }
                            }
                        }
                    }
                }
            }
            .sheet(isPresented: $showContextPicker) {
                NotePicker(selectedNotes: $selectedNotes, onConfirm: loadContext)
            }
        }
    }

    // MARK: - Subviews

    private var contextBanner: some View {
        VStack(spacing: 0) {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 8) {
                    ForEach(selectedNotes) { note in
                        HStack(spacing: 6) {
                            Image(systemName: "doc.text.fill")
                                .font(.caption2)
                                .foregroundColor(.purple)

                            Text(note.displayName)
                                .font(.caption)
                                .fontWeight(.medium)
                                .lineLimit(1)

                            Button(action: { removeNote(note) }) {
                                Image(systemName: "xmark.circle.fill")
                                    .font(.caption)
                                    .foregroundColor(.secondary)
                            }
                        }
                        .padding(.horizontal, 10)
                        .padding(.vertical, 6)
                        .background(Color.purple.opacity(0.12))
                        .cornerRadius(16)
                    }
                }
                .padding(.horizontal)
                .padding(.vertical, 10)
            }

            Divider()
        }
        .background(Color(.systemBackground))
    }

    private var autoContextBanner: some View {
        VStack(spacing: 0) {
            HStack(spacing: 8) {
                Image(systemName: "sparkles")
                    .font(.caption)
                    .foregroundColor(.purple)

                Text("Auto-discovered:")
                    .font(.caption)
                    .foregroundColor(.secondary)

                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 6) {
                        ForEach(discoveredNotes) { note in
                            Text(note.title)
                                .font(.caption2)
                                .fontWeight(.medium)
                                .padding(.horizontal, 8)
                                .padding(.vertical, 4)
                                .background(Color.purple.opacity(0.15))
                                .cornerRadius(10)
                        }
                    }
                }

                Button(action: { discoveredNotes = [] }) {
                    Image(systemName: "xmark.circle.fill")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 8)

            Divider()
        }
        .background(Color.purple.opacity(0.05))
    }

    private var messagesList: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 16) {
                    if messages.isEmpty {
                        emptyStateView
                            .padding(.top, 40)
                    }

                    ForEach(messages) { message in
                        MessageBubble(
                            message: message,
                            isStreaming: isStreaming && message.id == streamingMessageId,
                            onCopy: {
                                copyMessage(message.content)
                            },
                            onRegenerate: message.role == .assistant ? {
                                regenerateResponse(for: message)
                            } : nil
                        )
                        .id(message.id)
                    }

                    // Show typing indicator only when processing but not yet streaming
                    if isProcessing && !isStreaming {
                        HStack {
                            TypingIndicator()
                            Spacer()
                        }
                        .padding(.horizontal)
                        .id("typing")
                    }
                }
                .padding()
            }
            .onChange(of: scrollTrigger) { _, _ in
                scrollToBottom(proxy: proxy)
            }
        }
    }

    private func scrollToBottom(proxy: ScrollViewProxy) {
        // Use shorter animation during streaming for smoother UX
        let duration = isStreaming ? 0.1 : 0.3

        withAnimation(.easeOut(duration: duration)) {
            if isProcessing && !isStreaming {
                proxy.scrollTo("typing", anchor: .bottom)
            } else if let lastMessage = messages.last {
                proxy.scrollTo(lastMessage.id, anchor: .bottom)
            }
        }
    }

    private var emptyStateView: some View {
        VStack(spacing: 24) {
            ZStack {
                Circle()
                    .fill(
                        LinearGradient(
                            colors: [Color.purple.opacity(0.3), Color.blue.opacity(0.2)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
                    .frame(width: 100, height: 100)

                Image(systemName: "sparkles")
                    .font(.system(size: 44))
                    .foregroundStyle(
                        LinearGradient(
                            colors: [.purple, .blue],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
            }

            VStack(spacing: 8) {
                Text("AI Assistant")
                    .font(.title2)
                    .fontWeight(.semibold)

                Text("Ask questions about your notes or get help with analysis")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
            }

            // Suggested prompts
            VStack(spacing: 10) {
                ForEach(suggestedQuestions, id: \.self) { question in
                    Button(action: { inputText = question }) {
                        HStack {
                            Text(question)
                                .font(.subheadline)
                                .foregroundColor(.primary)
                                .multilineTextAlignment(.leading)

                            Spacer()

                            Image(systemName: "arrow.up.circle.fill")
                                .foregroundColor(.purple)
                        }
                        .padding(.horizontal, 14)
                        .padding(.vertical, 12)
                        .background(Color(.systemGray6))
                        .cornerRadius(12)
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding(.horizontal)
        }
    }

    private var suggestedQuestions: [String] {
        if selectedNotes.isEmpty {
            return [
                "/help - Show available commands",
                "/folders - List all folders",
                "/analyze - Get vault overview",
                "/search <query> - Semantic search"
            ]
        } else {
            return [
                "Summarize these notes",
                "What are the main themes?",
                "Find connections between topics",
                "Generate action items"
            ]
        }
    }

    private var inputBar: some View {
        VStack(spacing: 0) {
            Divider()

            HStack(alignment: .bottom, spacing: 12) {
                // Text field
                HStack(alignment: .bottom, spacing: 8) {
                    TextField("Ask anything...", text: $inputText, axis: .vertical)
                        .textFieldStyle(.plain)
                        .lineLimit(1...6)
                        .padding(.vertical, 10)
                        .padding(.leading, 14)

                    if !inputText.isEmpty {
                        Button(action: { inputText = "" }) {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundColor(.secondary)
                        }
                        .padding(.trailing, 8)
                        .padding(.bottom, 10)
                    }
                }
                .background(Color(.systemGray6))
                .cornerRadius(22)

                // Send button
                Button(action: sendMessage) {
                    ZStack {
                        Circle()
                            .fill(canSend ? Color.purple : Color.gray.opacity(0.3))
                            .frame(width: 44, height: 44)

                        Image(systemName: "arrow.up")
                            .font(.system(size: 18, weight: .semibold))
                            .foregroundColor(canSend ? .white : .gray)
                    }
                }
                .disabled(!canSend)
            }
            .padding(.horizontal)
            .padding(.vertical, 12)
            .background(Color(.systemBackground))
        }
    }

    private var toastView: some View {
        HStack(spacing: 8) {
            Image(systemName: "checkmark.circle.fill")
                .foregroundColor(.green)

            Text("Copied to clipboard")
                .font(.subheadline)
                .fontWeight(.medium)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
        .background(
            Capsule()
                .fill(Color(.systemBackground))
                .shadow(color: .black.opacity(0.15), radius: 10, y: 5)
        )
    }

    private var canSend: Bool {
        !inputText.trimmingCharacters(in: .whitespaces).isEmpty && !isProcessing
    }

    // MARK: - Actions

    private func sendMessage() {
        let userMessage = inputText.trimmingCharacters(in: .whitespaces)
        guard !userMessage.isEmpty else { return }

        messages.append(ChatMessage(role: .user, content: userMessage))
        inputText = ""
        isProcessing = true

        // Check for explicit slash commands first (backward compatibility)
        if let command = AgentCommand.parse(userMessage) {
            processAgentCommand(command, userMessage: userMessage)
            return
        }

        // Check for intelligent tool selection (if enabled)
        if useIntelligentTools {
            Task {
                await processWithToolSelection(userMessage)
            }
            return
        }

        Task {
            do {
                var contextToUse = loadedContext

                // Auto-discover context if enabled and no manual selection
                if autoContextEnabled && selectedNotes.isEmpty {
                    isDiscovering = true
                    discoveredNotes = await aiContextService.discoverContext(for: userMessage)
                    if !discoveredNotes.isEmpty {
                        contextToUse = await aiContextService.loadSuggestedContent()
                    }
                    isDiscovering = false
                }

                var conversationMessages: [ChatMessage] = []

                // System message with context
                var systemContent = "You are a helpful AI assistant analyzing Obsidian notes. Be concise but thorough. Use markdown formatting for better readability."
                if !contextToUse.isEmpty {
                    systemContent += "\n\nContext from relevant notes in user's Obsidian vault:\n\n\(contextToUse)"
                    systemContent += "\n\nInstructions: Answer based on the context provided above when relevant. If the context doesn't contain relevant information, you can still provide a helpful general answer."
                }
                conversationMessages.append(ChatMessage(role: .system, content: systemContent))

                // Add conversation history (last 10 messages)
                let recentMessages = messages.suffix(10)
                conversationMessages.append(contentsOf: recentMessages)

                // Create empty AI message for streaming/response
                let aiMessageId = UUID()
                await MainActor.run {
                    messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
                    streamingMessageId = aiMessageId
                    isStreaming = true
                }

                // Use multi-agent or streaming based on settings
                if useMultiAgent && multiAgent.canUseMultiAgent {
                    // Multi-agent mode (non-streaming, parallel processing)
                    await MainActor.run {
                        if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                            messages[index].content = "🔄 Processing with \(multiAgent.agentCount) agents..."
                        }
                    }

                    // Build full prompt with context
                    let fullPrompt = conversationMessages.map { msg in
                        "[\(msg.role.rawValue.uppercased())]\n\(msg.content)"
                    }.joined(separator: "\n\n---\n\n")

                    let result = try await multiAgent.analyze(
                        content: fullPrompt,
                        task: .custom(
                            prompt: userMessage,
                            systemPrompt: systemContent
                        )
                    )

                    await MainActor.run {
                        if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                            var response = result.mergedResponse
                            // Add source attribution
                            if result.agentCount > 1 {
                                response += "\n\n---\n*Response from \(result.sourceDescription) in \(String(format: "%.1f", result.processingTime))s*"
                            }
                            messages[index].content = response
                        }
                        isStreaming = false
                        streamingMessageId = nil
                        isProcessing = false
                    }
                } else {
                    // Streaming mode (single agent, token by token)
                    try await lmService.streamMessage(
                        messages: conversationMessages,
                        onToken: { token in
                            // Update message content in real-time
                            if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                                messages[index].content += token
                            }
                        }
                    )

                    await MainActor.run {
                        isStreaming = false
                        streamingMessageId = nil
                        isProcessing = false
                    }
                }
            } catch {
                await MainActor.run {
                    // If streaming was in progress, update the message with error
                    if let msgId = streamingMessageId,
                       let index = messages.firstIndex(where: { $0.id == msgId }) {
                        if messages[index].content.isEmpty {
                            messages[index].content = "Sorry, I encountered an error: \(error.localizedDescription)"
                        } else {
                            messages[index].content += "\n\n⚠ Error: \(error.localizedDescription)"
                        }
                    } else {
                        messages.append(ChatMessage(
                            role: .assistant,
                            content: "Sorry, I encountered an error: \(error.localizedDescription)"
                        ))
                    }
                    isStreaming = false
                    streamingMessageId = nil
                    isProcessing = false
                    isDiscovering = false
                }
            }
        }
    }

    // MARK: - Intelligent Tool Selection

    /// Process message with intelligent tool selection
    private func processWithToolSelection(_ userMessage: String) async {
        Log.info("🤖 [Chat] Processing with intelligent tool selection", category: .ai)

        // Create AI message placeholder
        let aiMessageId = UUID()
        await MainActor.run {
            messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
            streamingMessageId = aiMessageId
            isStreaming = true
        }

        // Step 1: Check if a tool is needed
        await MainActor.run {
            if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                messages[index].content = "🔍 Analyzing request..."
            }
        }

        let toolCall = await toolSelector.analyzeForTools(userMessage)

        if let tool = toolCall {
            // Step 2: Execute the tool
            Log.info("🤖 [Chat] Tool selected: \(tool.tool)", category: .ai)

            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    let toolDesc = toolExecutor.getToolDescription(tool.tool) ?? tool.tool
                    messages[index].content = "⚙ Executing: \(toolDesc)..."
                }
            }

            let result = await toolExecutor.execute(tool)

            // Step 3: Show tool result directly (or optionally enhance with AI)
            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    if result.success {
                        messages[index].content = result.output
                    } else {
                        messages[index].content = "❌ \(result.output)"
                    }
                }
                isStreaming = false
                streamingMessageId = nil
                isProcessing = false
            }

        } else {
            // No tool needed - process as regular conversation
            Log.info("🤖 [Chat] No tool needed, processing as regular message", category: .ai)
            await processRegularMessage(userMessage, aiMessageId: aiMessageId)
        }
    }

    /// Process a regular message (no tool) with context and AI
    private func processRegularMessage(_ userMessage: String, aiMessageId: UUID) async {
        do {
            var contextToUse = loadedContext

            // Auto-discover context if enabled and no manual selection
            if autoContextEnabled && selectedNotes.isEmpty {
                await MainActor.run { isDiscovering = true }
                let discovered = await aiContextService.discoverContext(for: userMessage)
                await MainActor.run {
                    discoveredNotes = discovered
                    isDiscovering = false
                }
                if !discovered.isEmpty {
                    contextToUse = await aiContextService.loadSuggestedContent()
                }
            }

            var conversationMessages: [ChatMessage] = []

            // System message with context
            var systemContent = "You are a helpful AI assistant analyzing Obsidian notes. Be concise but thorough. Use markdown formatting for better readability."
            if !contextToUse.isEmpty {
                systemContent += "\n\nContext from relevant notes in user's Obsidian vault:\n\n\(contextToUse)"
                systemContent += "\n\nInstructions: Answer based on the context provided above when relevant."
            }
            conversationMessages.append(ChatMessage(role: .system, content: systemContent))

            // Add conversation history (last 10 messages)
            let recentMessages = messages.suffix(10)
            conversationMessages.append(contentsOf: recentMessages)

            // Use streaming
            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    messages[index].content = ""
                }
            }

            // Stream response using callback
            try await lmService.streamMessage(
                messages: conversationMessages,
                onToken: { token in
                    if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                        messages[index].content += token
                    }
                }
            )

            await MainActor.run {
                isStreaming = false
                streamingMessageId = nil
                isProcessing = false
            }

        } catch {
            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    messages[index].content = "Error: \(error.localizedDescription)"
                }
                isStreaming = false
                streamingMessageId = nil
                isProcessing = false
            }
        }
    }

    // MARK: - Agent Commands

    private func processAgentCommand(_ command: AgentCommand, userMessage: String) {
        Task {
            // Create AI message for response
            let aiMessageId = UUID()
            await MainActor.run {
                messages.append(ChatMessage(id: aiMessageId, role: .assistant, content: ""))
                streamingMessageId = aiMessageId
                isStreaming = true
            }

            // Show processing indicator
            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    messages[index].content = "Processing `\(command.rawValue)` command..."
                }
            }

            // Extract argument from user message
            let argument = AgentCommand.extractArgument(from: userMessage)

            // Process command with or without argument
            let response: String
            if let arg = argument, !arg.isEmpty {
                response = await vaultAgent.processCommandWithArgument(command, argument: arg)
            } else {
                response = await vaultAgent.processCommand(command)
            }

            // Update message with response
            await MainActor.run {
                if let index = messages.firstIndex(where: { $0.id == aiMessageId }) {
                    messages[index].content = response
                }
                isStreaming = false
                streamingMessageId = nil
                isProcessing = false
            }
        }
    }

    // MARK: - Context Loading

    private func loadContext() {
        guard !selectedNotes.isEmpty else {
            loadedContext = ""
            return
        }

        Task {
            var contextParts: [String] = []

            for note in selectedNotes {
                if let content = try? await obsidianService.getFileContents(note.path) {
                    contextParts.append("## \(note.displayName)\n\n\(content)")
                }
            }

            await MainActor.run {
                loadedContext = contextParts.joined(separator: "\n\n---\n\n")
            }
        }
    }

    private func removeNote(_ note: ObsidianFile) {
        selectedNotes.removeAll { $0.id == note.id }
        loadContext()
    }

    private func clearChat() {
        messages.removeAll()
        loadedContext = ""
        selectedNotes.removeAll()
        discoveredNotes.removeAll()
        aiContextService.clearContext()
    }

    private func copyMessage(_ content: String) {
        UIPasteboard.general.string = content
        withAnimation {
            showCopiedToast = true
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            withAnimation {
                showCopiedToast = false
            }
        }
    }

    private func regenerateResponse(for message: ChatMessage) {
        // Find the index of this assistant message
        guard let assistantIndex = messages.firstIndex(where: { $0.id == message.id }),
              assistantIndex > 0 else { return }

        // Find the preceding user message
        var userMessageIndex = assistantIndex - 1
        while userMessageIndex >= 0 && messages[userMessageIndex].role != .user {
            userMessageIndex -= 1
        }

        guard userMessageIndex >= 0 else { return }

        // Remove the assistant message we're regenerating
        messages.remove(at: assistantIndex)

        // Re-send the user message to get a new response
        let userContent = messages[userMessageIndex].content
        inputText = userContent
        messages.remove(at: userMessageIndex)
        sendMessage()
    }
}

// MARK: - Note Picker

struct NotePicker: View {
    @EnvironmentObject var obsidianService: ObsidianService

    @Binding var selectedNotes: [ObsidianFile]
    let onConfirm: () -> Void

    @Environment(\.dismiss) var dismiss
    @State private var availableNotes: [ObsidianFile] = []
    @State private var isLoading = true
    @State private var searchText = ""

    var filteredNotes: [ObsidianFile] {
        // availableNotes already contains only .md files from listAllMarkdownFiles()
        if searchText.isEmpty {
            return availableNotes
        }
        return availableNotes.filter {
            $0.name.localizedCaseInsensitiveContains(searchText) ||
            $0.path.localizedCaseInsensitiveContains(searchText)
        }
    }

    var body: some View {
        NavigationStack {
            Group {
                if isLoading {
                    VStack(spacing: 16) {
                        ProgressView()
                        Text("Loading notes...")
                            .font(.subheadline)
                            .foregroundColor(.secondary)
                    }
                } else if filteredNotes.isEmpty {
                    VStack(spacing: 16) {
                        Image(systemName: "doc.text")
                            .font(.system(size: 48))
                            .foregroundColor(.secondary)

                        Text("No notes found")
                            .font(.headline)
                    }
                } else {
                    List {
                        ForEach(filteredNotes) { note in
                            Button(action: { toggleSelection(note) }) {
                                HStack(spacing: 12) {
                                    ZStack {
                                        RoundedRectangle(cornerRadius: 6)
                                            .fill(isSelected(note) ? Color.purple : Color(.systemGray6))
                                            .frame(width: 28, height: 28)

                                        Image(systemName: isSelected(note) ? "checkmark" : "doc.text")
                                            .font(.system(size: 12, weight: .semibold))
                                            .foregroundColor(isSelected(note) ? .white : .purple)
                                    }

                                    VStack(alignment: .leading, spacing: 2) {
                                        Text(note.displayName)
                                            .font(.body)
                                            .fontWeight(.medium)
                                            .foregroundColor(.primary)

                                        Text(note.path)
                                            .font(.caption)
                                            .foregroundColor(.secondary)
                                    }

                                    Spacer()
                                }
                            }
                            .listRowBackground(isSelected(note) ? Color.purple.opacity(0.1) : Color.clear)
                        }
                    }
                    .listStyle(.plain)
                }
            }
            .searchable(text: $searchText, prompt: "Search notes...")
            .navigationTitle("Add Context")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button("Cancel") {
                        dismiss()
                    }
                }

                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        onConfirm()
                        dismiss()
                    }) {
                        Text("Add")
                            .fontWeight(.semibold)
                        if !selectedNotes.isEmpty {
                            Text("(\(selectedNotes.count))")
                        }
                    }
                    .disabled(selectedNotes.isEmpty)
                }
            }
            .task {
                await loadNotes()
            }
        }
    }

    private func isSelected(_ note: ObsidianFile) -> Bool {
        selectedNotes.contains { $0.id == note.id }
    }

    private func toggleSelection(_ note: ObsidianFile) {
        if isSelected(note) {
            selectedNotes.removeAll { $0.id == note.id }
        } else {
            selectedNotes.append(note)
        }
    }

    private func loadNotes() async {
        isLoading = true
        do {
            // Use recursive listing to get ALL markdown files from vault
            availableNotes = try await obsidianService.listAllMarkdownFiles()
        } catch {
            availableNotes = []
        }
        isLoading = false
    }
}


Perfect! Now let me search for the VaultIndexService to understand how the index is created:


Bash

find /Users/neog/Apps/iOS/ObsidianViewer-iOS -name "*VaultIndex*" -o -name "*Index*Service*" 2>/dev/null | head -20

Find VaultIndexService files


/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Models/VaultIndex.swift
/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/VaultIndexService.swift


     1→import Foundation
     2→
     3→/// Índice completo do vault com metadados para acesso rápido
     4→struct VaultIndex: Codable {
     5→    var files: [IndexedNote]
     6→    var stats: VaultStats
     7→    var lastFullIndex: Date?
     8→    var version: Int = 1
     9→
    10→    /// Nota indexada com metadados extraídos
    11→    struct IndexedNote: Codable, Identifiable, Hashable {
    12→        let id: String // path como ID único
    13→        let path: String
    14→        let title: String
    15→        var tags: [String]
    16→        var wikiLinks: [String]
    17→        var wordCount: Int
    18→        var contentHash: String
    19→        var lastModified: Date?
    20→        var folder: String
    21→        var excerpt: String // First 500 chars of content for embeddings/preview
    22→
    23→        var displayName: String {
    24→            (path as NSString).lastPathComponent
    25→                .replacingOccurrences(of: ".md", with: "")
    26→        }
    27→
    28→        // Default initializer with empty excerpt for backward compatibility
    29→        init(id: String, path: String, title: String, tags: [String], wikiLinks: [String],
    30→             wordCount: Int, contentHash: String, lastModified: Date?, folder: String, excerpt: String = "") {
    31→            self.id = id
    32→            self.path = path
    33→            self.title = title
    34→            self.tags = tags
    35→            self.wikiLinks = wikiLinks
    36→            self.wordCount = wordCount
    37→            self.contentHash = contentHash
    38→            self.lastModified = lastModified
    39→            self.folder = folder
    40→            self.excerpt = excerpt
    41→        }
    42→
    43→        // Hashable conformance
    44→        func hash(into hasher: inout Hasher) {
    45→            hasher.combine(id)
    46→        }
    47→
    48→        static func == (lhs: IndexedNote, rhs: IndexedNote) -> Bool {
    49→            lhs.id == rhs.id
    50→        }
    51→    }
    52→
    53→    /// Inicializador vazio
    54→    init() {
    55→        self.files = []
    56→        self.stats = VaultStats(totalFiles: 0, markdownFiles: 0, folders: 0, lastUpdated: Date())
    57→        self.lastFullIndex = nil
    58→    }
    59→
    60→    /// Inicializador completo
    61→    init(files: [IndexedNote], stats: VaultStats, lastFullIndex: Date?) {
    62→        self.files = files
    63→        self.stats = stats
    64→        self.lastFullIndex = lastFullIndex
    65→    }
    66→
    67→    /// Busca nota por path
    68→    func note(at path: String) -> IndexedNote? {
    69→        files.first { $0.path == path }
    70→    }
    71→
    72→    /// Busca notas por tag
    73→    func notes(withTag tag: String) -> [IndexedNote] {
    74→        files.filter { $0.tags.contains(tag) }
    75→    }
    76→
    77→    /// Busca notas em uma pasta
    78→    func notes(inFolder folder: String) -> [IndexedNote] {
    79→        files.filter { $0.folder == folder }
    80→    }
    81→
    82→    /// Todas as tags únicas do vault
    83→    var allTags: [String] {
    84→        Array(Set(files.flatMap { $0.tags })).sorted()
    85→    }
    86→
    87→    /// Todas as pastas únicas
    88→    var allFolders: [String] {
    89→        Array(Set(files.map { $0.folder })).sorted()
    90→    }
    91→}
    92→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation

/// Índice completo do vault com metadados para acesso rápido
struct VaultIndex: Codable {
    var files: [IndexedNote]
    var stats: VaultStats
    var lastFullIndex: Date?
    var version: Int = 1

    /// Nota indexada com metadados extraídos
    struct IndexedNote: Codable, Identifiable, Hashable {
        let id: String // path como ID único
        let path: String
        let title: String
        var tags: [String]
        var wikiLinks: [String]
        var wordCount: Int
        var contentHash: String
        var lastModified: Date?
        var folder: String
        var excerpt: String // First 500 chars of content for embeddings/preview

        var displayName: String {
            (path as NSString).lastPathComponent
                .replacingOccurrences(of: ".md", with: "")
        }

        // Default initializer with empty excerpt for backward compatibility
        init(id: String, path: String, title: String, tags: [String], wikiLinks: [String],
             wordCount: Int, contentHash: String, lastModified: Date?, folder: String, excerpt: String = "") {
            self.id = id
            self.path = path
            self.title = title
            self.tags = tags
            self.wikiLinks = wikiLinks
            self.wordCount = wordCount
            self.contentHash = contentHash
            self.lastModified = lastModified
            self.folder = folder
            self.excerpt = excerpt
        }

        // Hashable conformance
        func hash(into hasher: inout Hasher) {
            hasher.combine(id)
        }

        static func == (lhs: IndexedNote, rhs: IndexedNote) -> Bool {
            lhs.id == rhs.id
        }
    }

    /// Inicializador vazio
    init() {
        self.files = []
        self.stats = VaultStats(totalFiles: 0, markdownFiles: 0, folders: 0, lastUpdated: Date())
        self.lastFullIndex = nil
    }

    /// Inicializador completo
    init(files: [IndexedNote], stats: VaultStats, lastFullIndex: Date?) {
        self.files = files
        self.stats = stats
        self.lastFullIndex = lastFullIndex
    }

    /// Busca nota por path
    func note(at path: String) -> IndexedNote? {
        files.first { $0.path == path }
    }

    /// Busca notas por tag
    func notes(withTag tag: String) -> [IndexedNote] {
        files.filter { $0.tags.contains(tag) }
    }

    /// Busca notas em uma pasta
    func notes(inFolder folder: String) -> [IndexedNote] {
        files.filter { $0.folder == folder }
    }

    /// Todas as tags únicas do vault
    var allTags: [String] {
        Array(Set(files.flatMap { $0.tags })).sorted()
    }

    /// Todas as pastas únicas
    var allFolders: [String] {
        Array(Set(files.map { $0.folder })).sorted()
    }
}