diff --git a/src/common/ProcessExecution/PersistentShell/PersistentShellProcess.cs b/src/common/ProcessExecution/PersistentShell/PersistentShellProcess.cs index 27e57c82..b0818351 100644 --- a/src/common/ProcessExecution/PersistentShell/PersistentShellProcess.cs +++ b/src/common/ProcessExecution/PersistentShell/PersistentShellProcess.cs @@ -273,7 +273,7 @@ private async Task RunCommandInternalAsync(string isSyntaxError ); } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { var duration = DateTime.Now - startTime; var looksLikeTimeout = timeoutMs.HasValue && Math.Abs(duration.TotalMilliseconds - timeoutMs.Value) < 100; @@ -467,7 +467,7 @@ protected virtual async Task WaitForMarkerAsync(Cancellat Logger.Warning($"🔓 WaitForMarkerAsync: OUTSIDE LOCK - About to check regex - Thread={Thread.CurrentThread.ManagedThreadId}"); - if (regex.IsMatch(currentOutput)) + if (regex.IsMatch(currentOutput!)) { Logger.Warning($"✅ WaitForMarkerAsync: MARKER FOUND! About to read error/merged outputs OUTSIDE LOCK - Thread={Thread.CurrentThread.ManagedThreadId}, Time={DateTime.Now:HH:mm:ss.fff}"); @@ -478,13 +478,13 @@ protected virtual async Task WaitForMarkerAsync(Cancellat Logger.Warning($"🔓 WaitForMarkerAsync: GetCurrentMergedOutput() returned length={merged?.Length ?? 0} - Thread={Thread.CurrentThread.ManagedThreadId}"); // Compare with what we captured earlier - Logger.Warning($"📊 WaitForMarkerAsync: COMPARISON - currentOutput.Length={currentOutput.Length}, merged.Length={merged?.Length ?? 0}, match={currentOutput.Length == (merged?.Length ?? 0)}"); + Logger.Warning($"📊 WaitForMarkerAsync: COMPARISON - currentOutput.Length={currentOutput!.Length}, merged.Length={merged?.Length ?? 0}, match={currentOutput.Length == (merged?.Length ?? 0)}"); // Marker found, return the current output return new RunnableProcessResult( currentOutput, - error, - merged, + error ?? string.Empty, + merged ?? string.Empty, 0, // Actual exit code will be parsed later ProcessCompletionState.Completed, DateTime.Now - startTime diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index c148ad02..1480bde6 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -191,11 +191,11 @@ public override async Task ExecuteAsync(bool interactive) break; } - var (skipAssistant, replaceUserPrompt) = await TryHandleChatCommandAsync(chat, userPrompt); + var (skipAssistant, replaceUserPrompt) = await TryHandleChatCommandAsync(chat, userPrompt!); if (skipAssistant) continue; // Some chat commands don't require a response from the assistant. var shouldReplaceUserPrompt = !string.IsNullOrEmpty(replaceUserPrompt); - if (shouldReplaceUserPrompt) DisplayPromptReplacement(userPrompt, replaceUserPrompt); + if (shouldReplaceUserPrompt) DisplayPromptReplacement(userPrompt!, replaceUserPrompt); var giveAssistant = shouldReplaceUserPrompt ? replaceUserPrompt! : userPrompt; @@ -210,7 +210,7 @@ public override async Task ExecuteAsync(bool interactive) var imageFiles = ImagePatterns.Any() ? ImageResolver.ResolveImagePatterns(ImagePatterns) : new List(); ImagePatterns.Clear(); - var response = await CompleteChatStreamingAsync(chat, giveAssistant, imageFiles, + var response = await CompleteChatStreamingAsync(chat, giveAssistant!, imageFiles, (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), (name, args) => HandleFunctionCallApproval(factory, name, args!), diff --git a/src/cycod/FunctionCallingTools/ScreenshotHelperFunctions.cs b/src/cycod/FunctionCallingTools/ScreenshotHelperFunctions.cs index 154aed4f..e48c8031 100644 --- a/src/cycod/FunctionCallingTools/ScreenshotHelperFunctions.cs +++ b/src/cycod/FunctionCallingTools/ScreenshotHelperFunctions.cs @@ -12,12 +12,12 @@ public ScreenshotHelperFunctions(ChatCommand chatCommand) _chatCommand = chatCommand; } -#if WINDOWS - [Description("Take a screenshot of the primary screen and add it to the conversation. The screenshot will be included in the next message exchange. Only works on Windows.")] +#if WINDOWS || OSX + [Description("Take a screenshot of the primary screen and add it to the conversation. The screenshot will be included in the next message exchange. Works on Windows and macOS.")] public object TakeScreenshot() { // Check platform support - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return ScreenshotHelper.GetPlatformErrorMessage(); } @@ -27,7 +27,13 @@ public object TakeScreenshot() // Capture screenshot var filePath = ScreenshotHelper.TakeScreenshot(); var fileExists = FileHelpers.FileExists(filePath); - if (!fileExists) return "Failed to capture screenshot. Please check that the display is accessible."; + if (!fileExists) + { + // Use macOS-specific error message if on macOS, otherwise generic message + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? ScreenshotHelper.GetMacOSPermissionErrorMessage() + : "Failed to capture screenshot. Please check that the display is accessible."; + } // Load the screenshot and return as DataContent for immediate inclusion try @@ -49,5 +55,147 @@ public object TakeScreenshot() } #endif +#if OSX + [Description("Take a screenshot of a window with matching title (partial match, case-insensitive). Returns the screenshot file path or an error message. Example: title='cycod' or title='Microsoft Edge'.")] + public object TakeScreenshotOfWindowWithTitle(string title) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window-specific screenshots are only available on macOS."; + } + + try + { + var result = ScreenshotHelper.TakeScreenshotOfWindowWithTitle(title); + + // If result is an existing file path, load and return as DataContent + if (!string.IsNullOrEmpty(result) && File.Exists(result)) + { + var imageBytes = File.ReadAllBytes(result); + var mediaType = ImageResolver.GetMediaTypeFromFileExtension(result); + return new DataContent(imageBytes, mediaType); + } + + // Otherwise it's an error message + return result; + } + catch (Exception ex) + { + return $"Error capturing screenshot by title: {ex.Message}"; + } + } + + [Description("Take a screenshot of a window from the specified application (partial match, case-insensitive). Returns the screenshot file path or an error message. Example: appName='Warp' or appName='Code'.")] + public object TakeScreenshotOfApp(string appName) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window-specific screenshots are only available on macOS."; + } + + try + { + var result = ScreenshotHelper.TakeScreenshotOfApp(appName); + + // If result starts with '/', it's a file path - load and return as DataContent + if (result.StartsWith('/') || result.StartsWith(Path.GetTempPath())) + { + var imageBytes = File.ReadAllBytes(result); + var mediaType = ImageResolver.GetMediaTypeFromFileExtension(result); + return new DataContent(imageBytes, mediaType); + } + + // Otherwise it's an error message + return result; + } + catch (Exception ex) + { + return $"Error capturing screenshot by app: {ex.Message}"; + } + } + + [Description("Take a screenshot of a specific display. displayNumber: 1 for main display, 2 for secondary, etc. Returns the screenshot or an error message.")] + public object TakeScreenshotOfDisplay(int displayNumber) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Display-specific screenshots are only available on macOS."; + } + + try + { + var filePath = ScreenshotHelper.TakeScreenshotOfDisplay(displayNumber); + if (filePath == null) + { + return $"Failed to capture screenshot of display {displayNumber}. Please check that the display exists."; + } + + var imageBytes = File.ReadAllBytes(filePath); + var mediaType = ImageResolver.GetMediaTypeFromFileExtension(filePath); + return new DataContent(imageBytes, mediaType); + } + catch (Exception ex) + { + return $"Error capturing screenshot of display {displayNumber}: {ex.Message}"; + } + } + + [Description("List all visible application windows with their metadata (window ID, app name, title, position, size). Returns a JSON array of window information. Useful for finding windows before capturing them.")] + public object ListWindows() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window enumeration is only available on macOS."; + } + + try + { + var windows = ScreenshotHelper.EnumerateWindows(); + + if (windows.Count == 0) + { + return "No application windows found."; + } + + var json = System.Text.Json.JsonSerializer.Serialize(windows, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + + return json; + } + catch (Exception ex) + { + return $"Error listing windows: {ex.Message}"; + } + } + + [Description("Take a screenshot of a specific window by ID. Use ListWindows() first to get window IDs. Returns the screenshot or an error message. This is an advanced method - prefer TakeScreenshotOfWindowWithTitle or TakeScreenshotOfApp for simpler use.")] + public object TakeScreenshotOfWindow(int windowId) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window-specific screenshots are only available on macOS."; + } + + try + { + var filePath = ScreenshotHelper.TakeScreenshotOfWindow(windowId); + if (filePath == null) + { + return $"Failed to capture screenshot of window {windowId}. Please check that the window ID is valid."; + } + + var imageBytes = File.ReadAllBytes(filePath); + var mediaType = ImageResolver.GetMediaTypeFromFileExtension(filePath); + return new DataContent(imageBytes, mediaType); + } + catch (Exception ex) + { + return $"Error capturing screenshot of window {windowId}: {ex.Message}"; + } + } +#endif + private readonly ChatCommand _chatCommand; } diff --git a/src/cycod/Helpers/ScreenshotHelper.cs b/src/cycod/Helpers/ScreenshotHelper.cs index 78ce36e1..1bb8a452 100644 --- a/src/cycod/Helpers/ScreenshotHelper.cs +++ b/src/cycod/Helpers/ScreenshotHelper.cs @@ -1,12 +1,13 @@ #if WINDOWS using System.Drawing; +using System.Drawing.Drawing2D; using System.Drawing.Imaging; #endif using System.Runtime.InteropServices; /// -/// Helper class for capturing screenshots on Windows. -/// Provides platform-aware screenshot functionality with graceful degradation on non-Windows platforms. +/// Helper class for capturing screenshots on Windows and macOS. +/// Provides platform-aware screenshot functionality with graceful degradation on unsupported platforms. /// public static class ScreenshotHelper { @@ -45,7 +46,64 @@ public static class ScreenshotHelper var fileName = Path.Combine(Path.GetTempPath(), $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss-fff}.png"); bitmap.Save(fileName, ImageFormat.Png); - return fileName; + // Resize to keep file size manageable + return ResizeImageIfNeeded(fileName); +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to capture screenshot: {ex.Message}"); + return null; + } +#elif OSX + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return null; + } + + try + { +#pragma warning disable CA1416 // Validate platform compatibility + // Create output file path + var fileName = Path.Combine( + Path.GetTempPath(), + $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss-fff}.png"); + + // Use the screencapture command-line tool (no permissions required from terminal) + var processInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/sbin/screencapture", + Arguments = $"-x \"{fileName}\"", // -x = no camera sound + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(processInfo); + if (process == null) + { + Logger.Error("Failed to start screencapture process"); + return null; + } + + process.WaitForExit(5000); // Wait up to 5 seconds + + if (process.ExitCode != 0) + { + var error = process.StandardError.ReadToEnd(); + Logger.Error($"screencapture failed with exit code {process.ExitCode}: {error}"); + return null; + } + + // Verify the file was created + if (!File.Exists(fileName)) + { + Logger.Error("Screenshot file was not created"); + return null; + } + + return ResizeImageIfNeeded(fileName); #pragma warning restore CA1416 // Validate platform compatibility } catch (Exception ex) @@ -58,8 +116,544 @@ public static class ScreenshotHelper #endif } +#if OSX + /// + /// Captures a screenshot of a window with the specified title (partial match, case-insensitive). + /// + /// Window title to search for (e.g., "cycod", "Microsoft Edge") + /// Path to screenshot file, or error message if window not found or capture failed + public static string TakeScreenshotOfWindowWithTitle(string title) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window-specific screenshots are only available on macOS."; + } + + var windows = FindWindowsByTitle(title); + + if (windows.Count == 0) + { + return $"No windows found with title containing '{title}'."; + } + + if (windows.Count > 1) + { + var matches = string.Join("\n", windows.Select(w => + $" - Window {w.WindowId}: {w.ApplicationName} - {w.WindowTitle}")); + return $"Multiple windows found with title containing '{title}':\n{matches}\n\nPlease be more specific or use TakeScreenshotOfWindow(windowId)."; + } + + var result = TakeScreenshotOfWindow(windows[0].WindowId); + return result ?? $"Failed to capture screenshot of window: {windows[0]}"; + } + + /// + /// Captures a screenshot of a window from the specified application. + /// + /// Application name (e.g., "Warp", "Code", "Microsoft Edge") + /// Path to screenshot file, or error message if app window not found or capture failed + public static string TakeScreenshotOfApp(string appName) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Window-specific screenshots are only available on macOS."; + } + + var windows = FindWindowsByApp(appName); + + if (windows.Count == 0) + { + return $"No windows found for application '{appName}'."; + } + + if (windows.Count > 1) + { + var matches = string.Join("\n", windows.Select(w => + $" - Window {w.WindowId}: {w.ApplicationName} - {w.WindowTitle}")); + return $"Multiple windows found for application '{appName}':\n{matches}\n\nPlease be more specific or use TakeScreenshotOfWindow(windowId)."; + } + + var result = TakeScreenshotOfWindow(windows[0].WindowId); + return result ?? $"Failed to capture screenshot of window: {windows[0]}"; + } + + /// + /// Captures a screenshot of a specific display. + /// + /// Display number (1 = main, 2 = secondary, etc.) + /// Path to screenshot file, or null if capture failed + public static string? TakeScreenshotOfDisplay(int displayNumber) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return null; + } + + try + { +#pragma warning disable CA1416 // Validate platform compatibility + var fileName = Path.Combine( + Path.GetTempPath(), + $"screenshot-display{displayNumber}-{DateTime.Now:yyyyMMdd-HHmmss-fff}.png"); + + var processInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/sbin/screencapture", + Arguments = $"-x -D {displayNumber} \"{fileName}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(processInfo); + if (process == null) + { + Logger.Error("Failed to start screencapture process"); + return null; + } + + process.WaitForExit(5000); + + if (process.ExitCode != 0) + { + var error = process.StandardError.ReadToEnd(); + Logger.Error($"screencapture failed with exit code {process.ExitCode}: {error}"); + return null; + } + + if (!File.Exists(fileName)) + { + Logger.Error("Screenshot file was not created"); + return null; + } + + return ResizeImageIfNeeded(fileName); +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to capture screenshot of display {displayNumber}: {ex.Message}"); + return null; + } + } + + /// + /// Captures a screenshot of a specific window by ID. + /// Use EnumerateWindows() to get window IDs. + /// + /// Window ID to capture + /// Path to screenshot file, or null if capture failed + public static string? TakeScreenshotOfWindow(int windowId) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return null; + } + + try + { +#pragma warning disable CA1416 // Validate platform compatibility + var fileName = Path.Combine( + Path.GetTempPath(), + $"screenshot-window{windowId}-{DateTime.Now:yyyyMMdd-HHmmss-fff}.png"); + + var processInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/sbin/screencapture", + Arguments = $"-x -l {windowId} \"{fileName}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(processInfo); + if (process == null) + { + Logger.Error("Failed to start screencapture process"); + return null; + } + + process.WaitForExit(5000); + + if (process.ExitCode != 0) + { + var error = process.StandardError.ReadToEnd(); + Logger.Error($"screencapture failed with exit code {process.ExitCode}: {error}"); + return null; + } + + if (!File.Exists(fileName)) + { + Logger.Error("Screenshot file was not created"); + return null; + } + + return ResizeImageIfNeeded(fileName); +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to capture screenshot of window {windowId}: {ex.Message}"); + return null; + } + } + + /// + /// Lists all visible application windows with metadata. + /// + /// List of window information objects + public static List EnumerateWindows() + { + var windows = new List(); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return windows; + } + + try + { +#pragma warning disable CA1416 // Validate platform compatibility + var windowListPtr = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID); + + if (windowListPtr == IntPtr.Zero) + { + Logger.Error("Failed to get window list - CGWindowListCopyWindowInfo returned null"); + return windows; + } + + var count = CFArrayGetCount(windowListPtr); + + for (var i = 0; i < count; i++) + { + var windowDictPtr = CFArrayGetValueAtIndex(windowListPtr, i); + if (windowDictPtr == IntPtr.Zero) continue; + + var windowInfo = ParseWindowInfo(windowDictPtr); + if (windowInfo != null) + { + // Filter to normal application windows (layer 0) with titles + if (windowInfo.Layer == 0 && !string.IsNullOrWhiteSpace(windowInfo.WindowTitle)) + { + windows.Add(windowInfo); + } + } + } + + CFRelease(windowListPtr); +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to enumerate windows: {ex.Message}"); + } + + return windows; + } + + /// + /// Finds windows by application name (partial match, case-insensitive). + /// + /// Application name to search for + /// List of matching windows + public static List FindWindowsByApp(string appName) + { + var allWindows = EnumerateWindows(); + return allWindows + .Where(w => w.ApplicationName.Contains(appName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Finds windows by title (partial match, case-insensitive). + /// + /// Window title to search for + /// List of matching windows + public static List FindWindowsByTitle(string title) + { + var allWindows = EnumerateWindows(); + return allWindows + .Where(w => !string.IsNullOrWhiteSpace(w.WindowTitle) && + w.WindowTitle.Contains(title, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Parses window information from a Core Foundation dictionary. + /// + private static WindowInfo? ParseWindowInfo(IntPtr windowDictPtr) + { + try + { +#pragma warning disable CA1416 // Validate platform compatibility + var windowId = GetIntValue(windowDictPtr, "kCGWindowNumber"); + var pid = GetIntValue(windowDictPtr, "kCGWindowOwnerPID"); + var ownerName = GetStringValue(windowDictPtr, "kCGWindowOwnerName") ?? ""; + var windowName = GetStringValue(windowDictPtr, "kCGWindowName") ?? ""; + var layer = GetIntValue(windowDictPtr, "kCGWindowLayer"); + var isOnscreen = GetIntValue(windowDictPtr, "kCGWindowIsOnscreen") == 1; + + var boundsDict = GetDictionaryValue(windowDictPtr, "kCGWindowBounds"); + var x = boundsDict != IntPtr.Zero ? (int)GetFloatValue(boundsDict, "X") : 0; + var y = boundsDict != IntPtr.Zero ? (int)GetFloatValue(boundsDict, "Y") : 0; + var width = boundsDict != IntPtr.Zero ? (int)GetFloatValue(boundsDict, "Width") : 0; + var height = boundsDict != IntPtr.Zero ? (int)GetFloatValue(boundsDict, "Height") : 0; + + return new WindowInfo + { + WindowId = windowId, + ProcessId = pid, + ApplicationName = ownerName, + WindowTitle = windowName, + Layer = layer, + IsOnScreen = isOnscreen, + X = x, + Y = y, + Width = width, + Height = height + }; +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to parse window info: {ex.Message}"); + return null; + } + } + + private static int GetIntValue(IntPtr dictPtr, string key) + { +#pragma warning disable CA1416 // Validate platform compatibility + var keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, kCFStringEncodingUTF8); + if (keyPtr == IntPtr.Zero) return 0; + + var valuePtr = CFDictionaryGetValue(dictPtr, keyPtr); + CFRelease(keyPtr); + + if (valuePtr == IntPtr.Zero) return 0; + + int value = 0; + CFNumberGetValue(valuePtr, kCFNumberIntType, ref value); + return value; +#pragma warning restore CA1416 // Validate platform compatibility + } + + private static float GetFloatValue(IntPtr dictPtr, string key) + { +#pragma warning disable CA1416 // Validate platform compatibility + var keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, kCFStringEncodingUTF8); + if (keyPtr == IntPtr.Zero) return 0; + + var valuePtr = CFDictionaryGetValue(dictPtr, keyPtr); + CFRelease(keyPtr); + + if (valuePtr == IntPtr.Zero) return 0; + + float value = 0; + CFNumberGetValue(valuePtr, kCFNumberFloatType, ref value); + return value; +#pragma warning restore CA1416 // Validate platform compatibility + } + + private static string? GetStringValue(IntPtr dictPtr, string key) + { +#pragma warning disable CA1416 // Validate platform compatibility + var keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, kCFStringEncodingUTF8); + if (keyPtr == IntPtr.Zero) return null; + + var valuePtr = CFDictionaryGetValue(dictPtr, keyPtr); + CFRelease(keyPtr); + + if (valuePtr == IntPtr.Zero) return null; + + var length = CFStringGetLength(valuePtr); + if (length == 0) return ""; + + var range = new CFRange { location = 0, length = length }; + var bufferSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + var buffer = new byte[bufferSize]; + + if (CFStringGetBytes(valuePtr, range, kCFStringEncodingUTF8, 0, false, buffer, bufferSize, out var usedBytes)) + { + return System.Text.Encoding.UTF8.GetString(buffer, 0, (int)usedBytes); + } + + return null; +#pragma warning restore CA1416 // Validate platform compatibility + } + + private static IntPtr GetDictionaryValue(IntPtr dictPtr, string key) + { +#pragma warning disable CA1416 // Validate platform compatibility + var keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, kCFStringEncodingUTF8); + if (keyPtr == IntPtr.Zero) return IntPtr.Zero; + + var valuePtr = CFDictionaryGetValue(dictPtr, keyPtr); + CFRelease(keyPtr); + + return valuePtr; +#pragma warning restore CA1416 // Validate platform compatibility + } + + [StructLayout(LayoutKind.Sequential)] + private struct CFRange + { + public long location; + public long length; + } +#endif + + /// + /// Resizes an image if it exceeds the specified maximum dimension. + /// Images are resized proportionally to fit within maxDimension x maxDimension. + /// + /// Path to the image file to resize + /// Maximum width or height in pixels (default: 1200) + /// The image path (same as input), or null if resize failed + private static string? ResizeImageIfNeeded(string imagePath, int maxDimension = 1200) + { + if (!File.Exists(imagePath)) + { + Logger.Error($"Cannot resize image - file not found: {imagePath}"); + return imagePath; + } + +#if WINDOWS + try + { +#pragma warning disable CA1416 // Validate platform compatibility + using var original = new Bitmap(imagePath); + + // Check if resize is needed + if (original.Width <= maxDimension && original.Height <= maxDimension) + { + Logger.Verbose($"Image resize not needed - dimensions: {original.Width}x{original.Height}"); + return imagePath; + } + + // Calculate new dimensions maintaining aspect ratio + var scale = (double)maxDimension / Math.Max(original.Width, original.Height); + var newWidth = (int)(original.Width * scale); + var newHeight = (int)(original.Height * scale); + + Logger.Verbose($"Resizing image from {original.Width}x{original.Height} to {newWidth}x{newHeight}"); + + // Create resized bitmap + using var resized = new Bitmap(newWidth, newHeight); + using var graphics = Graphics.FromImage(resized); + + // Use high quality resizing + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.CompositingQuality = CompositingQuality.HighQuality; + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + graphics.DrawImage(original, 0, 0, newWidth, newHeight); + + // Save over the original file + resized.Save(imagePath, ImageFormat.Png); + + return imagePath; +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to resize image: {ex.Message}"); + return imagePath; // Return original on failure + } +#elif OSX + try + { +#pragma warning disable CA1416 // Validate platform compatibility + // Use sips to get current dimensions + var getDimensionsInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/sips", + Arguments = $"-g pixelWidth -g pixelHeight \"{imagePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var getDims = System.Diagnostics.Process.Start(getDimensionsInfo); + if (getDims == null) + { + Logger.Error("Failed to start sips process to get dimensions"); + return imagePath; + } + + getDims.WaitForExit(5000); + var output = getDims.StandardOutput.ReadToEnd(); + + // Parse dimensions from output like " pixelWidth: 2940\n pixelHeight: 1912" + var widthMatch = System.Text.RegularExpressions.Regex.Match(output, @"pixelWidth:\s*(\d+)"); + var heightMatch = System.Text.RegularExpressions.Regex.Match(output, @"pixelHeight:\s*(\d+)"); + + if (widthMatch.Success && heightMatch.Success) + { + var width = int.Parse(widthMatch.Groups[1].Value); + var height = int.Parse(heightMatch.Groups[1].Value); + + if (width <= maxDimension && height <= maxDimension) + { + Logger.Verbose($"Image resize not needed - dimensions: {width}x{height}"); + return imagePath; + } + + Logger.Verbose($"Resizing image from {width}x{height} using sips"); + } + + // Resize using sips (-Z resizes proportionally to fit within maxDimension) + var resizeInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/sips", + Arguments = $"-Z {maxDimension} \"{imagePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = System.Diagnostics.Process.Start(resizeInfo); + if (process == null) + { + Logger.Error("Failed to start sips process for resize"); + return imagePath; + } + + process.WaitForExit(10000); // Allow up to 10 seconds for resize + + if (process.ExitCode != 0) + { + var error = process.StandardError.ReadToEnd(); + Logger.Error($"sips resize failed with exit code {process.ExitCode}: {error}"); + return imagePath; + } + + Logger.Verbose($"Image resized successfully"); + return imagePath; +#pragma warning restore CA1416 // Validate platform compatibility + } + catch (Exception ex) + { + Logger.Error($"Failed to resize image: {ex.Message}"); + return imagePath; // Return original on failure + } +#else + // On other platforms, return the original image without resizing + Logger.Verbose("Image resizing not supported on this platform"); + return imagePath; +#endif + } + + /// - /// Gets a user-friendly error message for non-Windows platforms. + /// Gets a user-friendly error message for unsupported platforms. /// public static string GetPlatformErrorMessage() { @@ -67,7 +661,20 @@ public static string GetPlatformErrorMessage() RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macOS" : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "Linux" : "Unknown"; - return $"Screenshot functionality is currently only available on Windows. Current platform: {platform}"; + return $"Screenshot functionality is currently only available on Windows and macOS. Current platform: {platform}"; + } + + /// + /// Gets a macOS-specific error message about screenshot failures. + /// + public static string GetMacOSPermissionErrorMessage() + { + return "Failed to capture screenshot.\n\n" + + "This could be due to:\n" + + "- Display settings or permissions issues\n" + + "- The screencapture utility not being available\n" + + "- Insufficient disk space in the temp directory\n\n" + + "Please check the console for more detailed error information."; } #region Windows Interop @@ -81,4 +688,61 @@ public static string GetPlatformErrorMessage() #pragma warning restore CA1416 // Validate platform compatibility #endif #endregion + +#region macOS Interop +#if OSX +#pragma warning disable CA1416 // Validate platform compatibility + // CoreGraphics - Window enumeration + [DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] + private static extern IntPtr CGWindowListCopyWindowInfo(uint options, uint relativeToWindow); + + // CoreFoundation - Array and dictionary handling + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern long CFArrayGetCount(IntPtr theArray); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFArrayGetValueAtIndex(IntPtr theArray, long idx); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern void CFRelease(IntPtr cf); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string str, uint encoding); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern IntPtr CFDictionaryGetValue(IntPtr theDict, IntPtr key); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern bool CFNumberGetValue(IntPtr number, int theType, ref int valuePtr); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern bool CFNumberGetValue(IntPtr number, int theType, ref float valuePtr); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern long CFStringGetLength(IntPtr theString); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern long CFStringGetMaximumSizeForEncoding(long length, uint encoding); + + [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] + private static extern bool CFStringGetBytes( + IntPtr theString, + CFRange range, + uint encoding, + byte lossByte, + bool isExternalRepresentation, + byte[] buffer, + long maxBufLen, + out long usedBufLen); + + // Constants + private const uint kCFStringEncodingUTF8 = 0x08000100; + private const uint kCGWindowListOptionOnScreenOnly = 1; + private const uint kCGWindowListExcludeDesktopElements = 16; + private const uint kCGNullWindowID = 0; + private const int kCFNumberIntType = 9; + private const int kCFNumberFloatType = 4; +#pragma warning restore CA1416 // Validate platform compatibility +#endif +#endregion } diff --git a/src/cycod/Helpers/SpeechHelpers.cs b/src/cycod/Helpers/SpeechHelpers.cs index c138d53d..8026e705 100644 --- a/src/cycod/Helpers/SpeechHelpers.cs +++ b/src/cycod/Helpers/SpeechHelpers.cs @@ -45,7 +45,7 @@ public static SpeechConfig CreateSpeechConfig() public static async Task GetSpeechInputAsync() { Console.Write("\r"); - ConsoleHelpers.Write("\nUser: ", ConsoleColor.Yellow); + ConsoleHelpers.Write("User: ", ConsoleColor.Yellow); Console.ForegroundColor = ConsoleColor.DarkGray; var text = "(listening)"; diff --git a/src/cycod/Helpers/WindowInfo.cs b/src/cycod/Helpers/WindowInfo.cs new file mode 100644 index 00000000..840d39f2 --- /dev/null +++ b/src/cycod/Helpers/WindowInfo.cs @@ -0,0 +1,63 @@ +#if OSX +/// +/// Represents metadata about a window on macOS. +/// +public class WindowInfo +{ + /// + /// Unique window identifier. + /// + public int WindowId { get; set; } + + /// + /// Process ID of the application that owns this window. + /// + public int ProcessId { get; set; } + + /// + /// Name of the application that owns this window (e.g., "Warp", "Code", "Microsoft Edge"). + /// + public string ApplicationName { get; set; } = ""; + + /// + /// Title of the window (may be empty for some windows). + /// + public string WindowTitle { get; set; } = ""; + + /// + /// X coordinate of the window's top-left corner. + /// + public int X { get; set; } + + /// + /// Y coordinate of the window's top-left corner. + /// + public int Y { get; set; } + + /// + /// Width of the window in pixels. + /// + public int Width { get; set; } + + /// + /// Height of the window in pixels. + /// + public int Height { get; set; } + + /// + /// Whether the window is currently visible on screen. + /// + public bool IsOnScreen { get; set; } + + /// + /// Window layer (0 = normal application windows, higher values = UI elements). + /// + public int Layer { get; set; } + + public override string ToString() + { + var title = string.IsNullOrEmpty(WindowTitle) ? "(no title)" : WindowTitle; + return $"Window #{WindowId}: {ApplicationName} - {title} [{Width}x{Height}]"; + } +} +#endif diff --git a/src/cycod/SlashCommands/SlashScreenshotCommandHandler.cs b/src/cycod/SlashCommands/SlashScreenshotCommandHandler.cs index 73dcbdfc..e8253d3b 100644 --- a/src/cycod/SlashCommands/SlashScreenshotCommandHandler.cs +++ b/src/cycod/SlashCommands/SlashScreenshotCommandHandler.cs @@ -33,7 +33,7 @@ public async Task HandleAsync(string userPrompt, FunctionCal ConsoleHelpers.DisplayUserFunctionCall("/screenshot", null); // Check platform support - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { var errorMessage = ScreenshotHelper.GetPlatformErrorMessage(); ConsoleHelpers.DisplayUserFunctionCall("/screenshot", errorMessage); diff --git a/src/cycod/SlashCommands/SlashSpeechCommandHandler.cs b/src/cycod/SlashCommands/SlashSpeechCommandHandler.cs index d037c5a6..b94e1bfc 100644 --- a/src/cycod/SlashCommands/SlashSpeechCommandHandler.cs +++ b/src/cycod/SlashCommands/SlashSpeechCommandHandler.cs @@ -35,7 +35,7 @@ public async Task HandleAsync(string userPrompt, FunctionCal } // Display the recognized text in yellow to show it was from speech - ConsoleHelpers.WriteLine(recognizedText, ConsoleColor.Yellow); + ConsoleHelpers.WriteLine(recognizedText, ConsoleColor.White); // Return the recognized text to be sent to the assistant return SlashCommandResult.WithResponse(recognizedText); diff --git a/src/cycod/cycod.csproj b/src/cycod/cycod.csproj index 26174821..51745e13 100644 --- a/src/cycod/cycod.csproj +++ b/src/cycod/cycod.csproj @@ -26,12 +26,23 @@ true cycod false + + + $(NoWarn);NETSDK1206 $(DefineConstants);WINDOWS + + + $(DefineConstants);OSX + + + + $(DefineConstants);LINUX + diff --git a/tests/cycod/Helpers/LineHelpersTests.cs b/tests/cycod/Helpers/LineHelpersTests.cs index 2681da5f..2eff3a12 100644 --- a/tests/cycod/Helpers/LineHelpersTests.cs +++ b/tests/cycod/Helpers/LineHelpersTests.cs @@ -441,6 +441,7 @@ public void FilterAndExpandContext_WithHighlighting_MarksMatchingLines() content, includePatterns, 1, 1, false, excludePatterns, "```", true); // Assert + Assert.IsNotNull(result); var lines = result.Split('\n'); Assert.AreEqual(3, lines.Length); Assert.IsFalse(lines[0].StartsWith("*"), "Context line should not be marked"); @@ -461,6 +462,7 @@ public void FilterAndExpandContext_WithHighlightingAndLineNumbers_MarksCorrectly content, includePatterns, 1, 1, true, excludePatterns, "```", true); // Assert + Assert.IsNotNull(result); var lines = result.Split('\n'); Assert.AreEqual(3, lines.Length); Assert.IsTrue(lines[0].StartsWith(" 1:"), "Context line should have space prefix"); @@ -501,6 +503,7 @@ public void FilterAndExpandContext_ContiguousMatches_NoBreaks() content, includePatterns, 0, 0, false, excludePatterns, "```", false); // Assert + Assert.IsNotNull(result); Assert.IsFalse(result.Contains("```\n\n```"), "Should not contain separator for contiguous lines"); } @@ -521,6 +524,7 @@ public void FilterAndExpandContext_ExcludePatternInContext_SkipsContextLine() content, includePatterns, 2, 2, false, excludePatterns, "```", false); // Assert + Assert.IsNotNull(result); // Should include: good 2, MATCH, good 3 // Should exclude: bad line (both before and after) var lines = result.Split('\n'); diff --git a/tests/cycod/Tests.csproj b/tests/cycod/Tests.csproj index 13c549e8..2ec6c447 100644 --- a/tests/cycod/Tests.csproj +++ b/tests/cycod/Tests.csproj @@ -7,6 +7,9 @@ false true + + + $(NoWarn);NETSDK1206