diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 2c1d3bbd..14707012 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -13,8 +13,14 @@ public interface ILoggingService void LogWarning(string message); void LogError(string message, Exception? exception = null); void LogDebug(string message); + + IReadOnlyList GetRecentLogs(int maxCount = 500); + void ClearLogs(); + event Action? OnLogAdded; } +public record LogEntry(DateTime Timestamp, string Level, string Message); + public interface INavigationService { Task NavigateToAsync(string route); @@ -861,7 +867,7 @@ public interface ICopilotService /// Start a new chat session /// /// Optional model to use - Task StartSessionAsync(string? model = null); + Task StartSessionAsync(string? model = null, string? systemPrompt = null); /// /// End the current chat session @@ -909,6 +915,41 @@ public interface ICopilotService /// event Action? OnToolComplete; // toolName, result + /// + /// Event fired when reasoning/thinking starts + /// + event Action? OnReasoningStart; // reasoningId + + /// + /// Event fired when reasoning delta is received + /// + event Action? OnReasoningDelta; // reasoningId, content + + /// + /// Event fired when assistant turn starts + /// + event Action? OnTurnStart; + + /// + /// Event fired when assistant turn ends + /// + event Action? OnTurnEnd; + + /// + /// Event fired when assistant intent changes (what Copilot is currently doing) + /// + event Action? OnIntentChanged; + + /// + /// Event fired when session usage info is updated + /// + event Action? OnUsageInfoChanged; + + /// + /// Event fired when a session error occurs + /// + event Action? OnSessionError; + /// /// Chat messages in the current session /// @@ -924,16 +965,133 @@ public interface ICopilotService /// void AddAssistantMessage(string content); + /// + /// Add a reasoning/thinking message to the chat history + /// + void AddReasoningMessage(string reasoningId); + + /// + /// Update a reasoning message with additional content + /// + void UpdateReasoningMessage(string reasoningId, string content); + + /// + /// Mark a reasoning message as complete and collapse it + /// + void CompleteReasoningMessage(string? reasoningId = null); + + /// + /// Add a tool call message to the chat history + /// + void AddToolMessage(string toolName, string? toolCallId = null); + + /// + /// Mark a tool message as complete with result + /// + void CompleteToolMessage(string? toolName, string? toolCallId, bool success, string result); + + /// + /// Add an error message to the chat history + /// + void AddErrorMessage(CopilotChatMessage errorMessage); + /// /// Clear all chat messages /// void ClearMessages(); + + /// + /// Sets a delegate to handle permission requests for tool execution. + /// The delegate receives the tool name, description, and the default result. + /// Return the default result to accept default behavior, or a custom result to override. + /// + Func>? PermissionHandler { get; set; } } +/// +/// Information about a tool permission request +/// +public record ToolPermissionRequest( + string ToolName, + string ToolDescription, + bool IsReadOnly, + ToolPermissionResult DefaultResult, + string? Command = null, + string? Path = null +); + +/// +/// Result of a tool permission request +/// +public record ToolPermissionResult(bool IsAllowed, string? DenialReason = null); + /// /// A chat message in a Copilot conversation /// -public record CopilotChatMessage(string Content, bool IsUser); +public record CopilotChatMessage +{ + public string Content { get; set; } = ""; + public bool IsUser { get; init; } + public CopilotMessageType MessageType { get; init; } = CopilotMessageType.Text; + public string? ToolName { get; init; } + public string? ToolCallId { get; init; } + public bool IsComplete { get; set; } + public bool IsSuccess { get; set; } = true; + public bool IsCollapsed { get; set; } + public string? ReasoningId { get; init; } + + // Simple constructor for backwards compatibility + public CopilotChatMessage(string content, bool isUser) + { + Content = content; + IsUser = isUser; + MessageType = CopilotMessageType.Text; + IsComplete = true; + } + + // Full constructor + public CopilotChatMessage(string content, bool isUser, CopilotMessageType messageType, string? toolName = null, string? reasoningId = null, string? toolCallId = null) + { + Content = content; + IsUser = isUser; + MessageType = messageType; + ToolName = toolName; + ReasoningId = reasoningId; + ToolCallId = toolCallId; + IsComplete = messageType == CopilotMessageType.Text; + } +} + +/// +/// Type of Copilot chat message +/// +public enum CopilotMessageType +{ + Text, + Reasoning, + ToolCall, + Error +} + +/// +/// Session usage information from Copilot +/// +public record CopilotUsageInfo( + string? Model, + int? CurrentTokens, + int? TokenLimit, + int? InputTokens, + int? OutputTokens +); + +/// +/// Session error information from Copilot +/// +public record CopilotSessionError( + string Message, + string? Code, + string? Details +); /// /// Result of checking Copilot CLI availability @@ -946,6 +1104,100 @@ public record CopilotAvailability( string? ErrorMessage ); +/// +/// Context for a Copilot assistance request +/// +public record CopilotContext( + string Title, + string Message, + CopilotContextType Type = CopilotContextType.General, + string? OperationName = null, + string? ErrorMessage = null, + string? Details = null, + int? ExitCode = null +); + +/// +/// Type of Copilot context request +/// +public enum CopilotContextType +{ + General, + EnvironmentFix, + OperationFailure, + ProcessFailure +} + +/// +/// Service for managing the global Copilot overlay +/// +public interface ICopilotContextService +{ + /// + /// Whether the Copilot overlay is currently open + /// + bool IsOverlayOpen { get; } + + /// + /// Open the Copilot overlay without sending a message + /// + void OpenOverlay(); + + /// + /// Close the Copilot overlay + /// + void CloseOverlay(); + + /// + /// Toggle the Copilot overlay open/closed + /// + void ToggleOverlay(); + + /// + /// Open the overlay and send a simple message + /// + void OpenWithMessage(string message); + + /// + /// Open the overlay and send a context-aware message + /// + void OpenWithContext(CopilotContext context); + + /// + /// Event fired when overlay open is requested + /// + event Action? OnOpenRequested; + + /// + /// Event fired when overlay close is requested + /// + event Action? OnCloseRequested; + + /// + /// Event fired when a message should be sent + /// + event Action? OnMessageRequested; + + /// + /// Event fired when a context message should be sent + /// + event Action? OnContextRequested; + + /// + /// Notify that the overlay state changed (called by overlay component) + /// + void NotifyOverlayStateChanged(bool isOpen); +} + +/// +/// Represents a Copilot tool with metadata +/// +public record CopilotTool(Microsoft.Extensions.AI.AIFunction Function, bool IsReadOnly = false) +{ + public string Name => Function.Name; + public string Description => Function.Description ?? string.Empty; +} + /// /// Service that provides Copilot SDK tool definitions for Apple Developer operations /// @@ -955,4 +1207,14 @@ public interface ICopilotToolsService /// Gets all tool definitions for use in Copilot sessions /// IReadOnlyList GetTools(); + + /// + /// Gets a specific tool by name + /// + CopilotTool? GetTool(string name); + + /// + /// Gets the names of all read-only tools + /// + IReadOnlyList ReadOnlyToolNames { get; } } diff --git a/src/MauiSherpa.Core/Services/CopilotContextService.cs b/src/MauiSherpa.Core/Services/CopilotContextService.cs new file mode 100644 index 00000000..737df8ac --- /dev/null +++ b/src/MauiSherpa.Core/Services/CopilotContextService.cs @@ -0,0 +1,160 @@ +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Service for managing the global Copilot overlay state and context +/// +public class CopilotContextService : ICopilotContextService +{ + public bool IsOverlayOpen { get; private set; } + + public event Action? OnOpenRequested; + public event Action? OnCloseRequested; + public event Action? OnMessageRequested; + public event Action? OnContextRequested; + + public void OpenOverlay() + { + OnOpenRequested?.Invoke(); + } + + public void CloseOverlay() + { + OnCloseRequested?.Invoke(); + } + + public void ToggleOverlay() + { + if (IsOverlayOpen) + CloseOverlay(); + else + OpenOverlay(); + } + + public void OpenWithMessage(string message) + { + OnOpenRequested?.Invoke(); + OnMessageRequested?.Invoke(message); + } + + public void OpenWithContext(CopilotContext context) + { + OnOpenRequested?.Invoke(); + OnContextRequested?.Invoke(context); + } + + public void NotifyOverlayStateChanged(bool isOpen) + { + IsOverlayOpen = isOpen; + } + + /// + /// Helper to build a context message for environment fix requests + /// + public static CopilotContext BuildEnvironmentFixContext( + IEnumerable<(string Category, string Message, bool IsError)> issues) + { + var errors = issues.Where(i => i.IsError).ToList(); + var warnings = issues.Where(i => !i.IsError).ToList(); + + var messageBuilder = new System.Text.StringBuilder(); + messageBuilder.AppendLine("Please help me evaluate and fix my development environment."); + messageBuilder.AppendLine(); + messageBuilder.AppendLine("Current Status:"); + + foreach (var error in errors) + { + messageBuilder.AppendLine($"❌ {error.Category}: {error.Message}"); + } + + foreach (var warning in warnings) + { + messageBuilder.AppendLine($"⚠️ {warning.Category}: {warning.Message}"); + } + + messageBuilder.AppendLine(); + messageBuilder.AppendLine("Please diagnose these issues and help me resolve them step by step."); + + return new CopilotContext( + Title: "Environment Fix", + Message: messageBuilder.ToString(), + Type: CopilotContextType.EnvironmentFix + ); + } + + /// + /// Helper to build a context message for operation failures + /// + public static CopilotContext BuildOperationFailureContext( + string operationName, + string errorMessage, + string? details = null) + { + var messageBuilder = new System.Text.StringBuilder(); + messageBuilder.AppendLine("An operation failed and I need help troubleshooting."); + messageBuilder.AppendLine(); + messageBuilder.AppendLine($"**Operation:** {operationName}"); + messageBuilder.AppendLine($"**Error:** {errorMessage}"); + + if (!string.IsNullOrEmpty(details)) + { + messageBuilder.AppendLine(); + messageBuilder.AppendLine("**Details:**"); + messageBuilder.AppendLine(details); + } + + messageBuilder.AppendLine(); + messageBuilder.AppendLine("Please help me understand what went wrong and how to fix it."); + + return new CopilotContext( + Title: "Operation Failure", + Message: messageBuilder.ToString(), + Type: CopilotContextType.OperationFailure, + OperationName: operationName, + ErrorMessage: errorMessage, + Details: details + ); + } + + /// + /// Helper to build a context message for process failures + /// + public static CopilotContext BuildProcessFailureContext( + string command, + int exitCode, + string? output = null) + { + var messageBuilder = new System.Text.StringBuilder(); + messageBuilder.AppendLine("A command/process failed and I need help."); + messageBuilder.AppendLine(); + messageBuilder.AppendLine($"**Command:** `{command}`"); + messageBuilder.AppendLine($"**Exit Code:** {exitCode}"); + + if (!string.IsNullOrEmpty(output)) + { + // Truncate very long output + var truncatedOutput = output.Length > 2000 + ? output.Substring(0, 2000) + "\n... (truncated)" + : output; + + messageBuilder.AppendLine(); + messageBuilder.AppendLine("**Output:**"); + messageBuilder.AppendLine("```"); + messageBuilder.AppendLine(truncatedOutput); + messageBuilder.AppendLine("```"); + } + + messageBuilder.AppendLine(); + messageBuilder.AppendLine("Please analyze this failure and suggest a fix."); + + return new CopilotContext( + Title: "Process Failure", + Message: messageBuilder.ToString(), + Type: CopilotContextType.ProcessFailure, + OperationName: command, + ExitCode: exitCode, + Details: output + ); + } +} diff --git a/src/MauiSherpa.Core/Services/CopilotService.cs b/src/MauiSherpa.Core/Services/CopilotService.cs index 3e01c975..7a9324e5 100644 --- a/src/MauiSherpa.Core/Services/CopilotService.cs +++ b/src/MauiSherpa.Core/Services/CopilotService.cs @@ -1,5 +1,6 @@ using GitHub.Copilot.SDK; using MauiSherpa.Core.Interfaces; +using System.Text.Json; namespace MauiSherpa.Core.Services; @@ -12,6 +13,7 @@ public class CopilotService : ICopilotService, IAsyncDisposable private readonly ICopilotToolsService _toolsService; private readonly string _skillsPath; private readonly List _messages = new(); + private readonly Dictionary _toolCallIdToName = new(); // Track callId -> toolName mapping private CopilotClient? _client; private CopilotSession? _session; @@ -29,6 +31,15 @@ public class CopilotService : ICopilotService, IAsyncDisposable public event Action? OnError; public event Action? OnToolStart; public event Action? OnToolComplete; + public event Action? OnReasoningStart; + public event Action? OnReasoningDelta; + public event Action? OnTurnStart; + public event Action? OnTurnEnd; + public event Action? OnIntentChanged; + public event Action? OnUsageInfoChanged; + public event Action? OnSessionError; + + public Func>? PermissionHandler { get; set; } public CopilotService(ILoggingService logger, ICopilotToolsService toolsService) { @@ -234,7 +245,7 @@ public async Task DisconnectAsync() } } - public async Task StartSessionAsync(string? model = null) + public async Task StartSessionAsync(string? model = null, string? systemPrompt = null) { if (_client == null) { @@ -254,12 +265,45 @@ public async Task StartSessionAsync(string? model = null) var tools = _toolsService.GetTools(); _logger.LogInformation($"Registering {tools.Count} tools with Copilot session"); + // Build system prompt - use provided prompt or fall back to default + var promptContent = CopilotSystemPromptBuilder.Build(systemPrompt); + var config = new SessionConfig { - Model = model ?? "gpt-5", + Model = model ?? "claude-opus-4.5", // Use Claude Opus 4.5 as default Streaming = true, - Tools = tools.ToList() + Tools = tools.ToList(), + OnPermissionRequest = HandleSdkPermissionRequest, + SystemMessage = new SystemMessageConfig + { + Content = promptContent + } }; + + _logger.LogInformation("System prompt configured for session"); + + // Add skills directory (the parent folder containing skill folders) + // Temporarily disabled for debugging - skills may be causing API errors + var skillsPath = _skillsPath; + var enableSkills = false; // Set to true to re-enable skills + if (enableSkills && Directory.Exists(skillsPath)) + { + config.SkillDirectories = new List { skillsPath }; + _logger.LogInformation($"Adding skills directory: {skillsPath}"); + + // Log individual skills found for debugging + foreach (var skillDir in Directory.GetDirectories(skillsPath)) + { + if (File.Exists(Path.Combine(skillDir, "SKILL.md"))) + { + _logger.LogInformation($" Found skill: {Path.GetFileName(skillDir)}"); + } + } + } + else + { + _logger.LogInformation("Skills disabled for this session"); + } _session = await _client.CreateSessionAsync(config); @@ -274,6 +318,128 @@ public async Task StartSessionAsync(string? model = null) throw; } } + + private async Task HandleSdkPermissionRequest(PermissionRequest request, PermissionInvocation invocation) + { + _logger.LogDebug($"Permission request: Kind={request.Kind}, ToolCallId={request.ToolCallId}"); + + // Log all extension data for debugging + if (request.ExtensionData != null) + { + foreach (var kvp in request.ExtensionData) + { + _logger.LogDebug($" Request.ExtensionData[{kvp.Key}]: {kvp.Value}"); + } + } + + // Log invocation properties using reflection + _logger.LogDebug($" Invocation type: {invocation.GetType().FullName}"); + foreach (var prop in invocation.GetType().GetProperties()) + { + try + { + var value = prop.GetValue(invocation); + if (value != null) + { + _logger.LogDebug($" Invocation.{prop.Name}: {value}"); + } + } + catch { } + } + + // Check for ExtensionData on invocation too + var invocationExtData = invocation.GetType().GetProperty("ExtensionData")?.GetValue(invocation) as IDictionary; + if (invocationExtData != null) + { + foreach (var kvp in invocationExtData) + { + _logger.LogDebug($" Invocation.ExtensionData[{kvp.Key}]: {kvp.Value}"); + } + } + + // Try to get tool name from our tracked mapping first (most reliable) + var toolCallId = request.ToolCallId ?? ""; + var toolName = "unknown"; + + if (!string.IsNullOrEmpty(toolCallId) && _toolCallIdToName.TryGetValue(toolCallId, out var mappedName)) + { + toolName = mappedName; + } + else + { + // Fall back to other sources + toolName = request.ExtensionData?.GetValueOrDefault("toolName")?.ToString() + ?? request.ExtensionData?.GetValueOrDefault("name")?.ToString() + ?? invocation.GetType().GetProperty("ToolName")?.GetValue(invocation)?.ToString() + ?? invocation.GetType().GetProperty("Name")?.GetValue(invocation)?.ToString() + ?? toolCallId + ?? "unknown"; + } + + // Get intention/description from extension data (much more useful than generic description) + var intention = request.ExtensionData?.GetValueOrDefault("intention")?.ToString(); + var path = request.ExtensionData?.GetValueOrDefault("path")?.ToString(); + + // Try to get command for bash/shell tools from multiple sources + // The SDK uses "fullCommandText" for the actual command + var command = request.ExtensionData?.GetValueOrDefault("fullCommandText")?.ToString() + ?? request.ExtensionData?.GetValueOrDefault("command")?.ToString() + ?? request.ExtensionData?.GetValueOrDefault("cmd")?.ToString() + ?? request.ExtensionData?.GetValueOrDefault("script")?.ToString() + ?? request.ExtensionData?.GetValueOrDefault("code")?.ToString(); + + // Also check invocation extension data + if (string.IsNullOrEmpty(command) && invocationExtData != null) + { + invocationExtData.TryGetValue("fullCommandText", out var fullCmd); + invocationExtData.TryGetValue("command", out var cmd); + invocationExtData.TryGetValue("code", out var code); + command = fullCmd?.ToString() ?? cmd?.ToString() ?? code?.ToString(); + } + + _logger.LogDebug($" Resolved toolName: {toolName}, intention: {intention}, path: {path}, command: {command}"); + + var tool = _toolsService.GetTool(toolName); + var isReadOnly = tool?.IsReadOnly ?? (request.Kind == "read"); + + // Build a better description using intention or path + var toolDescription = intention + ?? (path != null ? $"Access: {path}" : null) + ?? tool?.Description + ?? ""; + + // Default result + var defaultResult = new ToolPermissionResult(true); + + // If we have a permission handler delegate, call it + if (PermissionHandler != null) + { + var permRequest = new ToolPermissionRequest(toolName, toolDescription, isReadOnly, defaultResult, command, path); + _logger.LogDebug($"Calling PermissionHandler for tool: {toolName}"); + var result = await PermissionHandler(permRequest); + _logger.LogDebug($"PermissionHandler returned: IsAllowed={result.IsAllowed}"); + + if (result.IsAllowed) + { + _logger.LogDebug($"Returning 'approved' for tool: {toolName}"); + return new PermissionRequestResult { Kind = "approved" }; + } + else + { + _logger.LogDebug($"Returning 'denied' for tool: {toolName}"); + return new PermissionRequestResult { Kind = "denied" }; + } + } + + // Default: allow read-only tools, deny destructive tools + if (isReadOnly) + { + return new PermissionRequestResult { Kind = "approved" }; + } + + // Default deny for destructive tools if no handler + return new PermissionRequestResult { Kind = "denied" }; + } public async Task EndSessionAsync() { @@ -344,37 +510,222 @@ private void HandleSessionEvent(SessionEvent evt) { try { + // Debug log all events with full type name + var eventType = evt.GetType().Name; + _logger.LogDebug($"SDK Event: {eventType}"); + switch (evt) { + case AssistantTurnStartEvent turnStart: + _logger.LogDebug($" TurnStart: TurnId={turnStart.Data?.TurnId}"); + OnTurnStart?.Invoke(); + break; + + case AssistantTurnEndEvent turnEnd: + _logger.LogDebug($" TurnEnd: TurnId={turnEnd.Data?.TurnId}"); + OnTurnEnd?.Invoke(); + break; + + case AssistantReasoningEvent reasoning: + _logger.LogDebug($" ReasoningStart: Id={reasoning.Data.ReasoningId}, ContentLen={reasoning.Data.Content?.Length ?? 0}"); + OnReasoningStart?.Invoke(reasoning.Data.ReasoningId ?? ""); + if (!string.IsNullOrEmpty(reasoning.Data.Content)) + { + OnReasoningDelta?.Invoke( + reasoning.Data.ReasoningId ?? "", + reasoning.Data.Content); + } + break; + + case AssistantReasoningDeltaEvent reasoningDelta: + // Don't log reasoning delta events - too noisy + OnReasoningDelta?.Invoke( + reasoningDelta.Data.ReasoningId ?? "", + reasoningDelta.Data.DeltaContent ?? ""); + break; + case AssistantMessageDeltaEvent delta: + // Don't log delta events - too noisy OnAssistantDelta?.Invoke(delta.Data.DeltaContent ?? ""); break; case AssistantMessageEvent msg: + _logger.LogDebug($" Message: ContentLen={msg.Data.Content?.Length ?? 0}"); OnAssistantMessage?.Invoke(msg.Data.Content ?? ""); break; + + case AssistantIntentEvent intent: + var intentText = intent.Data?.Intent ?? ""; + _logger.LogDebug($" AssistantIntent: {intentText}"); + OnIntentChanged?.Invoke(intentText); + break; case SessionIdleEvent: + _logger.LogDebug($" SessionIdle"); OnSessionIdle?.Invoke(); break; case SessionErrorEvent error: - _logger.LogError($"Session error: {error.Data.Message}"); - OnError?.Invoke(error.Data.Message ?? "Unknown error"); + var errorData = error.Data; + + // Log all available properties from the error + if (errorData != null) + { + _logger.LogError($"Session error data type: {errorData.GetType().FullName}"); + foreach (var prop in errorData.GetType().GetProperties()) + { + try + { + var value = prop.GetValue(errorData); + _logger.LogError($" {prop.Name}: {value}"); + } + catch { } + } + } + + var errorCode = errorData?.GetType().GetProperty("Code")?.GetValue(errorData)?.ToString(); + var errorDetails = errorData?.GetType().GetProperty("Details")?.GetValue(errorData)?.ToString(); + _logger.LogError($"Session error: {errorData?.Message}, Code={errorCode}, Details={errorDetails}"); + + var sessionError = new CopilotSessionError( + errorData?.Message ?? "Unknown error", + errorCode, + errorDetails + ); + OnSessionError?.Invoke(sessionError); + OnError?.Invoke(errorData?.Message ?? "Unknown error"); + break; + + case SessionUsageInfoEvent usageInfo: + // Try to extract usage info properties using reflection + var usageData = usageInfo.Data; + var model = usageData?.GetType().GetProperty("Model")?.GetValue(usageData)?.ToString(); + var currentTokens = usageData?.GetType().GetProperty("CurrentTokens")?.GetValue(usageData) as int?; + var tokenLimit = usageData?.GetType().GetProperty("TokenLimit")?.GetValue(usageData) as int?; + var inputTokens = usageData?.GetType().GetProperty("InputTokens")?.GetValue(usageData) as int?; + var outputTokens = usageData?.GetType().GetProperty("OutputTokens")?.GetValue(usageData) as int?; + + _logger.LogDebug($" SessionUsageInfo: Model={model}, Tokens={currentTokens}/{tokenLimit}, In={inputTokens}, Out={outputTokens}"); + + var usage = new CopilotUsageInfo(model, currentTokens, tokenLimit, inputTokens, outputTokens); + OnUsageInfoChanged?.Invoke(usage); + break; + + case SessionModelChangeEvent modelChange: + var modelData = modelChange.Data; + var newModel = modelData?.GetType().GetProperty("NewModel")?.GetValue(modelData)?.ToString(); + var prevModel = modelData?.GetType().GetProperty("PreviousModel")?.GetValue(modelData)?.ToString(); + _logger.LogInformation($"Model changed: {prevModel} -> {newModel}"); + break; + + case AssistantUsageEvent assistantUsage: + // Extract token usage from assistant usage event + var aUsageData = assistantUsage.Data; + var aInputTokens = aUsageData?.GetType().GetProperty("InputTokens")?.GetValue(aUsageData) as int?; + var aOutputTokens = aUsageData?.GetType().GetProperty("OutputTokens")?.GetValue(aUsageData) as int?; + var aModel = aUsageData?.GetType().GetProperty("Model")?.GetValue(aUsageData)?.ToString(); + + if (aInputTokens.HasValue || aOutputTokens.HasValue) + { + _logger.LogDebug($" AssistantUsage: Model={aModel}, In={aInputTokens}, Out={aOutputTokens}"); + var aUsage = new CopilotUsageInfo(aModel, null, null, aInputTokens, aOutputTokens); + OnUsageInfoChanged?.Invoke(aUsage); + } break; case ToolExecutionStartEvent toolStart: - _logger.LogDebug($"Tool started: {toolStart.Data.ToolName}"); - OnToolStart?.Invoke( - toolStart.Data.ToolName ?? "unknown", - ""); + // Skip report_intent tool - we handle it via AssistantIntentEvent + if (toolStart.Data.ToolName == "report_intent") + { + _logger.LogDebug($" ToolExecutionStart: Skipping report_intent"); + break; + } + + // Track the callId -> toolName mapping for permission requests + var startToolName = toolStart.Data.ToolName ?? "unknown"; + var startCallId = toolStart.Data.ToolCallId ?? ""; + if (!string.IsNullOrEmpty(startCallId)) + { + _toolCallIdToName[startCallId] = startToolName; + } + + _logger.LogDebug($" ToolExecutionStart: Name={startToolName}, CallId={startCallId}"); + OnToolStart?.Invoke(startToolName, startCallId); break; case ToolExecutionCompleteEvent toolComplete: - _logger.LogDebug($"Tool completed"); + var resultObj = toolComplete.Data.Result; + var errorObj = toolComplete.Data.Error; + + // Debug log the Data object properties first + _logger.LogDebug($" ToolExecutionComplete: Data type: {toolComplete.Data.GetType().FullName}"); + foreach (var dataProp in toolComplete.Data.GetType().GetProperties()) + { + try + { + var val = dataProp.GetValue(toolComplete.Data); + _logger.LogDebug($" Data.{dataProp.Name}: {val}"); + } + catch { } + } + + // Debug log the error object if present + if (errorObj != null) + { + _logger.LogDebug($" ToolExecutionComplete: Error type: {errorObj.GetType().FullName}"); + foreach (var prop in errorObj.GetType().GetProperties()) + { + try + { + var val = prop.GetValue(errorObj); + _logger.LogDebug($" Error.{prop.Name}: {val}"); + } + catch { } + } + } + + // Debug log the result object details + if (resultObj == null) + { + _logger.LogDebug($" ToolExecutionComplete: Result is null"); + } + else + { + _logger.LogDebug($" ToolExecutionComplete: Result type: {resultObj.GetType().FullName}"); + // Log all properties + foreach (var prop in resultObj.GetType().GetProperties()) + { + try + { + var val = prop.GetValue(resultObj); + _logger.LogDebug($" Result.{prop.Name}: {val}"); + } + catch { } + } + } + + var resultStr = FormatToolResult(resultObj); + var completeCallId = toolComplete.Data.ToolCallId; + + // Try to get tool name from the event data + var completeToolName = toolComplete.Data?.GetType().GetProperty("ToolName")?.GetValue(toolComplete.Data)?.ToString(); + + // Skip report_intent completions - we handle intent via AssistantIntentEvent + if (completeToolName == "report_intent" || resultStr == "Intent logged") + { + _logger.LogDebug($" ToolExecutionComplete: Skipping report_intent completion"); + break; + } + + _logger.LogDebug($" ToolExecutionComplete: ToolName={completeToolName}, CallId={completeCallId}, ResultType={resultObj?.GetType().Name}, ResultLen={resultStr.Length}, Result={resultStr.Substring(0, Math.Min(200, resultStr.Length))}..."); OnToolComplete?.Invoke( - "tool", - toolComplete.Data.Result?.ToString() ?? ""); + completeCallId ?? "", + resultStr); + break; + + default: + // Log unhandled events with their full details for debugging + _logger.LogDebug($" (Unhandled event: {eventType}) - Data: {evt}"); break; } } @@ -386,18 +737,226 @@ private void HandleSessionEvent(SessionEvent evt) public void AddUserMessage(string content) { + _logger.LogDebug($"AddUserMessage: {content.Substring(0, Math.Min(50, content.Length))}..."); _messages.Add(new CopilotChatMessage(content, true)); } public void AddAssistantMessage(string content) { + _logger.LogDebug($"AddAssistantMessage: {content.Substring(0, Math.Min(50, content.Length))}..."); _messages.Add(new CopilotChatMessage(content, false)); } + + public void AddReasoningMessage(string reasoningId) + { + _logger.LogDebug($"AddReasoningMessage: {reasoningId}"); + + // Check for duplicate - don't add if we already have a reasoning message with this ID + var existing = _messages.FirstOrDefault(m => + m.MessageType == CopilotMessageType.Reasoning && + m.ReasoningId == reasoningId); + if (existing != null) + { + _logger.LogDebug($"AddReasoningMessage: Skipping duplicate for reasoningId={reasoningId}"); + return; + } + + _messages.Add(new CopilotChatMessage("", false, CopilotMessageType.Reasoning, reasoningId: reasoningId)); + } + + public void UpdateReasoningMessage(string reasoningId, string content) + { + // First try to find by exact ID + var msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.Reasoning && m.ReasoningId == reasoningId); + + // If not found, try to find any incomplete reasoning message + if (msg == null) + { + msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.Reasoning && !m.IsComplete); + } + + // If still not found, create a new one with this ID + if (msg == null) + { + _logger.LogDebug($"UpdateReasoningMessage: Creating new reasoning message for id {reasoningId}"); + msg = new CopilotChatMessage("", false, CopilotMessageType.Reasoning, reasoningId: reasoningId); + _messages.Add(msg); + } + + msg.Content += content; + _logger.LogDebug($"UpdateReasoningMessage: {reasoningId}, totalLen={msg.Content.Length}"); + } + + public void CompleteReasoningMessage(string? reasoningId = null) + { + _logger.LogDebug($"CompleteReasoningMessage: {reasoningId ?? "(any)"}"); + var msg = reasoningId != null + ? _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.Reasoning && m.ReasoningId == reasoningId) + : _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.Reasoning && !m.IsComplete); + + // Also try incomplete reasoning if specific ID not found + if (msg == null && reasoningId != null) + { + msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.Reasoning && !m.IsComplete); + } + + if (msg != null) + { + msg.IsComplete = true; + msg.IsCollapsed = true; + _logger.LogDebug($"CompleteReasoningMessage: Completed, contentLen={msg.Content.Length}"); + } + else + { + _logger.LogWarning($"CompleteReasoningMessage: Could not find reasoning message"); + } + } + + public void AddToolMessage(string toolName, string? toolCallId = null) + { + _logger.LogDebug($"AddToolMessage: {toolName}, callId={toolCallId}"); + + // Check for duplicate - don't add if we already have an incomplete tool with this callId or name + if (!string.IsNullOrEmpty(toolCallId)) + { + var existing = _messages.FirstOrDefault(m => + m.MessageType == CopilotMessageType.ToolCall && + m.ToolCallId == toolCallId); + if (existing != null) + { + _logger.LogDebug($"AddToolMessage: Skipping duplicate for callId={toolCallId}"); + return; + } + } + + // Also check if there's already an incomplete tool with the same name (in case callId varies) + var existingByName = _messages.FirstOrDefault(m => + m.MessageType == CopilotMessageType.ToolCall && + m.ToolName == toolName && + !m.IsComplete); + if (existingByName != null && string.IsNullOrEmpty(toolCallId)) + { + _logger.LogDebug($"AddToolMessage: Skipping duplicate for name={toolName} (already have incomplete)"); + return; + } + + var msg = new CopilotChatMessage("", false, CopilotMessageType.ToolCall, toolName: toolName, toolCallId: toolCallId); + _messages.Add(msg); + } + + public void CompleteToolMessage(string? toolName, string? toolCallId, bool success, string result) + { + _logger.LogDebug($"CompleteToolMessage: name={toolName ?? "(any)"}, callId={toolCallId}, success={success}, resultLen={result?.Length ?? 0}"); + + CopilotChatMessage? msg = null; + + // Try to find by call ID first (most reliable for parallel calls) + if (!string.IsNullOrEmpty(toolCallId)) + { + msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.ToolCall && m.ToolCallId == toolCallId && !m.IsComplete); + } + + // Then try by name + if (msg == null && !string.IsNullOrEmpty(toolName)) + { + msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.ToolCall && m.ToolName == toolName && !m.IsComplete); + } + + // Finally try any incomplete tool + if (msg == null) + { + msg = _messages.LastOrDefault(m => m.MessageType == CopilotMessageType.ToolCall && !m.IsComplete); + } + + if (msg != null) + { + msg.IsComplete = true; + msg.IsSuccess = success; + msg.Content = result; + _logger.LogDebug($"CompleteToolMessage: Completed tool {msg.ToolName}"); + } + else + { + _logger.LogWarning($"CompleteToolMessage: Could not find tool message for name={toolName}, callId={toolCallId}"); + } + } public void ClearMessages() { _messages.Clear(); } + + public void AddErrorMessage(CopilotChatMessage errorMessage) + { + _messages.Add(errorMessage); + } + + /// + /// Format a tool result object for display + /// + private string FormatToolResult(object? result) + { + if (result == null) return ""; + + var resultType = result.GetType(); + var resultTypeName = resultType.FullName ?? resultType.Name; + + // If it's already a string, return it + if (result is string str) return str; + + // Try to get useful properties from the result + try + { + // Check for common property names + var contentProp = resultType.GetProperty("Content") ?? resultType.GetProperty("content"); + if (contentProp != null) + { + var content = contentProp.GetValue(result)?.ToString(); + if (!string.IsNullOrEmpty(content)) return content; + } + + var messageProp = resultType.GetProperty("Message") ?? resultType.GetProperty("message"); + if (messageProp != null) + { + var message = messageProp.GetValue(result)?.ToString(); + if (!string.IsNullOrEmpty(message)) return message; + } + + var textProp = resultType.GetProperty("Text") ?? resultType.GetProperty("text"); + if (textProp != null) + { + var text = textProp.GetValue(result)?.ToString(); + if (!string.IsNullOrEmpty(text)) return text; + } + + var valueProp = resultType.GetProperty("Value") ?? resultType.GetProperty("value"); + if (valueProp != null) + { + var value = valueProp.GetValue(result)?.ToString(); + if (!string.IsNullOrEmpty(value)) return value; + } + + // Try JSON serialization + var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }); + // If JSON is just the type name or empty object, return type info + if (json == "{}" || json == "null" || json.Contains("\"$type\"")) + { + return $"[{resultTypeName}]"; + } + return json; + } + catch (Exception ex) + { + _logger.LogDebug($"FormatToolResult: Failed to format {resultTypeName}: {ex.Message}"); + // Fallback to ToString, but if it's just the type name, indicate that + var toString = result.ToString() ?? ""; + if (toString == resultTypeName || toString.StartsWith("GitHub.Copilot.SDK.")) + { + return $"[{resultTypeName}]"; + } + return toString; + } + } public async ValueTask DisposeAsync() { diff --git a/src/MauiSherpa.Core/Services/CopilotSystemPromptBuilder.cs b/src/MauiSherpa.Core/Services/CopilotSystemPromptBuilder.cs new file mode 100644 index 00000000..f0713319 --- /dev/null +++ b/src/MauiSherpa.Core/Services/CopilotSystemPromptBuilder.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using System.Text; + +namespace MauiSherpa.Core.Services; + +/// +/// Builds the system prompt for Copilot sessions in MAUI Sherpa. +/// Loads the prompt from an external markdown file and performs token replacements. +/// +public static class CopilotSystemPromptBuilder +{ + /// + /// Token replacements that can be used in the system prompt template. + /// Use {{TOKEN_NAME}} syntax in the markdown file. + /// + private static readonly Dictionary> TokenReplacements = new() + { + { "APP_VERSION", () => GetAppVersion() }, + { "CURRENT_DATE", () => DateTime.Now.ToString("yyyy-MM-dd") }, + { "PLATFORM", () => GetPlatform() } + }; + + /// + /// Builds the system prompt, loading from embedded resource if available, + /// otherwise falling back to the hardcoded default. + /// + public static string Build(string? promptContent = null) + { + var content = promptContent ?? GetDefaultPrompt(); + return ApplyTokenReplacements(content); + } + + /// + /// Apply token replacements to the prompt content. + /// Tokens are in the format {{TOKEN_NAME}}. + /// + private static string ApplyTokenReplacements(string content) + { + foreach (var (token, valueFunc) in TokenReplacements) + { + var placeholder = $"{{{{{token}}}}}"; + if (content.Contains(placeholder)) + { + content = content.Replace(placeholder, valueFunc()); + } + } + return content; + } + + private static string GetAppVersion() + { + try + { + var version = Assembly.GetExecutingAssembly().GetName().Version; + return version?.ToString() ?? "1.0.0"; + } + catch + { + return "1.0.0"; + } + } + + private static string GetPlatform() + { +#if MACCATALYST + return "macOS (Mac Catalyst)"; +#elif WINDOWS + return "Windows"; +#else + return "Unknown"; +#endif + } + + /// + /// Returns a hardcoded fallback prompt in case the external file cannot be loaded. + /// + private static string GetDefaultPrompt() + { + return """ + # MAUI Sherpa Assistant + + You are MAUI Sherpa, an expert assistant for .NET MAUI mobile app development. + + ## CRITICAL RULES + + 1. **Identity First**: ALWAYS call `get_current_apple_identity` before ANY Apple Developer operation. + 2. **Use SDK Path Tools**: ALWAYS use `get_android_sdk_path` for Android SDK location - never assume ANDROID_HOME. + 3. **Security**: Never execute arbitrary code, never run destructive commands (rm -rf, format, etc.). + 4. **Confirmation Required**: Always confirm before revoking certificates, deleting profiles/bundle IDs, or uninstalling packages. + + ## Available Tools + + Use the available tools proactively to help users. Don't just describe what's possible - actually do it. + + ### Apple Developer + - `get_current_apple_identity` - Check this FIRST + - `list_apple_identities`, `select_apple_identity` + - `list_bundle_ids`, `create_bundle_id`, `delete_bundle_id` + - `list_devices`, `register_device`, `enable_device`, `disable_device` + - `list_certificates`, `create_certificate`, `revoke_certificate` + - `list_provisioning_profiles`, `download_provisioning_profile`, `install_provisioning_profile` + + ### Android SDK + - `get_android_sdk_path` - Use this instead of ANDROID_HOME + - `list_android_packages`, `install_android_package`, `uninstall_android_package` + - `list_emulators`, `create_emulator`, `delete_emulator`, `start_emulator`, `stop_emulator` + - `list_android_devices`, `list_system_images`, `list_device_definitions` + """; + } +} + diff --git a/src/MauiSherpa.Core/Services/CopilotToolsService.cs b/src/MauiSherpa.Core/Services/CopilotToolsService.cs index 7fbd52f7..91628dea 100644 --- a/src/MauiSherpa.Core/Services/CopilotToolsService.cs +++ b/src/MauiSherpa.Core/Services/CopilotToolsService.cs @@ -15,6 +15,9 @@ public class CopilotToolsService : ICopilotToolsService private readonly IAppleIdentityService _identityService; private readonly IAndroidSdkService _androidService; private readonly ILoggingService _logger; + + private readonly List _tools = new(); + private readonly HashSet _readOnlyToolNames = new(); public CopilotToolsService( IAppleConnectService appleService, @@ -28,91 +31,112 @@ public CopilotToolsService( _identityService = identityService; _androidService = androidService; _logger = logger; + + InitializeTools(); } - + + public IReadOnlyList ReadOnlyToolNames => _readOnlyToolNames.ToList(); + public IReadOnlyList GetTools() { - var tools = new List(); - + return _tools.Select(t => t.Function).ToList(); + } + + public CopilotTool? GetTool(string name) + { + return _tools.FirstOrDefault(t => t.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + private void AddTool(AIFunction function, bool isReadOnly = false) + { + _tools.Add(new CopilotTool(function, isReadOnly)); + if (isReadOnly) + { + _readOnlyToolNames.Add(function.Name); + } + } + + private void InitializeTools() + { // Apple Identity Tools - tools.Add(AIFunctionFactory.Create(ListAppleIdentitiesAsync, "list_apple_identities", - "List all configured Apple Developer identities (App Store Connect API keys). Shows which one is currently selected.")); - tools.Add(AIFunctionFactory.Create(GetCurrentAppleIdentity, "get_current_apple_identity", - "Get the currently selected Apple Developer identity.")); - tools.Add(AIFunctionFactory.Create(SelectAppleIdentityAsync, "select_apple_identity", - "Select an Apple Developer identity by name or ID for subsequent operations.")); + AddTool(AIFunctionFactory.Create(ListAppleIdentitiesAsync, "list_apple_identities", + "List all configured Apple Developer identities (App Store Connect API keys). Shows which one is currently selected."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(GetCurrentAppleIdentity, "get_current_apple_identity", + "Get the currently selected Apple Developer identity."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(SelectAppleIdentityAsync, "select_apple_identity", + "Select an Apple Developer identity by name or ID for subsequent operations."), isReadOnly: false); // Bundle ID Tools - tools.Add(AIFunctionFactory.Create(ListBundleIdsAsync, "list_bundle_ids", - "List all Bundle IDs (App IDs) for the current Apple Developer account. Optionally filter by search query.")); - tools.Add(AIFunctionFactory.Create(CreateBundleIdAsync, "create_bundle_id", - "Create a new Bundle ID (App ID) in App Store Connect.")); - tools.Add(AIFunctionFactory.Create(DeleteBundleIdAsync, "delete_bundle_id", - "Delete a Bundle ID from App Store Connect.")); + AddTool(AIFunctionFactory.Create(ListBundleIdsAsync, "list_bundle_ids", + "List all Bundle IDs (App IDs) for the current Apple Developer account. Optionally filter by search query."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(CreateBundleIdAsync, "create_bundle_id", + "Create a new Bundle ID (App ID) in App Store Connect. Supports explicit IDs (com.company.appname) or wildcard IDs (com.company.*). Platform should be 'IOS' or 'MAC_OS'."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(GetAppIdPrefixesAsync, "get_app_id_prefixes", + "Get the list of App ID Prefixes (Team IDs) available for your account. These are assigned by Apple and shown on Bundle IDs."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(DeleteBundleIdAsync, "delete_bundle_id", + "Delete a Bundle ID from App Store Connect."), isReadOnly: false); // Device Tools - tools.Add(AIFunctionFactory.Create(ListDevicesAsync, "list_devices", - "List all registered Apple devices. Optionally filter by name or UDID.")); - tools.Add(AIFunctionFactory.Create(RegisterDeviceAsync, "register_device", - "Register a new device for development or ad-hoc distribution.")); - tools.Add(AIFunctionFactory.Create(EnableDeviceAsync, "enable_device", - "Enable a disabled device.")); - tools.Add(AIFunctionFactory.Create(DisableDeviceAsync, "disable_device", - "Disable a device (it will no longer be usable for development).")); + AddTool(AIFunctionFactory.Create(ListDevicesAsync, "list_devices", + "List all registered Apple devices. Optionally filter by name or UDID."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(RegisterDeviceAsync, "register_device", + "Register a new device for development or ad-hoc distribution."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(EnableDeviceAsync, "enable_device", + "Enable a disabled device."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(DisableDeviceAsync, "disable_device", + "Disable a device (it will no longer be usable for development)."), isReadOnly: false); // Certificate Tools - tools.Add(AIFunctionFactory.Create(ListCertificatesAsync, "list_certificates", - "List all signing certificates. Optionally filter by name or type (e.g., 'DEVELOPMENT', 'DISTRIBUTION').")); - tools.Add(AIFunctionFactory.Create(CreateCertificateAsync, "create_certificate", - "Create a new signing certificate. The PFX file will be saved to your Downloads folder.")); - tools.Add(AIFunctionFactory.Create(RevokeCertificateAsync, "revoke_certificate", - "Revoke a signing certificate. This action cannot be undone.")); + AddTool(AIFunctionFactory.Create(ListCertificatesAsync, "list_certificates", + "List all signing certificates. Optionally filter by name or type (e.g., 'DEVELOPMENT', 'DISTRIBUTION')."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(CreateCertificateAsync, "create_certificate", + "Create a new signing certificate. The PFX file will be saved to your Downloads folder."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(RevokeCertificateAsync, "revoke_certificate", + "Revoke a signing certificate. This action cannot be undone."), isReadOnly: false); // Provisioning Profile Tools - tools.Add(AIFunctionFactory.Create(ListProvisioningProfilesAsync, "list_provisioning_profiles", - "List all provisioning profiles. Optionally filter by name or bundle ID.")); - tools.Add(AIFunctionFactory.Create(DownloadProvisioningProfileAsync, "download_provisioning_profile", - "Download a provisioning profile to your Downloads folder.")); - tools.Add(AIFunctionFactory.Create(InstallProvisioningProfileAsync, "install_provisioning_profile", - "Install a provisioning profile to the system (~/Library/MobileDevice/Provisioning Profiles).")); - tools.Add(AIFunctionFactory.Create(InstallAllProvisioningProfilesAsync, "install_all_provisioning_profiles", - "Install all valid (active, non-expired) provisioning profiles to the system.")); - tools.Add(AIFunctionFactory.Create(DeleteProvisioningProfileAsync, "delete_provisioning_profile", - "Delete a provisioning profile from App Store Connect.")); + AddTool(AIFunctionFactory.Create(ListProvisioningProfilesAsync, "list_provisioning_profiles", + "List all provisioning profiles. Optionally filter by name or bundle ID."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(DownloadProvisioningProfileAsync, "download_provisioning_profile", + "Download a provisioning profile to your Downloads folder."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(InstallProvisioningProfileAsync, "install_provisioning_profile", + "Install a provisioning profile to the system (~/Library/MobileDevice/Provisioning Profiles)."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(InstallAllProvisioningProfilesAsync, "install_all_provisioning_profiles", + "Install all valid (active, non-expired) provisioning profiles to the system."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(DeleteProvisioningProfileAsync, "delete_provisioning_profile", + "Delete a provisioning profile from App Store Connect."), isReadOnly: false); // Android SDK Tools - tools.Add(AIFunctionFactory.Create(GetAndroidSdkPath, "get_android_sdk_path", - "Get the Android SDK installation path.")); - tools.Add(AIFunctionFactory.Create(ListAndroidPackagesAsync, "list_android_packages", - "List Android SDK packages. Can filter by installed, available, or search query.")); - tools.Add(AIFunctionFactory.Create(InstallAndroidPackageAsync, "install_android_package", - "Install an Android SDK package.")); - tools.Add(AIFunctionFactory.Create(UninstallAndroidPackageAsync, "uninstall_android_package", - "Uninstall an Android SDK package.")); + AddTool(AIFunctionFactory.Create(GetAndroidSdkPathAsync, "get_android_sdk_path", + "Get the Android SDK installation path."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(ListAndroidPackagesAsync, "list_android_packages", + "List Android SDK packages. Can filter by installed, available, or search query."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(InstallAndroidPackageAsync, "install_android_package", + "Install an Android SDK package."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(UninstallAndroidPackageAsync, "uninstall_android_package", + "Uninstall an Android SDK package."), isReadOnly: false); // Android Emulator/AVD Tools - tools.Add(AIFunctionFactory.Create(ListEmulatorsAsync, "list_emulators", - "List all Android emulators (AVDs). Shows running status.")); - tools.Add(AIFunctionFactory.Create(CreateEmulatorAsync, "create_emulator", - "Create a new Android emulator (AVD).")); - tools.Add(AIFunctionFactory.Create(DeleteEmulatorAsync, "delete_emulator", - "Delete an Android emulator (AVD).")); - tools.Add(AIFunctionFactory.Create(StartEmulatorAsync, "start_emulator", - "Start an Android emulator.")); - tools.Add(AIFunctionFactory.Create(StopEmulatorAsync, "stop_emulator", - "Stop a running Android emulator.")); + AddTool(AIFunctionFactory.Create(ListEmulatorsAsync, "list_emulators", + "List all Android emulators (AVDs). Shows running status."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(CreateEmulatorAsync, "create_emulator", + "Create a new Android emulator (AVD)."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(DeleteEmulatorAsync, "delete_emulator", + "Delete an Android emulator (AVD)."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(StartEmulatorAsync, "start_emulator", + "Start an Android emulator."), isReadOnly: false); + AddTool(AIFunctionFactory.Create(StopEmulatorAsync, "stop_emulator", + "Stop a running Android emulator."), isReadOnly: false); // Android Device Tools - tools.Add(AIFunctionFactory.Create(ListAndroidDevicesAsync, "list_android_devices", - "List connected Android devices and running emulators.")); + AddTool(AIFunctionFactory.Create(ListAndroidDevicesAsync, "list_android_devices", + "List connected Android devices and running emulators."), isReadOnly: true); // Android System Images & Device Definitions - tools.Add(AIFunctionFactory.Create(ListSystemImagesAsync, "list_system_images", - "List available Android system images for creating emulators.")); - tools.Add(AIFunctionFactory.Create(ListDeviceDefinitionsAsync, "list_device_definitions", - "List available device definitions for creating emulators.")); - - return tools; + AddTool(AIFunctionFactory.Create(ListSystemImagesAsync, "list_system_images", + "List available Android system images for creating emulators."), isReadOnly: true); + AddTool(AIFunctionFactory.Create(ListDeviceDefinitionsAsync, "list_device_definitions", + "List available device definitions for creating emulators."), isReadOnly: true); } private string? CheckIdentitySelected() @@ -129,6 +153,7 @@ public IReadOnlyList GetTools() [Description("List all configured Apple Developer identities")] private async Task ListAppleIdentitiesAsync() { + _logger.LogDebug("Tool: list_apple_identities called"); var identities = await _identityService.GetIdentitiesAsync(); if (!identities.Any()) { @@ -143,17 +168,21 @@ private async Task ListAppleIdentitiesAsync() i.KeyId, IsSelected = current?.Id == i.Id }); + _logger.LogDebug($"Tool: list_apple_identities returning {identities.Count()} identities"); return JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }); } [Description("Get the currently selected Apple Developer identity")] private string GetCurrentAppleIdentity() { + _logger.LogDebug("Tool: get_current_apple_identity called"); var identity = _identityState.SelectedIdentity; if (identity == null) { + _logger.LogDebug("Tool: get_current_apple_identity - no identity selected"); return JsonSerializer.Serialize(new { Selected = false, Message = "No Apple Developer identity is currently selected." }); } + _logger.LogDebug($"Tool: get_current_apple_identity - returning {identity.Name}"); return JsonSerializer.Serialize(new { Selected = true, @@ -168,6 +197,7 @@ private string GetCurrentAppleIdentity() private async Task SelectAppleIdentityAsync( [Description("The name or ID of the Apple identity to select")] string identityNameOrId) { + _logger.LogDebug($"Tool: select_apple_identity called with: {identityNameOrId}"); var identities = await _identityService.GetIdentitiesAsync(); var identity = identities.FirstOrDefault(i => i.Id.Equals(identityNameOrId, StringComparison.OrdinalIgnoreCase) || @@ -176,10 +206,12 @@ private async Task SelectAppleIdentityAsync( if (identity == null) { var available = string.Join(", ", identities.Select(i => i.Name)); + _logger.LogWarning($"Tool: select_apple_identity - identity not found: {identityNameOrId}"); return $"Identity '{identityNameOrId}' not found. Available identities: {available}"; } _identityState.SetSelectedIdentity(identity); + _logger.LogInformation($"Tool: select_apple_identity - selected: {identity.Name}"); return $"Selected Apple identity: {identity.Name}"; } @@ -217,24 +249,39 @@ private async Task ListBundleIdsAsync( [Description("Create a new Bundle ID in App Store Connect")] private async Task CreateBundleIdAsync( - [Description("The bundle identifier (e.g., com.company.appname)")] string identifier, + [Description("The bundle identifier (e.g., 'com.company.appname' for explicit or 'com.company.*' for wildcard)")] string identifier, [Description("A descriptive name for the Bundle ID")] string name, - [Description("Platform: 'IOS' or 'MAC_OS'")] string platform) + [Description("Platform: 'IOS' for iPhone/iPad or 'MAC_OS' for Mac apps. Defaults to IOS.")] string platform = "IOS") { var error = CheckIdentitySelected(); if (error != null) return error; try { + // Validate identifier format + if (string.IsNullOrWhiteSpace(identifier)) + { + return JsonSerializer.Serialize(new { Success = false, Message = "Bundle identifier cannot be empty" }); + } + + // Check for valid wildcard format + bool isWildcard = identifier.EndsWith(".*"); + if (identifier.Contains("*") && !isWildcard) + { + return JsonSerializer.Serialize(new { Success = false, Message = "Wildcard bundle IDs must end with '.*' (e.g., 'com.company.*')" }); + } + var result = await _appleService.CreateBundleIdAsync(identifier, name, platform.ToUpperInvariant()); return JsonSerializer.Serialize(new { Success = true, - Message = $"Created Bundle ID: {result.Identifier}", + Message = $"Created {(isWildcard ? "wildcard " : "")}Bundle ID: {result.Identifier}", result.Id, result.Identifier, result.Name, - result.Platform + result.Platform, + result.SeedId, + IsWildcard = isWildcard }, new JsonSerializerOptions { WriteIndented = true }); } catch (Exception ex) @@ -243,6 +290,36 @@ private async Task CreateBundleIdAsync( } } + [Description("Get the list of App ID Prefixes (Team IDs/Seed IDs) available for your account")] + private async Task GetAppIdPrefixesAsync() + { + var error = CheckIdentitySelected(); + if (error != null) return error; + + try + { + // Get all bundle IDs and extract unique seed IDs + var bundleIds = await _appleService.GetBundleIdsAsync(); + var prefixes = bundleIds + .Where(b => !string.IsNullOrEmpty(b.SeedId)) + .Select(b => b.SeedId!) + .Distinct() + .ToList(); + + return JsonSerializer.Serialize(new + { + Success = true, + Message = $"Found {prefixes.Count} App ID prefix(es)", + Prefixes = prefixes, + Note = "App ID Prefixes are assigned by Apple to your team. When creating a Bundle ID, Apple automatically assigns an appropriate prefix." + }, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { Success = false, Message = $"Failed to get App ID prefixes: {ex.Message}" }); + } + } + [Description("Delete a Bundle ID from App Store Connect")] private async Task DeleteBundleIdAsync( [Description("The Bundle ID identifier or internal ID to delete")] string bundleIdOrIdentifier) @@ -673,12 +750,24 @@ private async Task DeleteProvisioningProfileAsync( } [Description("Get the Android SDK installation path")] - private string GetAndroidSdkPath() + private async Task GetAndroidSdkPathAsync() { + _logger.LogDebug("Tool: get_android_sdk_path called"); + + // Try to detect SDK if not already done + if (!_androidService.IsSdkInstalled) + { + _logger.LogDebug("Tool: get_android_sdk_path - SDK not detected, attempting detection..."); + await _androidService.DetectSdkAsync(); + } + if (!_androidService.IsSdkInstalled) { - return JsonSerializer.Serialize(new { Installed = false, Message = "Android SDK is not installed or not detected." }); + _logger.LogWarning("Tool: get_android_sdk_path - SDK not found"); + return JsonSerializer.Serialize(new { Installed = false, Message = "Android SDK is not installed or not detected. Check ANDROID_HOME environment variable or install Android SDK." }); } + + _logger.LogDebug($"Tool: get_android_sdk_path - found at {_androidService.SdkPath}"); return JsonSerializer.Serialize(new { Installed = true, diff --git a/src/MauiSherpa.Core/ViewModels/AppleToolsViewModel.cs b/src/MauiSherpa.Core/ViewModels/AppleToolsViewModel.cs index 1bbdba0c..e8c52e21 100644 --- a/src/MauiSherpa.Core/ViewModels/AppleToolsViewModel.cs +++ b/src/MauiSherpa.Core/ViewModels/AppleToolsViewModel.cs @@ -45,5 +45,8 @@ public void LogInformation(string message) { } public void LogWarning(string message) { } public void LogError(string message, Exception? exception = null) { } public void LogDebug(string message) { } + public IReadOnlyList GetRecentLogs(int maxCount = 500) => []; + public void ClearLogs() { } + public event Action? OnLogAdded; } } diff --git a/src/MauiSherpa.Core/ViewModels/CopilotViewModel.cs b/src/MauiSherpa.Core/ViewModels/CopilotViewModel.cs index 0a8b4a80..62f482a3 100644 --- a/src/MauiSherpa.Core/ViewModels/CopilotViewModel.cs +++ b/src/MauiSherpa.Core/ViewModels/CopilotViewModel.cs @@ -70,5 +70,8 @@ public void LogInformation(string message) { } public void LogWarning(string message) { } public void LogError(string message, Exception? exception = null) { } public void LogDebug(string message) { } + public IReadOnlyList GetRecentLogs(int maxCount = 500) => []; + public void ClearLogs() { } + public event Action? OnLogAdded; } } diff --git a/src/MauiSherpa.Core/ViewModels/DashboardViewModel.cs b/src/MauiSherpa.Core/ViewModels/DashboardViewModel.cs index cab0aa50..d2ff067e 100644 --- a/src/MauiSherpa.Core/ViewModels/DashboardViewModel.cs +++ b/src/MauiSherpa.Core/ViewModels/DashboardViewModel.cs @@ -23,5 +23,8 @@ public void LogInformation(string message) { } public void LogWarning(string message) { } public void LogError(string message, Exception? exception = null) { } public void LogDebug(string message) { } + public IReadOnlyList GetRecentLogs(int maxCount = 500) => []; + public void ClearLogs() { } + public event Action? OnLogAdded; } } diff --git a/src/MauiSherpa.Core/ViewModels/SettingsViewModel.cs b/src/MauiSherpa.Core/ViewModels/SettingsViewModel.cs index ffca4002..434cc268 100644 --- a/src/MauiSherpa.Core/ViewModels/SettingsViewModel.cs +++ b/src/MauiSherpa.Core/ViewModels/SettingsViewModel.cs @@ -48,5 +48,8 @@ public void LogInformation(string message) { } public void LogWarning(string message) { } public void LogError(string message, Exception? exception = null) { } public void LogDebug(string message) { } + public IReadOnlyList GetRecentLogs(int maxCount = 500) => []; + public void ClearLogs() { } + public event Action? OnLogAdded; } } diff --git a/src/MauiSherpa/Components/CopilotOverlay.razor b/src/MauiSherpa/Components/CopilotOverlay.razor new file mode 100644 index 00000000..fa728bc9 --- /dev/null +++ b/src/MauiSherpa/Components/CopilotOverlay.razor @@ -0,0 +1,308 @@ +@using MauiSherpa.Core.Interfaces +@inject ICopilotContextService ContextService +@inject ICopilotService CopilotService +@inject ILoggingService Logger +@implements IDisposable + +@* Floating Copilot Button - stays visible when open, acts as minimize button *@ + + +@* Overlay *@ +@if (isOpen) +{ +
+
+
+
+ + GitHub Copilot + @if (CopilotService.IsConnected) + { + Connected + } + else + { + Disconnected + } +
+ +
+
+ +
+
+
+} + + + +@code { + private bool isOpen = false; + private bool isClosing = false; + private string? pendingMessage; + private CopilotContext? pendingContext; + private bool chatReady = false; + + protected override void OnInitialized() + { + ContextService.OnOpenRequested += HandleOpenRequested; + ContextService.OnCloseRequested += HandleCloseRequested; + ContextService.OnMessageRequested += HandleMessageRequested; + ContextService.OnContextRequested += HandleContextRequested; + } + + public void Dispose() + { + ContextService.OnOpenRequested -= HandleOpenRequested; + ContextService.OnCloseRequested -= HandleCloseRequested; + ContextService.OnMessageRequested -= HandleMessageRequested; + ContextService.OnContextRequested -= HandleContextRequested; + } + + private void HandleOpenRequested() + { + isClosing = false; + isOpen = true; + ContextService.NotifyOverlayStateChanged(true); + InvokeAsync(StateHasChanged); + } + + private void HandleCloseRequested() + { + CloseOverlay(); + } + + private void HandleMessageRequested(string message) + { + pendingMessage = message; + pendingContext = null; + InvokeAsync(StateHasChanged); + } + + private void HandleContextRequested(CopilotContext context) + { + pendingContext = context; + pendingMessage = null; + InvokeAsync(StateHasChanged); + } + + private void ToggleOverlay() + { + if (isOpen) + CloseOverlay(); + else + OpenOverlay(); + } + + private void OpenOverlay() + { + isClosing = false; + isOpen = true; + ContextService.NotifyOverlayStateChanged(true); + } + + private async void CloseOverlay() + { + isClosing = true; + StateHasChanged(); + + // Wait for animation to complete + await Task.Delay(250); + + isOpen = false; + isClosing = false; + ContextService.NotifyOverlayStateChanged(false); + await InvokeAsync(StateHasChanged); + } + + private void OnChatReady() + { + chatReady = true; + } + + private void ClearPending() + { + pendingMessage = null; + pendingContext = null; + } +} diff --git a/src/MauiSherpa/Components/CopilotRetryModal.razor b/src/MauiSherpa/Components/CopilotRetryModal.razor deleted file mode 100644 index c8c0f5ca..00000000 --- a/src/MauiSherpa/Components/CopilotRetryModal.razor +++ /dev/null @@ -1,414 +0,0 @@ -@using MauiSherpa.Core.Interfaces -@using MauiSherpa.Services -@inject ICopilotService CopilotService -@inject OperationModalService OperationModalService -@inject ProcessModalService ProcessModalService -@implements IDisposable - -@if (showModal) -{ -
-
- - - -
-
-} - - - -@code { - private bool showModal = false; - private bool isConnected = false; - private bool isBusy = false; - private string statusText = ""; - private List outputLines = new(); - private ElementReference outputRef; - - private record OutputLine(string Text, string Type); - - protected override void OnInitialized() - { - // Subscribe to modal services - OperationModalService.OnTryWithCopilotRequested += HandleOperationRetry; - ProcessModalService.OnTryWithCopilotRequested += HandleProcessRetry; - - // Subscribe to Copilot events - CopilotService.OnAssistantDelta += OnAssistantDelta; - CopilotService.OnAssistantMessage += OnAssistantMessage; - CopilotService.OnSessionIdle += OnSessionIdle; - CopilotService.OnError += OnCopilotError; - CopilotService.OnToolStart += OnToolStart; - CopilotService.OnToolComplete += OnToolComplete; - } - - public void Dispose() - { - OperationModalService.OnTryWithCopilotRequested -= HandleOperationRetry; - ProcessModalService.OnTryWithCopilotRequested -= HandleProcessRetry; - - CopilotService.OnAssistantDelta -= OnAssistantDelta; - CopilotService.OnAssistantMessage -= OnAssistantMessage; - CopilotService.OnSessionIdle -= OnSessionIdle; - CopilotService.OnError -= OnCopilotError; - CopilotService.OnToolStart -= OnToolStart; - CopilotService.OnToolComplete -= OnToolComplete; - } - - private async Task HandleOperationRetry(string title, string description, IEnumerable logs) - { - var logText = string.Join("\n", logs.Select(l => $"[{l.Timestamp:HH:mm:ss}] [{l.Level}] {l.Message}")); - - var prompt = $@"I was trying to perform an operation that failed. Please investigate and help me fix it. - -## Operation -**Title:** {title} -**Description:** {description} - -## Activity Log -``` -{logText} -``` - -## What I Need -1. Analyze why this operation failed -2. Suggest or implement alternative solutions -3. If you can fix the issue, please do so -4. If the fix requires commands, explain what they do before running them - -Please help me resolve this issue."; - - await StartCopilotSession(prompt); - } - - private async Task HandleProcessRetry(string title, string command, string output) - { - var prompt = $@"A command I ran failed. Please investigate and help me fix it. - -## Command -**Title:** {title} -**Command:** `{command}` - -## Output -``` -{output} -``` - -## What I Need -1. Analyze why this command failed -2. Suggest or implement alternative solutions -3. If you can fix the issue, please do so -4. If the fix requires different commands, explain what they do before running them - -Please help me resolve this issue."; - - await StartCopilotSession(prompt); - } - - private async Task StartCopilotSession(string prompt) - { - showModal = true; - outputLines.Clear(); - isConnected = false; - isBusy = true; - statusText = "Connecting to Copilot..."; - await InvokeAsync(StateHasChanged); - - try - { - if (!CopilotService.IsConnected) - { - AddOutput("Connecting to GitHub Copilot CLI...", "system"); - await CopilotService.ConnectAsync(); - } - - statusText = "Starting session..."; - AddOutput("Starting Copilot session...", "system"); - await InvokeAsync(StateHasChanged); - - await CopilotService.StartSessionAsync(); - isConnected = true; - - statusText = "Analyzing failure..."; - AddOutput("Sending analysis request...", "system"); - await InvokeAsync(StateHasChanged); - - await CopilotService.SendMessageAsync(prompt); - } - catch (Exception ex) - { - AddOutput($"Error: {ex.Message}", "error"); - isBusy = false; - statusText = "Failed to connect"; - await InvokeAsync(StateHasChanged); - } - } - - private void OnAssistantDelta(string delta) - { - if (string.IsNullOrEmpty(delta)) return; - - var lastLine = outputLines.LastOrDefault(); - if (lastLine?.Type == "assistant") - { - outputLines.RemoveAt(outputLines.Count - 1); - outputLines.Add(new OutputLine(lastLine.Text + delta, "assistant")); - } - else - { - outputLines.Add(new OutputLine(delta, "assistant")); - } - InvokeAsync(StateHasChanged); - } - - private void OnAssistantMessage(string message) - { - if (!string.IsNullOrEmpty(message)) - { - if (outputLines.Count > 0 && outputLines[^1].Type == "assistant") - { - outputLines.RemoveAt(outputLines.Count - 1); - } - AddOutput(message, "assistant"); - } - InvokeAsync(StateHasChanged); - } - - private void OnSessionIdle() - { - isBusy = false; - statusText = "Complete"; - AddOutput("\n--- Analysis complete ---", "system"); - InvokeAsync(StateHasChanged); - } - - private void OnCopilotError(string error) - { - AddOutput($"Error: {error}", "error"); - isBusy = false; - statusText = "Error occurred"; - InvokeAsync(StateHasChanged); - } - - private void OnToolStart(string toolName, string args) - { - statusText = $"Running: {toolName}"; - AddOutput($"[Tool: {toolName}]", "tool"); - InvokeAsync(StateHasChanged); - } - - private void OnToolComplete(string toolName, string result) - { - statusText = "Processing..."; - InvokeAsync(StateHasChanged); - } - - private void AddOutput(string text, string type) - { - outputLines.Add(new OutputLine(text, type)); - } - - private async Task Abort() - { - try - { - AddOutput("Aborting...", "system"); - await CopilotService.AbortAsync(); - isBusy = false; - statusText = "Aborted"; - } - catch { } - } - - private void CloseIfNotBusy() - { - if (!isBusy) Close(); - } - - private void Close() - { - if (isBusy) return; - showModal = false; - } -} diff --git a/src/MauiSherpa/Components/DebugOverlay.razor b/src/MauiSherpa/Components/DebugOverlay.razor deleted file mode 100644 index 699e5daa..00000000 --- a/src/MauiSherpa/Components/DebugOverlay.razor +++ /dev/null @@ -1,139 +0,0 @@ -@inject MauiSherpa.Services.DebugLogService DebugLog - -
🐛
- -@if (isVisible) -{ -
-
- Debug Log -
- - -
-
-
- @foreach (var log in logs) - { -
@log
- } -
-
-} - - - -@code { - private bool isVisible = false; - private List logs = new(); - - protected override void OnInitialized() - { - DebugLog.OnLogAdded += RefreshLogs; - RefreshLogs(); - } - - private void RefreshLogs() - { - logs = DebugLog.GetLogs().Reverse().ToList(); - InvokeAsync(StateHasChanged); - } - - private void ToggleOverlay() - { - isVisible = !isVisible; - } - - private void Clear() - { - DebugLog.Clear(); - } - - public void Dispose() - { - DebugLog.OnLogAdded -= RefreshLogs; - } -} diff --git a/src/MauiSherpa/Components/MainLayout.razor b/src/MauiSherpa/Components/MainLayout.razor index 0d81caaa..ebc70f2c 100644 --- a/src/MauiSherpa/Components/MainLayout.razor +++ b/src/MauiSherpa/Components/MainLayout.razor @@ -18,10 +18,6 @@ Settings - - - Copilot - @@ -74,11 +70,8 @@ - - - - - + + @code { private string ThemeClass => ThemeService.IsDarkMode ? "theme-dark" : "theme-light"; @@ -166,12 +159,12 @@ .nav-item.active { background-color: #4a5568; color: white; - border-left-color: #48bb78; + border-left-color: #8b5cf6; } .nav-item.active .nav-icon { opacity: 1; - color: #48bb78; + color: #8b5cf6; } .content { diff --git a/src/MauiSherpa/Components/MultiOperationModal.razor b/src/MauiSherpa/Components/MultiOperationModal.razor index 17df129b..80cd1d46 100644 --- a/src/MauiSherpa/Components/MultiOperationModal.razor +++ b/src/MauiSherpa/Components/MultiOperationModal.razor @@ -1,6 +1,8 @@ @using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Services @using MauiSherpa.Services @inject MultiOperationModalService ModalService +@inject ICopilotContextService CopilotContext @implements IDisposable @if (ModalService.IsVisible) @@ -158,6 +160,12 @@ } else { + @if (FailedCount > 0) + { + + } @@ -473,6 +481,9 @@ .btn-warning { background: #ed8936; color: white; } .btn-warning:hover:not(:disabled) { background: #dd6b20; } + + .btn-copilot { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; display: inline-flex; align-items: center; gap: 8px; } + .btn-copilot:hover:not(:disabled) { background: linear-gradient(135deg, #4f46e5, #7c3aed); } @code { @@ -584,6 +595,36 @@ { ModalService.Close(); } + + private void HandleTryWithCopilot() + { + // Collect failed operations + var failedOps = ModalService.Operations + .Where(op => op.State == OperationItemState.Failed) + .ToList(); + + var details = new System.Text.StringBuilder(); + details.AppendLine($"Batch operation: {ModalService.Title}"); + details.AppendLine($"Total: {ModalService.Operations.Count}, Failed: {FailedCount}, Completed: {CompletedCount}"); + details.AppendLine(); + details.AppendLine("Failed operations:"); + + foreach (var op in failedOps) + { + details.AppendLine($"- {op.Name}: {op.ErrorMessage ?? "Unknown error"}"); + } + + var context = CopilotContextService.BuildOperationFailureContext( + ModalService.Title ?? "Batch operation", + $"{FailedCount} operation(s) failed", + details.ToString() + ); + + CopilotContext.OpenWithContext(context); + + // Close the modal + ModalService.Close(); + } private string GetOperationCardClass(OperationItemStatus op) => op.State switch { diff --git a/src/MauiSherpa/Components/OperationModal.razor b/src/MauiSherpa/Components/OperationModal.razor index 8fee456d..da32e9eb 100644 --- a/src/MauiSherpa/Components/OperationModal.razor +++ b/src/MauiSherpa/Components/OperationModal.razor @@ -1,6 +1,8 @@ @using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Services @using MauiSherpa.Services @inject OperationModalService ModalService +@inject ICopilotContextService CopilotContext @implements IDisposable @if (ModalService.IsVisible) @@ -492,9 +494,28 @@ ModalService.Close(); } - private async Task HandleTryWithCopilot() + private void HandleTryWithCopilot() { - await ModalService.TryWithCopilotAsync(); + // Get error details from log + var errorLogs = ModalService.LogEntries + .Where(e => e.Level == OperationLogLevel.Error) + .Select(e => e.Message) + .ToList(); + + var lastError = errorLogs.LastOrDefault() ?? "Operation failed"; + var allLogs = string.Join("\n", ModalService.LogEntries.Select(e => $"[{e.Level}] {e.Message}")); + + // Build context and open overlay + var context = CopilotContextService.BuildOperationFailureContext( + ModalService.Title ?? "Unknown operation", + lastError, + allLogs.Length > 2000 ? allLogs.Substring(0, 2000) + "..." : allLogs + ); + + CopilotContext.OpenWithContext(context); + + // Close the modal - use HandleClose which handles proper cleanup + HandleClose(); } private async Task CopyLog() diff --git a/src/MauiSherpa/Components/ProcessExecutionModal.razor b/src/MauiSherpa/Components/ProcessExecutionModal.razor index 37524dbf..9fe1d913 100644 --- a/src/MauiSherpa/Components/ProcessExecutionModal.razor +++ b/src/MauiSherpa/Components/ProcessExecutionModal.razor @@ -1,7 +1,9 @@ @using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Services @using MauiSherpa.Services @inject ProcessModalService ModalService @inject IProcessExecutionService ProcessService +@inject ICopilotContextService CopilotContext @inject IJSRuntime JS @implements IDisposable @@ -568,7 +570,18 @@ } catch { } } - await ModalService.TryWithCopilotAsync(output); + + // Build context and open overlay + var context = CopilotContextService.BuildProcessFailureContext( + Request?.CommandLine ?? "Unknown command", + Result?.ExitCode ?? -1, + output + ); + + CopilotContext.OpenWithContext(context); + + // Close the modal + HandleClose(); } private async Task CopyOutput() diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index 644acf7f..14ffd76f 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -55,6 +55,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Apple services builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/MauiSherpa.csproj b/src/MauiSherpa/MauiSherpa.csproj index 9203a2c4..5595d4ee 100644 --- a/src/MauiSherpa/MauiSherpa.csproj +++ b/src/MauiSherpa/MauiSherpa.csproj @@ -30,6 +30,7 @@ + diff --git a/src/MauiSherpa/Pages/Copilot.razor b/src/MauiSherpa/Pages/Copilot.razor index 9adc8abe..20fc9836 100644 --- a/src/MauiSherpa/Pages/Copilot.razor +++ b/src/MauiSherpa/Pages/Copilot.razor @@ -1,10 +1,18 @@ -@page "/copilot" @using MauiSherpa.Core.Interfaces +@using Markdig @inject ICopilotService CopilotService @inject IAlertService AlertService +@inject ICopilotToolsService ToolsService +@inject IJSRuntime JSRuntime +@inject ILoggingService _logger @implements IDisposable -

GitHub Copilot

+
+ +@if (!IsEmbedded) +{ +

GitHub Copilot

+} @if (isCheckingAvailability) { @@ -158,29 +166,128 @@ else
-
- @foreach (var msg in messages) + @if (messages.Count > 0 || isBusy) + { +
+ @foreach (var msg in messages.ToList()) { -
-
- - @(msg.IsUser ? "You" : "Copilot") + @if (msg.MessageType == CopilotMessageType.Reasoning) + { + @* Reasoning/Thinking Message *@ +
+ + @if (!msg.IsCollapsed) + { +
@msg.Content
+ }
-
@msg.Content
-
+ } + else if (msg.MessageType == CopilotMessageType.ToolCall) + { + @* Tool Call Message - skip internal/noisy tools *@ + @if (!IsInternalTool(msg.ToolName)) + { +
+
+
+ + @FormatToolName(msg.ToolName ?? "") +
+
+ @if (!msg.IsComplete) + { + + Running + } + else if (msg.IsSuccess) + { + + Complete + } + else + { + + Failed + } +
+
+ @if (msg.IsComplete && !string.IsNullOrEmpty(msg.Content) && !IsUnusableToolResult(msg.Content)) + { +
+ + @if (!msg.IsCollapsed) + { +
@TruncateResult(msg.Content)
+ } +
+ } +
+ } + } + else if (msg.MessageType == CopilotMessageType.Error) + { + @* Error Message *@ +
+
+ + Error@(!string.IsNullOrEmpty(msg.ToolName) ? $" ({msg.ToolName})" : "") +
+
+ @msg.Content +
+
+ } + else + { + @* Regular Text Message *@ +
+
+ + @(msg.IsUser ? "You" : "Copilot") +
+
+ @if (msg.IsUser) + { + @msg.Content + } + else + { + @((MarkupString)RenderMarkdown(msg.Content)) + } +
+
+ } } + + @* Streaming Response *@ @if (isBusy && !string.IsNullOrEmpty(currentResponse)) {
Copilot -
-
@currentResponse
+
+ @((MarkupString)RenderMarkdown(currentResponse)) +
+ + + +
+
} - else if (isBusy) + else if (isBusy && !messages.Any(m => m.MessageType == CopilotMessageType.Reasoning && !m.IsComplete) + && !messages.Any(m => m.MessageType == CopilotMessageType.ToolCall && !m.IsComplete)) {
@@ -195,20 +302,65 @@ else
}
+ }
- @if (!string.IsNullOrEmpty(currentTool)) + @{ + var hasUsageToShow = currentUsageInfo != null && ( + !string.IsNullOrEmpty(currentUsageInfo.Model) || + currentUsageInfo.InputTokens.HasValue || + currentUsageInfo.OutputTokens.HasValue || + (currentUsageInfo.CurrentTokens.HasValue && currentUsageInfo.TokenLimit.HasValue) + ); + } + @if (hasUsageToShow) + { +
+ @if (!string.IsNullOrEmpty(currentUsageInfo!.Model)) + { + @currentUsageInfo.Model + } + @if (currentUsageInfo.InputTokens.HasValue || currentUsageInfo.OutputTokens.HasValue) + { + + @(currentUsageInfo.InputTokens ?? 0) + @(currentUsageInfo.OutputTokens ?? 0) + + } + @if (currentUsageInfo.CurrentTokens.HasValue && currentUsageInfo.TokenLimit.HasValue) + { + @currentUsageInfo.CurrentTokens / @currentUsageInfo.TokenLimit + } +
+ } + else if (CopilotService.IsConnected) + { +
+ claude-opus-4-5-20250514 + Waiting for response... +
+ } + @if (!string.IsNullOrEmpty(currentIntent)) + { +
+ + @currentIntent +
+ } + else if (!string.IsNullOrEmpty(currentTool)) {
- Running: @currentTool + Running: @FormatToolName(currentTool)
}
+ +
+
+
+} + +@* Debug Overlay *@ +@if (showDebugOverlay) +{ +
+
+ Debug Logs +
+ + + + + +
+
+
+ @foreach (var log in GetFilteredLogs()) + { +
+ @log.Timestamp.ToString("HH:mm:ss.fff") + [@log.Level] + @log.Message +
+ } +
+
+} + +@* Debug Toggle Button *@ + + +
@* Close copilot-page *@ + -@code { - private bool isCheckingAvailability = true; - private CopilotAvailability? availability; - private bool isConnecting = false; - private bool isBusy = false; - private string userInput = ""; - private string currentResponse = ""; - private string currentTool = ""; - private ElementReference chatMessagesRef; + /* Reasoning Block Styles */ + .reasoning-block { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.08) 0%, rgba(99, 102, 241, 0.08) 100%); + border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: 12px; + margin-bottom: 8px; + overflow: hidden; + max-width: 95%; + flex-shrink: 0; /* Prevent shrinking */ + } - // Use service's message list for persistence across navigation - private IReadOnlyList messages => CopilotService.Messages; + .reasoning-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-primary); + } - private readonly List suggestedPrompts = new() - { - new("fa-stethoscope", "Check Development Environment", - "Analyze and help setup my .NET MAUI development environment", - "Please check my .NET MAUI development environment and help me fix any issues. Check .NET SDK, MAUI workloads, JDK, Android SDK, and Xcode (if on macOS). Ask before making changes."), - - new("fa-mobile-alt", "Register iPhone for Development", - "Help register a new iPhone device for iOS development", - "Help me register my new iPhone for iOS development. Guide me through getting the device UDID, adding it to my Apple Developer account, and updating my provisioning profiles."), - - new("fa-desktop", "Create Android Emulator", - "Create a new Android emulator with recommended settings", - "Help me create a new Android emulator. Suggest a good system image and device definition for testing .NET MAUI apps. Walk me through the options."), - - new("fa-certificate", "Setup Code Signing", - "Help configure iOS code signing certificates and profiles", - "Help me set up code signing for my iOS app. I need to create or check my development certificate, app ID, and provisioning profile. Guide me through the process."), - - new("fa-box", "Install Android SDK Packages", - "Recommend and install essential Android SDK packages", - "What Android SDK packages do I need for .NET MAUI development? Please check what I have installed and recommend any missing packages."), - - new("fa-bug", "Debug Build Issues", - "Help troubleshoot .NET MAUI build errors", - "I'm having build issues with my .NET MAUI project. Help me diagnose and fix the problem. What information do you need to help?") - }; + .reasoning-header:hover { + background: rgba(139, 92, 246, 0.1); + } - private record SuggestedPrompt(string Icon, string Title, string Description, string Prompt); + .reasoning-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 600; + color: #8b5cf6; + } - protected override async Task OnInitializedAsync() - { - CopilotService.OnAssistantDelta += OnAssistantDelta; - CopilotService.OnAssistantMessage += OnAssistantMessage; - CopilotService.OnSessionIdle += OnSessionIdle; - CopilotService.OnError += OnCopilotError; - CopilotService.OnToolStart += OnToolStart; - CopilotService.OnToolComplete += OnToolComplete; + .reasoning-title i { + font-size: 16px; + } - await CheckAvailability(); + .collapse-icon { + font-size: 12px; + color: var(--text-muted); + transition: transform 0.2s; } - public void Dispose() - { - CopilotService.OnAssistantDelta -= OnAssistantDelta; - CopilotService.OnAssistantMessage -= OnAssistantMessage; - CopilotService.OnSessionIdle -= OnSessionIdle; - CopilotService.OnError -= OnCopilotError; - CopilotService.OnToolStart -= OnToolStart; - CopilotService.OnToolComplete -= OnToolComplete; + .reasoning-content { + padding: 0 16px 16px 16px; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + font-style: italic; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; } - private async Task CheckAvailability() - { - // Use cached availability if available for instant display - if (CopilotService.CachedAvailability != null) - { - availability = CopilotService.CachedAvailability; - isCheckingAvailability = false; - - // Auto-connect if Copilot is available and authenticated - if (availability.IsInstalled && availability.IsAuthenticated && !CopilotService.IsConnected) - { - await Connect(); - } - StateHasChanged(); - return; - } + .reasoning-block.collapsed .reasoning-content { + display: none; + } - isCheckingAvailability = true; - StateHasChanged(); + /* Tool Card Styles */ + .tool-card { + background: var(--card-bg); + border-radius: 10px; + border: 1px solid var(--border-color); + margin-bottom: 8px; + overflow: hidden; + max-width: 90%; + flex-shrink: 0; /* Prevent shrinking */ + } + } - try - { - availability = await CopilotService.CheckAvailabilityAsync(); - - // Auto-connect if Copilot is available and authenticated - if (availability.IsInstalled && availability.IsAuthenticated && !CopilotService.IsConnected) - { - await Connect(); - } - } - finally - { - isCheckingAvailability = false; - StateHasChanged(); - } + .tool-card.running { + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.15); } - private async Task CopyToClipboard(string text) - { - try - { - await Clipboard.Default.SetTextAsync(text); - await AlertService.ShowToastAsync("Copied to clipboard"); - } - catch { } + .tool-card.success { + border-color: rgba(34, 197, 94, 0.4); } - private async Task Connect() - { - isConnecting = true; - StateHasChanged(); + .tool-card.error { + border-color: rgba(239, 68, 68, 0.4); + } - try - { - await CopilotService.ConnectAsync(); - await CopilotService.StartSessionAsync(); - } - catch (Exception ex) - { - await AlertService.ShowAlertAsync("Connection Failed", ex.Message); + .tool-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-tertiary); + } + + .tool-info { + display: flex; + align-items: center; + gap: 10px; + } + + .tool-info i { + font-size: 14px; + color: #6366f1; + } + + .tool-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + } + + .tool-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + } + + .tool-card.running .tool-status { + color: #6366f1; + } + + .tool-card.success .tool-status { + color: #22c55e; + } + + .tool-card.error .tool-status { + color: #ef4444; + } + + /* Error Card Styles */ + .error-card { + flex-shrink: 0; + border-radius: 12px; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(220, 38, 38, 0.05)); + border: 1px solid rgba(239, 68, 68, 0.3); + overflow: hidden; + } + + .error-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(239, 68, 68, 0.15); + border-bottom: 1px solid rgba(239, 68, 68, 0.2); + } + + .error-header i { + color: #ef4444; + font-size: 16px; + } + + .error-header span { + font-weight: 600; + color: #dc2626; + font-size: 14px; + } + + .error-content { + padding: 12px 16px; + font-size: 13px; + color: #b91c1c; + white-space: pre-wrap; + line-height: 1.5; + } + + :global(.theme-dark) .error-card { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.08)); + } + + :global(.theme-dark) .error-header span { + color: #f87171; + } + + :global(.theme-dark) .error-content { + color: #fca5a5; + } + + .tool-result { + border-top: 1px solid var(--border-color); + } + + .tool-result-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: transparent; + border: none; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + } + + .tool-result-toggle:hover { + background: var(--bg-hover); + } + + .tool-result-content { + margin: 0; + padding: 12px 16px; + font-size: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + /* Markdown Content Styles */ + .message-content p { + margin: 0 0 8px 0; + } + + .message-content p:last-child { + margin-bottom: 0; + } + + .message-content code { + background: rgba(99, 102, 241, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + .user-message .message-content code { + background: rgba(255, 255, 255, 0.2); + } + + .message-content pre { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + overflow-x: auto; + margin: 8px 0; + } + + .message-content pre code { + background: transparent; + padding: 0; + font-size: 13px; + line-height: 1.4; + } + + .user-message .message-content pre { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + } + + .message-content ul, .message-content ol { + margin: 6px 0; + padding-left: 24px; + } + + .message-content li { + margin: 2px 0; + } + + .message-content h1, .message-content h2, .message-content h3 { + margin: 12px 0 6px 0; + } + + .message-content h1 { font-size: 1.3em; } + .message-content h2 { font-size: 1.15em; } + .message-content h3 { font-size: 1.05em; } + + .message-content blockquote { + border-left: 3px solid #6366f1; + margin: 12px 0; + padding: 8px 16px; + background: rgba(99, 102, 241, 0.05); + border-radius: 0 8px 8px 0; + } + + .user-message .message-content blockquote { + border-left-color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.1); + } + + .message-content a { + color: #6366f1; + text-decoration: none; + } + + .message-content a:hover { + text-decoration: underline; + } + + .user-message .message-content a { + color: #c4b5fd; + } + + .message-content table { + border-collapse: collapse; + margin: 12px 0; + width: 100%; + } + + .message-content th, .message-content td { + border: 1px solid var(--border-color); + padding: 8px 12px; + text-align: left; + } + + .message-content th { + background: var(--bg-tertiary); + font-weight: 600; + } + + /* Dark mode specific for reasoning */ + :global(.theme-dark) .reasoning-block { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(99, 102, 241, 0.12) 100%); + border-color: rgba(139, 92, 246, 0.3); + } + + :global(.theme-dark) .reasoning-header:hover { + background: rgba(139, 92, 246, 0.15); + } + + /* Permission Modal Styles */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + } + + .permission-modal { + background: var(--card-bg); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 600px; + min-width: 400px; + width: auto; + overflow: hidden; + animation: modalSlideIn 0.2s ease-out; + } + + @@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .permission-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); + border-bottom: 1px solid var(--border-color); + } + + .permission-header i { + font-size: 24px; + color: #6366f1; + } + + .permission-header h3 { + margin: 0; + font-size: 18px; + color: var(--text-primary); + } + + .permission-content { + padding: 24px; + } + + .permission-content > p { + margin: 0 0 16px 0; + color: var(--text-secondary); + } + + .tool-details { + background: var(--bg-tertiary); + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; + } + + .tool-detail-name { + display: flex; + align-items: center; + gap: 10px; + } + + .tool-detail-name i { + font-size: 18px; + color: #6366f1; + } + + .tool-detail-name strong { + font-size: 15px; + color: var(--text-primary); + } + + .tool-description { + margin: 10px 0 0 0; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + } + + .tool-command, .tool-path { + margin: 12px 0 0 0; + padding: 12px; + background: var(--bg-tertiary, #f1f5f9); + border-radius: 8px; + border: 1px solid var(--border-color, #e2e8f0); + } + + .tool-command label, .tool-path label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .tool-command code, .tool-path code { + display: block; + font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 12px; + color: var(--text-primary); + word-break: break-all; + white-space: pre-wrap; + line-height: 1.5; + } + + .permission-warning { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 8px; + color: #d97706; + font-size: 13px; + margin: 0; + } + + .permission-warning i { + color: #f59e0b; + } + + .permission-actions { + display: flex; + gap: 12px; + padding: 16px 24px 24px; + justify-content: flex-end; + } + + .permission-actions .btn { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + } + + /* Debug Overlay Styles */ + .debug-toggle-btn { + position: fixed; + bottom: 20px; + right: 20px; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: all 0.2s ease; + } + + .debug-toggle-btn:hover { + background: #6366f1; + color: white; + border-color: #6366f1; + } + + .debug-overlay { + position: fixed; + bottom: 80px; + right: 20px; + width: 500px; + height: 300px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + transition: all 0.2s ease; + } + + .debug-overlay.expanded { + width: 1000px; + height: 700px; + bottom: 40px; + right: 40px; + } + + .debug-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(99, 102, 241, 0.1); + border-bottom: 1px solid var(--border-color); + } + + .debug-title { + font-weight: 600; + color: #6366f1; + display: flex; + align-items: center; + gap: 8px; + } + + .debug-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .debug-filter { + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg, var(--card-bg)); + color: var(--text-primary); + font-size: 13px; + width: 150px; + } + + .debug-filter:focus { + outline: none; + border-color: #6366f1; + } + + .debug-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + } + + .debug-btn:hover { + background: rgba(99, 102, 241, 0.15); + color: #6366f1; + } + + .debug-content { + flex: 1; + overflow-y: auto; + font-family: 'SF Mono', Consolas, monospace; + font-size: 12px; + padding: 8px; + background: #f8fafc; + } + + :global(.theme-dark) .debug-content { + background: #1e1e2e; + } + + .debug-log { + display: flex; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; + } + + .debug-log:hover { + background: rgba(99, 102, 241, 0.1); + } + + .log-time { + color: #64748b; + flex-shrink: 0; + } + + .log-level { + flex-shrink: 0; + font-weight: 600; + } + + .log-message { + color: #1e293b; + } + + :global(.theme-dark) .log-time { + color: #94a3b8; + } + + :global(.theme-dark) .log-message { + color: #e2e8f0; + } + + .debug-log.log-dbg .log-level { color: #64748b; } + .debug-log.log-inf .log-level { color: #2563eb; } + .debug-log.log-wrn .log-level { color: #d97706; } + .debug-log.log-err .log-level { color: #dc2626; } + .debug-log.log-err { background: rgba(239, 68, 68, 0.15); } + + :global(.theme-dark) .debug-log.log-dbg .log-level { color: #94a3b8; } + :global(.theme-dark) .debug-log.log-inf .log-level { color: #60a5fa; } + :global(.theme-dark) .debug-log.log-wrn .log-level { color: #fbbf24; } + :global(.theme-dark) .debug-log.log-err .log-level { color: #f87171; } + + + + +@code { + // Parameters for embedded mode (overlay) + [Parameter] public bool IsEmbedded { get; set; } = false; + [Parameter] public string? PendingMessage { get; set; } + [Parameter] public CopilotContext? PendingContext { get; set; } + [Parameter] public EventCallback OnPendingHandled { get; set; } + [Parameter] public EventCallback OnReady { get; set; } + + private bool isCheckingAvailability = true; + private CopilotAvailability? availability; + private bool isConnecting = false; + private bool isBusy = false; + private string userInput = ""; + private string currentResponse = ""; + private string currentTool = ""; + private string currentToolCallId = ""; + private string currentIntent = ""; + private ElementReference chatMessagesRef; + private ElementReference? _activeReasoningRef; + private bool shouldPreventDefault = false; + private bool isAtBottom = true; // Track if user is scrolled to bottom + + // Debug overlay state + private bool showDebugOverlay = false; + private bool debugOverlayExpanded = false; + private string debugFilter = ""; + private ElementReference debugContentRef; + + // Usage info state + private CopilotUsageInfo? currentUsageInfo; + + // System prompt loaded from bundled file + private string? _systemPrompt; + + // Current streaming reasoning ID for updates + private string? currentReasoningId; + + // Track if pending was already processed + private string? _lastProcessedMessage; + private CopilotContext? _lastProcessedContext; + + // Markdig pipeline for markdown rendering + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + // Use service's message list for persistence across navigation + private IReadOnlyList messages => CopilotService.Messages; + + private readonly List suggestedPrompts = new() + { + new("fa-stethoscope", "Check Development Environment", + "Analyze and help setup my .NET MAUI development environment", + "Please check my .NET MAUI development environment and help me fix any issues. Check .NET SDK, MAUI workloads, JDK, Android SDK, and Xcode (if on macOS). Ask before making changes."), + + new("fa-mobile-alt", "Register iPhone for Development", + "Help register a new iPhone device for iOS development", + "Help me register my new iPhone for iOS development. Guide me through getting the device UDID, adding it to my Apple Developer account, and updating my provisioning profiles."), + + new("fa-desktop", "Create Android Emulator", + "Create a new Android emulator with recommended settings", + "Help me create a new Android emulator. Suggest a good system image and device definition for testing .NET MAUI apps. Walk me through the options."), + + new("fa-certificate", "Setup Code Signing", + "Help configure iOS code signing certificates and profiles", + "Help me set up code signing for my iOS app. I need to create or check my development certificate, app ID, and provisioning profile. Guide me through the process."), + + new("fa-box", "Install Android SDK Packages", + "Recommend and install essential Android SDK packages", + "What Android SDK packages do I need for .NET MAUI development? Please check what I have installed and recommend any missing packages."), + + new("fa-bug", "Debug Build Issues", + "Help troubleshoot .NET MAUI build errors", + "I'm having build issues with my .NET MAUI project. Help me diagnose and fix the problem. What information do you need to help?") + }; + + private record SuggestedPrompt(string Icon, string Title, string Description, string Prompt); + + protected override async Task OnInitializedAsync() + { + CopilotService.OnAssistantDelta += OnAssistantDelta; + CopilotService.OnAssistantMessage += OnAssistantMessage; + CopilotService.OnSessionIdle += OnSessionIdle; + CopilotService.OnError += OnCopilotError; + CopilotService.OnToolStart += OnToolStart; + CopilotService.OnToolComplete += OnToolComplete; + CopilotService.OnReasoningStart += OnReasoningStart; + CopilotService.OnReasoningDelta += OnReasoningDelta; + CopilotService.OnTurnStart += OnTurnStart; + CopilotService.OnTurnEnd += OnTurnEnd; + CopilotService.OnIntentChanged += OnIntentChanged; + CopilotService.OnUsageInfoChanged += OnUsageInfoChanged; + CopilotService.OnSessionError += OnSessionError; + _logger.OnLogAdded += OnLogAdded; + + // Set up permission handler + CopilotService.PermissionHandler = HandlePermissionRequest; + + // Load system prompt from bundled file + await LoadSystemPromptAsync(); + + await CheckAvailability(); + + // Notify ready + await OnReady.InvokeAsync(); + } + + protected override async Task OnParametersSetAsync() + { + // Handle pending message when overlay opens with context + await ProcessPendingAsync(); + } + + private async Task ProcessPendingAsync() + { + // Check for pending message + if (!string.IsNullOrEmpty(PendingMessage) && PendingMessage != _lastProcessedMessage) + { + _lastProcessedMessage = PendingMessage; + + // Ensure connected + if (!CopilotService.IsConnected && availability?.IsAuthenticated == true) + { + await Connect(); + await Task.Delay(100); // Brief wait for connection + } + + // Set user input and send + userInput = PendingMessage; + StateHasChanged(); + await Task.Delay(50); + await SendMessage(); + + await OnPendingHandled.InvokeAsync(); + } + + // Check for pending context + if (PendingContext != null && PendingContext != _lastProcessedContext) + { + _lastProcessedContext = PendingContext; + + // Ensure connected + if (!CopilotService.IsConnected && availability?.IsAuthenticated == true) + { + await Connect(); + await Task.Delay(100); + } + + // Set user input from context and send + userInput = PendingContext.Message; + StateHasChanged(); + await Task.Delay(50); + await SendMessage(); + + await OnPendingHandled.InvokeAsync(); + } + } + + private async Task LoadSystemPromptAsync() + { + try + { + using var stream = await FileSystem.OpenAppPackageFileAsync("SystemPrompt.md"); + using var reader = new StreamReader(stream); + _systemPrompt = await reader.ReadToEndAsync(); + _logger.LogInformation($"Loaded system prompt ({_systemPrompt.Length} chars)"); + } + catch (Exception ex) + { + _logger.LogWarning($"Could not load system prompt file, using default: {ex.Message}"); + _systemPrompt = null; // Will use default + } + } + + public void Dispose() + { + CopilotService.OnAssistantDelta -= OnAssistantDelta; + CopilotService.OnAssistantMessage -= OnAssistantMessage; + CopilotService.OnSessionIdle -= OnSessionIdle; + CopilotService.OnError -= OnCopilotError; + CopilotService.OnToolStart -= OnToolStart; + CopilotService.OnToolComplete -= OnToolComplete; + CopilotService.OnReasoningStart -= OnReasoningStart; + CopilotService.OnReasoningDelta -= OnReasoningDelta; + CopilotService.OnTurnStart -= OnTurnStart; + CopilotService.OnTurnEnd -= OnTurnEnd; + CopilotService.OnIntentChanged -= OnIntentChanged; + CopilotService.OnUsageInfoChanged -= OnUsageInfoChanged; + CopilotService.OnSessionError -= OnSessionError; + _logger.OnLogAdded -= OnLogAdded; + CopilotService.PermissionHandler = null; + } + + private async Task CheckAvailability() + { + // Use cached availability if available for instant display + if (CopilotService.CachedAvailability != null) + { + availability = CopilotService.CachedAvailability; + isCheckingAvailability = false; + + // Auto-connect if Copilot is available and authenticated + if (availability.IsInstalled && availability.IsAuthenticated && !CopilotService.IsConnected) + { + await Connect(); + } + StateHasChanged(); + return; + } + + isCheckingAvailability = true; + StateHasChanged(); + + try + { + availability = await CopilotService.CheckAvailabilityAsync(); + + // Auto-connect if Copilot is available and authenticated + if (availability.IsInstalled && availability.IsAuthenticated && !CopilotService.IsConnected) + { + await Connect(); + } + } + finally + { + isCheckingAvailability = false; + StateHasChanged(); + } + } + + private async Task CopyToClipboard(string text) + { + try + { + await Clipboard.Default.SetTextAsync(text); + await AlertService.ShowToastAsync("Copied to clipboard"); + } + catch { } + } + + private async Task Connect() + { + isConnecting = true; + StateHasChanged(); + + try + { + await CopilotService.ConnectAsync(); + await CopilotService.StartSessionAsync(systemPrompt: _systemPrompt); + } + catch (Exception ex) + { + await AlertService.ShowAlertAsync("Connection Failed", ex.Message); } finally { @@ -890,14 +2049,19 @@ else CopilotService.AddUserMessage(message); currentResponse = ""; isBusy = true; + isAtBottom = true; // Reset to bottom when user sends a message StateHasChanged(); + + // Force scroll after DOM update + await Task.Delay(50); + await ForceScrollToBottom(); try { if (!CopilotService.IsConnected) { await CopilotService.ConnectAsync(); - await CopilotService.StartSessionAsync(); + await CopilotService.StartSessionAsync(systemPrompt: _systemPrompt); } await CopilotService.SendMessageAsync(message); @@ -907,11 +2071,15 @@ else CopilotService.AddAssistantMessage($"Error: {ex.Message}"); isBusy = false; StateHasChanged(); + await ScrollToBottomIfNeeded(); } } private async Task HandleKeyDown(KeyboardEventArgs e) { + // Prevent default only for Enter without Shift (submit on Enter, newline on Shift+Enter) + shouldPreventDefault = e.Key == "Enter" && !e.ShiftKey; + if (e.Key == "Enter" && !e.ShiftKey) { await SendMessage(); @@ -922,7 +2090,11 @@ else { if (string.IsNullOrEmpty(delta)) return; currentResponse += delta; - InvokeAsync(StateHasChanged); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); } private void OnAssistantMessage(string message) @@ -932,7 +2104,11 @@ else { currentResponse = message; } - InvokeAsync(StateHasChanged); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); } private void OnSessionIdle() @@ -944,7 +2120,12 @@ else } isBusy = false; currentTool = ""; - InvokeAsync(StateHasChanged); + currentReasoningId = null; + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); } private void OnCopilotError(string error) @@ -952,18 +2133,382 @@ else CopilotService.AddAssistantMessage($"Error: {error}"); isBusy = false; currentTool = ""; - InvokeAsync(StateHasChanged); + currentReasoningId = null; + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); } - private void OnToolStart(string toolName, string args) + private void OnToolStart(string toolName, string toolCallId) { currentTool = toolName; - InvokeAsync(StateHasChanged); + currentToolCallId = toolCallId; + _logger.LogDebug($"UI: OnToolStart - {toolName}, callId={toolCallId}"); + CopilotService.AddToolMessage(toolName, toolCallId); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); } - private void OnToolComplete(string toolName, string result) + private void OnToolComplete(string toolCallId, string result) { + _logger.LogDebug($"UI: OnToolComplete - callId={toolCallId}, currentTool={currentTool}, currentCallId={currentToolCallId}, resultLen={result?.Length ?? 0}"); + // Use stored callId/name if event doesn't have it + var completedCallId = !string.IsNullOrEmpty(toolCallId) ? toolCallId : currentToolCallId; currentTool = ""; + currentToolCallId = ""; + CopilotService.CompleteToolMessage(null, completedCallId, true, result ?? "(no result)"); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); + } + + private void OnReasoningStart(string reasoningId) + { + // Complete any previous reasoning + if (currentReasoningId != null) + { + CopilotService.CompleteReasoningMessage(currentReasoningId); + } + currentReasoningId = reasoningId; + CopilotService.AddReasoningMessage(reasoningId); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); + } + + private void OnReasoningDelta(string reasoningId, string content) + { + if (!string.IsNullOrEmpty(content)) + { + CopilotService.UpdateReasoningMessage(reasoningId, content); + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + // Also scroll the reasoning content itself to the bottom + try + { + await JSRuntime.InvokeVoidAsync("copilotChat.scrollReasoningToBottom"); + } + catch { /* Ignore JS errors */ } + }); + } + } + + private void OnTurnStart() + { + // Complete any pending reasoning from previous turn + if (currentReasoningId != null) + { + CopilotService.CompleteReasoningMessage(currentReasoningId); + currentReasoningId = null; + } + InvokeAsync(StateHasChanged); + } + + private void OnTurnEnd() + { + // Complete reasoning when turn ends + if (currentReasoningId != null) + { + CopilotService.CompleteReasoningMessage(currentReasoningId); + currentReasoningId = null; + } + // Clear intent when turn ends + currentIntent = ""; + InvokeAsync(StateHasChanged); + } + + private void OnIntentChanged(string intent) + { + currentIntent = intent; + _logger.LogDebug($"UI: Intent changed to: {intent}"); + InvokeAsync(StateHasChanged); + } + + private void OnUsageInfoChanged(CopilotUsageInfo usageInfo) + { + _logger.LogDebug($"UI: UsageInfo received - Model={usageInfo.Model}, Tokens={usageInfo.CurrentTokens}/{usageInfo.TokenLimit}, In={usageInfo.InputTokens}, Out={usageInfo.OutputTokens}"); + + // Merge with existing info (some events only have partial data) + if (currentUsageInfo == null) + { + currentUsageInfo = usageInfo; + } + else + { + currentUsageInfo = new CopilotUsageInfo( + usageInfo.Model ?? currentUsageInfo.Model, + usageInfo.CurrentTokens ?? currentUsageInfo.CurrentTokens, + usageInfo.TokenLimit ?? currentUsageInfo.TokenLimit, + usageInfo.InputTokens ?? currentUsageInfo.InputTokens, + usageInfo.OutputTokens ?? currentUsageInfo.OutputTokens + ); + } + + _logger.LogDebug($"UI: UsageInfo merged - Model={currentUsageInfo.Model}, Tokens={currentUsageInfo.CurrentTokens}/{currentUsageInfo.TokenLimit}"); InvokeAsync(StateHasChanged); } + + private void OnSessionError(CopilotSessionError error) + { + _logger.LogError($"Session error: {error.Message} (Code: {error.Code})"); + + // Add error message to chat + var errorMsg = new CopilotChatMessage( + error.Message + (error.Details != null ? $"\n\nDetails: {error.Details}" : ""), + false, + CopilotMessageType.Error, + toolName: error.Code + ); + CopilotService.AddErrorMessage(errorMsg); + + InvokeAsync(async () => + { + StateHasChanged(); + await ScrollToBottomIfNeeded(); + }); + } + + // Permission handling + private TaskCompletionSource? _permissionTcs; + private ToolPermissionRequest? _pendingPermissionRequest; + private bool _showPermissionDialog = false; + + private async Task HandlePermissionRequest(ToolPermissionRequest request) + { + // Auto-approve read-only tools + if (request.IsReadOnly) + { + return new ToolPermissionResult(true); + } + + // Show dialog for destructive tools + _pendingPermissionRequest = request; + _permissionTcs = new TaskCompletionSource(); + _showPermissionDialog = true; + await InvokeAsync(StateHasChanged); + + return await _permissionTcs.Task; + } + + private void AllowPermission() + { + _logger.LogDebug($"AllowPermission called, _permissionTcs is {(_permissionTcs == null ? "null" : "set")}"); + _showPermissionDialog = false; + var setResult = _permissionTcs?.TrySetResult(new ToolPermissionResult(true)); + _logger.LogDebug($"AllowPermission: TrySetResult returned {setResult}"); + _pendingPermissionRequest = null; + StateHasChanged(); + } + + private void DenyPermission() + { + _logger.LogDebug($"DenyPermission called, _permissionTcs is {(_permissionTcs == null ? "null" : "set")}"); + _showPermissionDialog = false; + var setResult = _permissionTcs?.TrySetResult(new ToolPermissionResult(false, "User denied permission")); + _logger.LogDebug($"DenyPermission: TrySetResult returned {setResult}"); + _pendingPermissionRequest = null; + StateHasChanged(); + } + + // UI helper methods + private void ToggleMessageCollapse(CopilotChatMessage msg) + { + msg.IsCollapsed = !msg.IsCollapsed; + StateHasChanged(); + } + + private bool IsInternalTool(string? toolName) + { + if (string.IsNullOrEmpty(toolName)) return false; + // These tools are internal/noisy and shouldn't be shown in the UI + return toolName switch + { + "report_intent" => true, + "skill" => true, + "update_todo" => true, // TODO tracking tool + _ => false + }; + } + + private string RenderMarkdown(string markdown) + { + if (string.IsNullOrEmpty(markdown)) return ""; + try + { + return Markdown.ToHtml(markdown, MarkdownPipeline); + } + catch + { + return System.Web.HttpUtility.HtmlEncode(markdown); + } + } + + private string TruncateResult(string result, int maxLength = 1000) + { + if (string.IsNullOrEmpty(result)) return ""; + if (result.Length <= maxLength) return result; + return result.Substring(0, maxLength) + "\n... (truncated)"; + } + + private bool IsUnusableToolResult(string? content) + { + if (string.IsNullOrEmpty(content)) return true; + // Filter out SDK type names that aren't useful to display + if (content.StartsWith("GitHub.Copilot.SDK.")) return true; + if (content == "(no result)") return true; + return false; + } + + private string FormatToolName(string toolName) + { + if (string.IsNullOrEmpty(toolName)) return ""; + // Convert snake_case to Title Case + return string.Join(" ", toolName.Split('_').Select(w => + char.ToUpperInvariant(w[0]) + w.Substring(1).ToLowerInvariant())); + } + + private string GetToolIcon(string toolName) + { + // Note: Brand icons (apple, android) need "fab" class, others use "fas" + return toolName switch + { + var t when t.Contains("apple") || t.Contains("certificate") || t.Contains("profile") => "fa-apple", + var t when t.Contains("android") || t.Contains("emulator") || t.Contains("avd") => "fa-android", + var t when t.Contains("bundle") || t.Contains("package") => "fa-box", + var t when t.Contains("device") => "fa-mobile-alt", + var t when t.Contains("identity") => "fa-id-card", + var t when t.Contains("list") => "fa-list", + var t when t.Contains("create") || t.Contains("install") => "fa-plus-circle", + var t when t.Contains("delete") || t.Contains("uninstall") => "fa-trash", + var t when t.Contains("download") => "fa-download", + var t when t.Contains("sdk") || t.Contains("path") => "fa-folder-open", + _ => "fa-cog" + }; + } + + private string GetToolIconClass(string toolName) + { + // Brand icons need "fab" class, others use "fas" + var isBrandIcon = toolName.Contains("apple") || toolName.Contains("android") + || toolName.Contains("certificate") || toolName.Contains("profile") + || toolName.Contains("emulator") || toolName.Contains("avd"); + return isBrandIcon ? "fab" : "fas"; + } + + // Auto-scroll functionality + private async Task ScrollToBottomIfNeeded() + { + if (isAtBottom) + { + try + { + await JSRuntime.InvokeVoidAsync("copilotChat.scrollToBottom", chatMessagesRef); + } + catch { /* Ignore JS interop errors during disposal */ } + } + } + + private async Task ForceScrollToBottom() + { + try + { + await JSRuntime.InvokeVoidAsync("copilotChat.scrollToBottom", chatMessagesRef); + isAtBottom = true; + } + catch { /* Ignore JS interop errors during disposal */ } + } + + private async Task CheckScrollPosition() + { + try + { + isAtBottom = await JSRuntime.InvokeAsync("copilotChat.isAtBottom", chatMessagesRef); + } + catch { /* Ignore JS interop errors */ } + } + + private async Task OnChatScroll() + { + await CheckScrollPosition(); + } + + // Debug overlay methods + private void OnLogAdded() + { + InvokeAsync(async () => + { + StateHasChanged(); + if (showDebugOverlay) + { + await Task.Delay(10); + try + { + await JSRuntime.InvokeVoidAsync("copilotChat.scrollDebugToBottom", debugContentRef); + } + catch { } + } + }); + } + + private IEnumerable GetFilteredLogs() + { + var logs = _logger.GetRecentLogs(); + if (string.IsNullOrWhiteSpace(debugFilter)) + return logs; + + var filter = debugFilter.Trim(); + return logs.Where(l => + l.Message.Contains(filter, StringComparison.OrdinalIgnoreCase) || + l.Level.Contains(filter, StringComparison.OrdinalIgnoreCase)); + } + + private string GetLogLevelClass(string level) => level switch + { + "DBG" => "log-dbg", + "INF" => "log-inf", + "WRN" => "log-wrn", + "ERR" => "log-err", + _ => "" + }; + + private void ToggleDebugExpanded() + { + debugOverlayExpanded = !debugOverlayExpanded; + } + + private void ClearDebugLogs() + { + _logger.ClearLogs(); + StateHasChanged(); + } + + private async Task CopyFilteredLogs() + { + var logs = GetFilteredLogs(); + var text = string.Join("\n", logs.Select(l => $"[{l.Timestamp:HH:mm:ss.fff}] [{l.Level}] {l.Message}")); + + try + { + var success = await JSRuntime.InvokeAsync("copilotChat.copyToClipboard", text); + if (success) + { + await AlertService.ShowToastAsync("Logs copied to clipboard"); + } + } + catch (Exception ex) + { + _logger.LogError("Failed to copy logs", ex); + } + } } diff --git a/src/MauiSherpa/Pages/Dashboard.razor b/src/MauiSherpa/Pages/Dashboard.razor index 49ae74e7..30dcffad 100644 --- a/src/MauiSherpa/Pages/Dashboard.razor +++ b/src/MauiSherpa/Pages/Dashboard.razor @@ -1,6 +1,8 @@ @page "/" @using MauiSherpa.Core.ViewModels +@using MauiSherpa.Core.Interfaces @inject DashboardViewModel ViewModel +@inject ICopilotContextService CopilotContext

@ViewModel.Title

@ViewModel.WelcomeMessage

@@ -14,10 +16,10 @@ Settings - +
@@ -182,3 +184,10 @@ opacity: 0.7; } + +@code { + private void OpenCopilot() + { + CopilotContext.OpenOverlay(); + } +} diff --git a/src/MauiSherpa/Pages/Doctor.razor b/src/MauiSherpa/Pages/Doctor.razor index 70bed39f..095a4699 100644 --- a/src/MauiSherpa/Pages/Doctor.razor +++ b/src/MauiSherpa/Pages/Doctor.razor @@ -1,5 +1,6 @@ @page "/doctor" @using MauiSherpa.Core.Interfaces +@using MauiSherpa.Core.Services @using MauiSherpa.Services @using Microsoft.Extensions.Logging @inject IDoctorService DoctorService @@ -10,7 +11,7 @@ @inject INavigationService NavigationService @inject MultiOperationModalService MultiOpModal @inject IFileSystemService FileSystem -@inject ICopilotService CopilotService +@inject ICopilotContextService CopilotContext @inject ILogger Logger @implements IDisposable @@ -42,62 +43,6 @@ }
-@if (showCopilotDialog) -{ - -} - @if (!string.IsNullOrEmpty(errorMessage)) {
@errorMessage
@@ -774,39 +719,15 @@ else private bool manifestsExpanded = false; private bool depsExpanded = true; - // Copilot dialog state - private bool showCopilotDialog = false; - private bool copilotConnected = false; - private bool copilotBusy = false; - private bool copilotComplete = false; - private string copilotStatus = ""; - private List copilotOutput = new(); - private ElementReference copilotOutputRef; - - private record OutputLine(string Text, string Type); - protected override async Task OnInitializedAsync() { - // Subscribe to Copilot events - CopilotService.OnAssistantDelta += OnAssistantDelta; - CopilotService.OnAssistantMessage += OnAssistantMessage; - CopilotService.OnSessionIdle += OnSessionIdle; - CopilotService.OnError += OnCopilotError; - CopilotService.OnToolStart += OnToolStart; - CopilotService.OnToolComplete += OnToolComplete; - // Auto-run doctor on page load await RunDoctor(); } public void Dispose() { - CopilotService.OnAssistantDelta -= OnAssistantDelta; - CopilotService.OnAssistantMessage -= OnAssistantMessage; - CopilotService.OnSessionIdle -= OnSessionIdle; - CopilotService.OnError -= OnCopilotError; - CopilotService.OnToolStart -= OnToolStart; - CopilotService.OnToolComplete -= OnToolComplete; + // No cleanup needed } private async Task RunDoctor() @@ -1154,64 +1075,33 @@ else await AlertService.ShowToastAsync("Copied to clipboard"); } - // Copilot Methods - private async Task FixWithCopilot() + // Copilot Method - uses global overlay + private void FixWithCopilot() { - showCopilotDialog = true; - copilotOutput.Clear(); - copilotConnected = false; - copilotBusy = true; - copilotComplete = false; - copilotStatus = "Connecting to Copilot..."; - StateHasChanged(); - - try + // Build context from current report + var issues = new List<(string Category, string Message, bool IsError)>(); + + if (report != null) { - // Connect to Copilot if not already connected - if (!CopilotService.IsConnected) + foreach (var dep in report.Dependencies) { - AddOutput("Connecting to GitHub Copilot CLI...", "system"); - await CopilotService.ConnectAsync(); + if (dep.Status == DependencyStatusType.Error) + { + issues.Add((dep.Name, dep.Message ?? "Unknown error", true)); + } + else if (dep.Status == DependencyStatusType.Warning) + { + issues.Add((dep.Name, dep.Message ?? "Unknown warning", false)); + } } - - copilotStatus = "Starting session..."; - AddOutput("Starting Copilot session...", "system"); - StateHasChanged(); - - // Start a new session - await CopilotService.StartSessionAsync(); - copilotConnected = true; - - copilotStatus = "Analyzing environment..."; - AddOutput("Sending environment analysis request...", "system"); - StateHasChanged(); - - // Load and send the prompt - var prompt = LoadMauiDoctorPrompt(); - await CopilotService.SendMessageAsync(prompt); } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to start Copilot fix"); - AddOutput($"Error: {ex.Message}", "error"); - copilotBusy = false; - copilotStatus = "Failed to connect"; - await InvokeAsync(StateHasChanged); - } - } - - private string LoadMauiDoctorPrompt() - { - // Load the embedded prompt resource - var assembly = typeof(MauiSherpa.Core.Services.CopilotService).Assembly; - var resourceName = "MauiSherpa.Core.Prompts.maui-doctor.md"; - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream == null) + // If no issues from report, add a generic request + if (issues.Count == 0) { - Logger.LogWarning("Could not find embedded prompt resource: {ResourceName}", resourceName); - // Fallback to a default prompt - return @"Please check my .NET MAUI development environment and help me fix any issues. + var context = new CopilotContext( + Title: "Environment Check", + Message: @"Please check my .NET MAUI development environment and help me fix any issues. Check: 1. .NET SDK installation @@ -1220,104 +1110,15 @@ Check: 4. Android SDK 5. Xcode (if on macOS) -Ask before making any changes to my system."; - } - - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } - - private void OnAssistantDelta(string delta) - { - if (string.IsNullOrEmpty(delta)) return; - - // Append to last assistant line or create new one - var lastLine = copilotOutput.LastOrDefault(); - if (lastLine?.Type == "assistant") - { - copilotOutput.RemoveAt(copilotOutput.Count - 1); - copilotOutput.Add(new OutputLine(lastLine.Text + delta, "assistant")); +Ask before making any changes to my system.", + Type: CopilotContextType.EnvironmentFix + ); + CopilotContext.OpenWithContext(context); } else { - copilotOutput.Add(new OutputLine(delta, "assistant")); - } - InvokeAsync(StateHasChanged); - } - - private void OnAssistantMessage(string message) - { - if (!string.IsNullOrEmpty(message)) - { - // Replace last delta line with complete message - if (copilotOutput.Count > 0 && copilotOutput[^1].Type == "assistant") - { - copilotOutput.RemoveAt(copilotOutput.Count - 1); - } - AddOutput(message, "assistant"); + var context = CopilotContextService.BuildEnvironmentFixContext(issues); + CopilotContext.OpenWithContext(context); } - InvokeAsync(StateHasChanged); - } - - private void OnSessionIdle() - { - copilotBusy = false; - copilotComplete = true; - copilotStatus = "Complete"; - AddOutput("\n--- Analysis complete ---", "system"); - InvokeAsync(StateHasChanged); - } - - private void OnCopilotError(string error) - { - AddOutput($"Error: {error}", "error"); - copilotBusy = false; - copilotStatus = "Error occurred"; - InvokeAsync(StateHasChanged); - } - - private void OnToolStart(string toolName, string args) - { - copilotStatus = $"Running: {toolName}"; - AddOutput($"[Tool: {toolName}]", "tool"); - InvokeAsync(StateHasChanged); - } - - private void OnToolComplete(string toolName, string result) - { - copilotStatus = "Processing..."; - InvokeAsync(StateHasChanged); - } - - private void AddOutput(string text, string type) - { - copilotOutput.Add(new OutputLine(text, type)); - } - - private async Task AbortCopilot() - { - try - { - AddOutput("Aborting...", "system"); - await CopilotService.AbortAsync(); - copilotBusy = false; - copilotStatus = "Aborted"; - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to abort Copilot"); - } - } - - private void CloseCopilotDialog() - { - if (copilotBusy) return; - showCopilotDialog = false; - } - - private async Task RunDoctorAfterCopilot() - { - showCopilotDialog = false; - await RunDoctor(); } } diff --git a/src/MauiSherpa/Pages/RootCertificates.razor b/src/MauiSherpa/Pages/RootCertificates.razor index 0ab08a12..af21fc23 100644 --- a/src/MauiSherpa/Pages/RootCertificates.razor +++ b/src/MauiSherpa/Pages/RootCertificates.razor @@ -34,15 +34,26 @@ else
} +
+ + +
+
-
- - +
+ @if (activeTab == "root") + { + Root Certificates + } + else + { + Intermediate Certificates + }