From cbc8d2768ad3d6e8bb0165c4800e001803297abf Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 10:31:25 -0800 Subject: [PATCH 01/22] Initial Press [ESC] twice in quick succession (500ms) to interrupt the AI at any point in its response. Currently does not save the conversation, but will. This is a better way to regain control compared to CTRL+C, and has historical backing in Claude Code. --- src/cycod/ChatClient/FunctionCallingChat.cs | 15 +++-- src/cycod/CommandLineCommands/ChatCommand.cs | 71 +++++++++++++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index d40b45fa..0298d4f6 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.AI; +using System.Threading; public class FunctionCallingChat : IAsyncDisposable { @@ -60,7 +61,8 @@ public async Task CompleteChatStreamingAsync( Action>? messageCallback = null, Action? streamingCallback = null, Func? approveFunctionCall = null, - Action? functionCallCallback = null) + Action? functionCallCallback = null, + CancellationToken cancellationToken = default) { return await CompleteChatStreamingAsync( userPrompt, @@ -68,7 +70,8 @@ public async Task CompleteChatStreamingAsync( messageCallback, streamingCallback, approveFunctionCall, - functionCallCallback); + functionCallCallback, + cancellationToken); } public async Task CompleteChatStreamingAsync( @@ -77,7 +80,8 @@ public async Task CompleteChatStreamingAsync( Action>? messageCallback = null, Action? streamingCallback = null, Func? approveFunctionCall = null, - Action? functionCallCallback = null) + Action? functionCallCallback = null, + CancellationToken cancellationToken = default) { var message = CreateUserMessageWithImages(userPrompt, imageFiles); @@ -88,8 +92,11 @@ public async Task CompleteChatStreamingAsync( while (true) { var responseContent = string.Empty; - await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options)) + await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) { + // Check for cancellation before processing each update + cancellationToken.ThrowIfCancellationRequested(); + _functionCallDetector.CheckForFunctionCall(update); var content = string.Join("", update.Contents diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 8af66d64..47425dee 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -188,6 +188,7 @@ public override async Task ExecuteAsync(bool interactive) (name, args) => HandleFunctionCallApproval(factory, name, args!), (name, args, result) => HandleFunctionCallCompleted(name, args, result)); + // Check for notifications that may have been generated during the assistant's response ConsoleHelpers.WriteLine("\n", overrideQuiet: true); if (chat.Notifications.HasPending()) @@ -507,22 +508,41 @@ private async Task CompleteChatStreamingAsync( streamingCallback = TryCatchHelpers.NoThrowWrap(streamingCallback); functionCallCallback = TryCatchHelpers.NoThrowWrap(functionCallCallback); + // Create a new cancellation token source for this streaming session + _interruptTokenSource = new CancellationTokenSource(); + _lastEscKeyTime = null; // Reset ESC tracking + _suppressAssistantDisplay = false; // Reset display suppression + try { var response = await chat.CompleteChatStreamingAsync(userPrompt, imageFiles, (messages) => messageCallback?.Invoke(messages), (update) => streamingCallback?.Invoke(update), (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, - (name, args, result) => functionCallCallback?.Invoke(name, args, result)); + (name, args, result) => functionCallCallback?.Invoke(name, args, result), + _interruptTokenSource.Token); return response; } + catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) + { + // Handle graceful interruption - show em dash to indicate user interruption + ConsoleHelpers.Write("—", ConsoleColor.Yellow); + return ""; + } catch (Exception ex) { ConsoleHelpers.LogException(ex, "Exception occurred during chat completion", showToUser: false); SaveExceptionHistory(chat); throw; } + finally + { + // Clean up the cancellation token source and reset state + _interruptTokenSource?.Dispose(); + _interruptTokenSource = null; + _suppressAssistantDisplay = false; + } } private string? ReadLineOrSimulateInput(List inputInstructions, string? defaultOnEndOfInput = null) @@ -716,6 +736,15 @@ private void CheckAndShowPendingNotifications(FunctionCallingChat chat) private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) { + // Check for ESC key presses before processing any content + CheckForDoubleEscapeInterrupt(); + + // If display is suppressed due to cancellation, don't display anything + if (_suppressAssistantDisplay) + { + return; + } + var usageUpdate = update.Contents .Where(x => x is UsageContent) .Cast() @@ -740,6 +769,40 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) DisplayAssistantResponse(text); } + private void CheckForDoubleEscapeInterrupt() + { + // Only check for ESC if console input is not redirected and we're in an interactive session + if (Console.IsInputRedirected || !Console.KeyAvailable) + return; + + // Check if there are any keys available + while (Console.KeyAvailable) + { + var keyInfo = Console.ReadKey(true); // Read key without displaying it + + if (keyInfo.Key == ConsoleKey.Escape) + { + var currentTime = DateTime.UtcNow; + + if (_lastEscKeyTime.HasValue && + (currentTime - _lastEscKeyTime.Value).TotalMilliseconds <= DoubleEscTimeoutMs) + { + // Double ESC detected - suppress further display and show interruption indicator + _suppressAssistantDisplay = true; + _interruptTokenSource?.Cancel(); + return; + } + + _lastEscKeyTime = currentTime; + } + else + { + // Reset ESC tracking if any other key is pressed + _lastEscKeyTime = null; + } + } + } + private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) { Logger.Info($"HandleFunctionCallApproval: Function '{name}' called with args: {args}"); @@ -1231,6 +1294,12 @@ private void AddAutoDenyToolDefaults() private HashSet _approvedFunctionCallNames = new HashSet(); private HashSet _deniedFunctionCallNames = new HashSet(); + + // Double-ESC interrupt tracking + private DateTime? _lastEscKeyTime = null; + private CancellationTokenSource? _interruptTokenSource = null; + private bool _suppressAssistantDisplay = false; + private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC } From daec916f800e2ac0f2d86a8c3bbe4897b7b81a81 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 10:43:01 -0800 Subject: [PATCH 02/22] Saving Interrupting now saves the conversation. The only issue right now is that the displayed content is slightly desynchronized from the actual content (display lags behind actual AI content). --- src/cycod/ChatClient/FunctionCallingChat.cs | 53 +++++++++++++------- src/cycod/CommandLineCommands/ChatCommand.cs | 4 +- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index 0298d4f6..3e1d2093 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -92,28 +92,43 @@ public async Task CompleteChatStreamingAsync( while (true) { var responseContent = string.Empty; - await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) + + try { - // Check for cancellation before processing each update - cancellationToken.ThrowIfCancellationRequested(); - - _functionCallDetector.CheckForFunctionCall(update); - - var content = string.Join("", update.Contents - .Where(c => c is TextContent) - .Cast() - .Select(c => c.Text) - .ToList()); - - if (update.FinishReason == ChatFinishReason.ContentFilter) + await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) { - content = $"{content}\nWARNING: Content filtered!"; + // Check for cancellation before processing each update + cancellationToken.ThrowIfCancellationRequested(); + + _functionCallDetector.CheckForFunctionCall(update); + + var content = string.Join("", update.Contents + .Where(c => c is TextContent) + .Cast() + .Select(c => c.Text) + .ToList()); + + if (update.FinishReason == ChatFinishReason.ContentFilter) + { + content = $"{content}\nWARNING: Content filtered!"; + } + + responseContent += content; + contentToReturn += content; + + streamingCallback?.Invoke(update); } - - responseContent += content; - contentToReturn += content; - - streamingCallback?.Invoke(update); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User interrupted - save the partial response (without em dash for now) + if (!string.IsNullOrEmpty(contentToReturn)) + { + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, contentToReturn)); + messageCallback?.Invoke(Conversation.Messages); + } + // Re-throw so ChatCommand can handle the display + throw; } if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 47425dee..ae9524bc 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -526,8 +526,8 @@ private async Task CompleteChatStreamingAsync( } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) { - // Handle graceful interruption - show em dash to indicate user interruption - ConsoleHelpers.Write("—", ConsoleColor.Yellow); + // Handle graceful interruption - show yellow em dash and save partial content + ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); return ""; } catch (Exception ex) From 918013cd7817c8cc40fd52b0b29526d35ea65002 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 10:56:25 -0800 Subject: [PATCH 03/22] Desynchronization fixed Displayed text and saved text during an interrupt now matches perfectly. Interrupting the AI is super fluid. You can do it at any point in its response. Last issue to fix is giving the user control immediately during an early interrupt (right now it still waits for input from AI). --- src/cycod/ChatClient/FunctionCallingChat.cs | 47 +++++++++++++++++--- src/cycod/CommandLineCommands/ChatCommand.cs | 29 +++++++++++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index 3e1d2093..b4a8abd5 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -62,7 +62,8 @@ public async Task CompleteChatStreamingAsync( Action? streamingCallback = null, Func? approveFunctionCall = null, Action? functionCallCallback = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? getDisplayBuffer = null) { return await CompleteChatStreamingAsync( userPrompt, @@ -71,7 +72,8 @@ public async Task CompleteChatStreamingAsync( streamingCallback, approveFunctionCall, functionCallCallback, - cancellationToken); + cancellationToken, + getDisplayBuffer); } public async Task CompleteChatStreamingAsync( @@ -81,7 +83,8 @@ public async Task CompleteChatStreamingAsync( Action? streamingCallback = null, Func? approveFunctionCall = null, Action? functionCallCallback = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? getDisplayBuffer = null) { var message = CreateUserMessageWithImages(userPrompt, imageFiles); @@ -121,11 +124,23 @@ public async Task CompleteChatStreamingAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // User interrupted - save the partial response (without em dash for now) - if (!string.IsNullOrEmpty(contentToReturn)) + // User interrupted - trim content to match what was actually displayed + var displayBuffer = getDisplayBuffer?.Invoke() ?? ""; + + if (string.IsNullOrEmpty(displayBuffer)) { - Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, contentToReturn)); - messageCallback?.Invoke(Conversation.Messages); + // No content was displayed, don't add any message + // (This handles issue #1 - early interrupt before any text shown) + } + else if (!string.IsNullOrEmpty(contentToReturn)) + { + // Trim contentToReturn to match displayBuffer (handles issue #2) + var trimmedContent = TrimContentToDisplayBuffer(contentToReturn, displayBuffer); + if (!string.IsNullOrEmpty(trimmedContent)) + { + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, trimmedContent)); + messageCallback?.Invoke(Conversation.Messages); + } } // Re-throw so ChatCommand can handle the display throw; @@ -144,6 +159,24 @@ public async Task CompleteChatStreamingAsync( } } + private string TrimContentToDisplayBuffer(string fullContent, string displayBuffer) + { + if (string.IsNullOrEmpty(displayBuffer) || string.IsNullOrEmpty(fullContent)) + return ""; + + // Find where the display buffer content appears in the full content + var displayBufferIndex = fullContent.LastIndexOf(displayBuffer); + if (displayBufferIndex >= 0) + { + // Trim to end where display buffer ends + return fullContent.Substring(0, displayBufferIndex + displayBuffer.Length); + } + + // If we can't find the display buffer in the content, return the display buffer + // This handles edge cases where the content might have been modified + return displayBuffer; + } + private bool TryCallFunctions(string responseContent, Func? approveFunctionCall, Action? functionCallCallback, Action>? messageCallback) { var noFunctionsToCall = !_functionCallDetector.HasFunctionCalls(); diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index ae9524bc..c5219167 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -512,6 +512,7 @@ private async Task CompleteChatStreamingAsync( _interruptTokenSource = new CancellationTokenSource(); _lastEscKeyTime = null; // Reset ESC tracking _suppressAssistantDisplay = false; // Reset display suppression + _displayBuffer = ""; // Reset display buffer for new response try { @@ -520,14 +521,16 @@ private async Task CompleteChatStreamingAsync( (update) => streamingCallback?.Invoke(update), (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, (name, args, result) => functionCallCallback?.Invoke(name, args, result), - _interruptTokenSource.Token); + _interruptTokenSource.Token, + () => _displayBuffer); return response; } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) { - // Handle graceful interruption - show yellow em dash and save partial content + // Handle graceful interruption - show yellow em dash (trimming now handled in FunctionCallingChat) ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); + _isDisplayingAssistantResponse = false; return ""; } catch (Exception ex) @@ -542,6 +545,8 @@ private async Task CompleteChatStreamingAsync( _interruptTokenSource?.Dispose(); _interruptTokenSource = null; _suppressAssistantDisplay = false; + _isDisplayingAssistantResponse = false; + _displayBuffer = ""; } } @@ -767,6 +772,9 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) .Select(x => x.Text) .ToList()); DisplayAssistantResponse(text); + + // Track displayed content for accurate interrupt saving + UpdateDisplayBuffer(text); } private void CheckForDoubleEscapeInterrupt() @@ -803,6 +811,20 @@ private void CheckForDoubleEscapeInterrupt() } } + private void UpdateDisplayBuffer(string displayedText) + { + if (string.IsNullOrEmpty(displayedText)) + return; + + _displayBuffer += displayedText; + + // Keep only the last DisplayBufferSize characters + if (_displayBuffer.Length > DisplayBufferSize) + { + _displayBuffer = _displayBuffer.Substring(_displayBuffer.Length - DisplayBufferSize); + } + } + private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) { Logger.Info($"HandleFunctionCallApproval: Function '{name}' called with args: {args}"); @@ -1298,8 +1320,11 @@ private void AddAutoDenyToolDefaults() // Double-ESC interrupt tracking private DateTime? _lastEscKeyTime = null; private CancellationTokenSource? _interruptTokenSource = null; + private bool _isDisplayingAssistantResponse = false; private bool _suppressAssistantDisplay = false; + private string _displayBuffer = ""; // Track last displayed content for accurate saving private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC + private const int DisplayBufferSize = 50; // Track last 50 characters displayed } From c1a72b4e78f10f5ee296fdae52a5a65664c34c3f Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 11:22:39 -0800 Subject: [PATCH 04/22] Early Interrupt is now Instant A new polling method was added to handle early interrupts. Now early interrupts are handled as fast as regular ones. --- src/cycod/CommandLineCommands/ChatCommand.cs | 53 ++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index c5219167..ef91618a 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -182,7 +182,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 CompleteChatStreamingAsyncWithInterruptPolling(chat, giveAssistant, imageFiles, (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), (name, args) => HandleFunctionCallApproval(factory, name, args!), @@ -528,9 +528,7 @@ private async Task CompleteChatStreamingAsync( } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) { - // Handle graceful interruption - show yellow em dash (trimming now handled in FunctionCallingChat) - ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); - _isDisplayingAssistantResponse = false; + // Interruption handled by polling - just return empty (no message display needed) return ""; } catch (Exception ex) @@ -545,11 +543,52 @@ private async Task CompleteChatStreamingAsync( _interruptTokenSource?.Dispose(); _interruptTokenSource = null; _suppressAssistantDisplay = false; - _isDisplayingAssistantResponse = false; _displayBuffer = ""; } } + private async Task CompleteChatStreamingAsyncWithInterruptPolling( + FunctionCallingChat chat, + string userPrompt, + IEnumerable imageFiles, + Action>? messageCallback = null, + Action? streamingCallback = null, + Func? approveFunctionCall = null, + Action? functionCallCallback = null) + { + // Start the AI streaming task + var streamingTask = CompleteChatStreamingAsync(chat, userPrompt, imageFiles, messageCallback, streamingCallback, approveFunctionCall, functionCallCallback); + + // Poll for interrupts throughout the entire streaming at AI token frequency + const int pollIntervalMs = 50; // Same frequency as typical AI token arrival + + while (!streamingTask.IsCompleted) + { + CheckForDoubleEscapeInterrupt(); + if (_interruptTokenSource?.Token.IsCancellationRequested == true) + { + // Interrupt detected during polling - handle immediately + ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); + + // Properly cancel and await the streaming task to prevent duplicate messages + try + { + await streamingTask; // This will hit the catch block but won't display message again + } + catch (OperationCanceledException) + { + // Expected - streaming was cancelled, ignore + } + return ""; + } + + await Task.Delay(pollIntervalMs); + } + + // Either streaming started or polling timed out - return the streaming result + return await streamingTask; + } + private string? ReadLineOrSimulateInput(List inputInstructions, string? defaultOnEndOfInput = null) { while (inputInstructions?.Count > 0) @@ -741,9 +780,6 @@ private void CheckAndShowPendingNotifications(FunctionCallingChat chat) private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) { - // Check for ESC key presses before processing any content - CheckForDoubleEscapeInterrupt(); - // If display is suppressed due to cancellation, don't display anything if (_suppressAssistantDisplay) { @@ -1320,7 +1356,6 @@ private void AddAutoDenyToolDefaults() // Double-ESC interrupt tracking private DateTime? _lastEscKeyTime = null; private CancellationTokenSource? _interruptTokenSource = null; - private bool _isDisplayingAssistantResponse = false; private bool _suppressAssistantDisplay = false; private string _displayBuffer = ""; // Track last displayed content for accurate saving private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC From de5d08c03453774d1d141ef231dcf6dddac3d8f5 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 11:58:28 -0800 Subject: [PATCH 05/22] ESC to regain control during a tool call Instead of explicitly approving or denying a tool call, users can press ESC to regain control of the conversation and type more content to the AI. This is good if you need to provide more context to the AI before a tool call is made, or if it is performing a bad tool call and you want to tell it so (like ending itself while trying to build cycod). Right now, the chat-history is saved with an Assistant message that declares the function call was cancelled (`{"role":"assistant","content":"Function call cancelled by user - returning to conversation."}`), but this doesn't seem to carry the same "weight" (importance to the AI) as the actual function call returning this same information (like is done when the function is approved or denied). I'll be looking into this in the next commit. --- src/cycod/ChatClient/FunctionCallingChat.cs | 47 ++++++++++++++++--- src/cycod/CommandLineCommands/ChatCommand.cs | 48 +++++++++++++++++--- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index b4a8abd5..671ff0c0 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -146,15 +146,24 @@ public async Task CompleteChatStreamingAsync( throw; } - if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) + try + { + if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) + { + _functionCallDetector.Clear(); + continue; + } + + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent)); + messageCallback?.Invoke(Conversation.Messages); + } + catch (Exception ex) when (ex.GetType().Name == "UserWantsControlException") { + // User cancelled function call - exit streaming entirely and return control to user _functionCallDetector.Clear(); - continue; + return contentToReturn; // Exit the streaming loop, return to ChatCommand for user prompt } - Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent)); - messageCallback?.Invoke(Conversation.Messages); - return contentToReturn; } } @@ -194,7 +203,22 @@ private bool TryCallFunctions(string responseContent, Func functionCallResults; + try + { + functionCallResults = CallFunctions(readyToCallFunctionCalls, approveFunctionCall, functionCallCallback); + } + catch (Exception ex) when (ex.GetType().Name == "UserWantsControlException") + { + // User wants to regain control - add cancellation message + // Don't add the original assistant message again - it was already added in TryCallFunctions + var cancelMessage = "Function call cancelled by user - returning to conversation."; + Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, cancelMessage)); + messageCallback?.Invoke(Conversation.Messages); + + // Re-throw so the main loop knows to skip adding the assistant message + throw; + } var attachToToolMessage = functionCallResults .Where(c => c is FunctionResultContent) @@ -229,7 +253,16 @@ private List CallFunctions(List(); foreach (var functionCall in readyToCallFunctionCalls) { - var approved = approveFunctionCall?.Invoke(functionCall.Name, functionCall.Arguments) ?? true; + bool approved; + try + { + approved = approveFunctionCall?.Invoke(functionCall.Name, functionCall.Arguments) ?? true; + } + catch (Exception ex) when (ex.GetType().Name == "UserWantsControlException") + { + // Re-throw so TryCallFunctions can handle it + throw; + } var functionResult = approved ? CallFunction(functionCall, functionCallCallback) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index ef91618a..8e7468d3 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -3,6 +3,23 @@ using System.Diagnostics; using System.Text; +public enum FunctionCallDecision +{ + Approved, + Denied, + UserWantsControl +} + +public class UserWantsControlException : Exception +{ + public bool AssistantMessageAlreadyAdded { get; } + + public UserWantsControlException(bool assistantMessageAlreadyAdded = false) : base("User requested control") + { + AssistantMessageAlreadyAdded = assistantMessageAlreadyAdded; + } +} + public class ChatCommand : CommandWithVariables { public ChatCommand() @@ -185,7 +202,7 @@ public override async Task ExecuteAsync(bool interactive) var response = await CompleteChatStreamingAsyncWithInterruptPolling(chat, giveAssistant, imageFiles, (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), - (name, args) => HandleFunctionCallApproval(factory, name, args!), + (name, args) => ConvertFunctionCallDecision(HandleFunctionCallApproval(factory, name, args!)), (name, args, result) => HandleFunctionCallCompleted(name, args, result)); @@ -861,14 +878,25 @@ private void UpdateDisplayBuffer(string displayedText) } } - private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) + private bool ConvertFunctionCallDecision(FunctionCallDecision decision) + { + return decision switch + { + FunctionCallDecision.Approved => true, + FunctionCallDecision.Denied => false, + FunctionCallDecision.UserWantsControl => throw new UserWantsControlException(), + _ => false + }; + } + + private FunctionCallDecision HandleFunctionCallApproval(McpFunctionFactory factory, string name, string args) { Logger.Info($"HandleFunctionCallApproval: Function '{name}' called with args: {args}"); var autoApprove = ShouldAutoApprove(factory, name); Logger.Info($"HandleFunctionCallApproval: Auto-approve result for '{name}': {autoApprove}"); - if (autoApprove) return true; + if (autoApprove) return FunctionCallDecision.Approved; while (true) { @@ -890,23 +918,28 @@ private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, { ConsoleHelpers.WriteLine($"\b\b\b\b Approved (session)", ConsoleColor.Yellow); _approvedFunctionCallNames.Add(name); - return true; + return FunctionCallDecision.Approved; } else if (key == null || key?.KeyChar == 'N') { _deniedFunctionCallNames.Add(name); ConsoleHelpers.WriteLine($"\b\b\b\b Declined (session)", ConsoleColor.Red); - return false; + return FunctionCallDecision.Denied; } else if (key?.KeyChar == 'y') { ConsoleHelpers.WriteLine($"\b\b\b\b Approved (once)", ConsoleColor.Yellow); - return true; + return FunctionCallDecision.Approved; } else if (key?.KeyChar == 'n') { ConsoleHelpers.WriteLine($"\b\b\b\b Declined (once)", ConsoleColor.Red); - return false; + return FunctionCallDecision.Denied; + } + else if (key?.Key == ConsoleKey.Escape) + { + ConsoleHelpers.WriteLine($"\b\b\b\b Cancelled - returning control to user", ConsoleColor.Yellow); + return FunctionCallDecision.UserWantsControl; } else if (key?.KeyChar == '?') { @@ -916,6 +949,7 @@ private bool HandleFunctionCallApproval(McpFunctionFactory factory, string name, ConsoleHelpers.WriteLine(" y: Approve this function call for this one time"); ConsoleHelpers.WriteLine(" N: Decline this function call for this session"); ConsoleHelpers.WriteLine(" n: Decline this function call for this one time"); + ConsoleHelpers.WriteLine(" ESC: Cancel function call and return to conversation"); ConsoleHelpers.WriteLine(" ?: Show this help message\n"); ConsoleHelpers.Write(" See "); ConsoleHelpers.Write("cycod help function calls", ConsoleColor.Yellow); From 53d5ecadfb9fd74df190306caf37ceefc31dd8e1 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 12:48:10 -0800 Subject: [PATCH 06/22] Cancellation mimics tool request Now cancelling the request outputs exactly like approve and deny, so it should be much clearer both to the AI and user what has happened. --- src/cycod/ChatClient/FunctionCallingChat.cs | 29 +++++++++++++++----- src/cycod/CommandLineCommands/ChatCommand.cs | 13 ++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index 671ff0c0..97cb7754 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -161,7 +161,7 @@ public async Task CompleteChatStreamingAsync( { // User cancelled function call - exit streaming entirely and return control to user _functionCallDetector.Clear(); - return contentToReturn; // Exit the streaming loop, return to ChatCommand for user prompt + return ""; // Empty response - will show blank Assistant line } return contentToReturn; @@ -210,13 +210,28 @@ private bool TryCallFunctions(string responseContent, Func(); + + // For each cancelled function call, replicate the exact approve/deny callback pattern + foreach (var functionCall in readyToCallFunctionCalls) + { + // First callback: null result (shows "...") + functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, null); + + // Second callback: actual result (shows cancellation message) + var cancelResult = ChatCommand.CallDeniedMessage; + functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, cancelResult); + + // Create the FunctionResultContent for the Tool message + functionResultContents.Add(new FunctionResultContent(functionCall.CallId, cancelResult)); + } + + // Add Tool message (same pattern as normal flow) + Conversation.Messages.Add(new ChatMessage(ChatRole.Tool, functionResultContents)); messageCallback?.Invoke(Conversation.Messages); - // Re-throw so the main loop knows to skip adding the assistant message + // Re-throw so the main loop knows to exit throw; } @@ -303,7 +318,7 @@ private object DontCallFunction(FunctionCallDetector.ReadyToCallFunctionCall fun functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, null); ConsoleHelpers.WriteDebugLine($"Function call not approved: {functionCall.Name} with arguments: {functionCall.Arguments}"); - var functionResult = "User did not approve function call"; + var functionResult = ChatCommand.CallDeniedMessage; functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, functionResult); diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 8e7468d3..c1039e00 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -22,6 +22,9 @@ public UserWantsControlException(bool assistantMessageAlreadyAdded = false) : ba public class ChatCommand : CommandWithVariables { + // Public constant for function call cancellation message + public const string CallDeniedMessage = "User did not approve function call"; + public ChatCommand() { } @@ -938,7 +941,7 @@ private FunctionCallDecision HandleFunctionCallApproval(McpFunctionFactory facto } else if (key?.Key == ConsoleKey.Escape) { - ConsoleHelpers.WriteLine($"\b\b\b\b Cancelled - returning control to user", ConsoleColor.Yellow); + ConsoleHelpers.WriteLine($"\b\b\b\b Cancelled", ConsoleColor.Yellow); return FunctionCallDecision.UserWantsControl; } else if (key?.KeyChar == '?') @@ -964,6 +967,11 @@ private FunctionCallDecision HandleFunctionCallApproval(McpFunctionFactory facto private void HandleFunctionCallCompleted(string name, string args, object? result) { + // Track if this is a cancellation for other logic + var isCancellation = result?.ToString() == CallDeniedMessage; + _lastFunctionCallWasCancelled = isCancellation; + + // Always use normal flow - let DisplayAssistantLabel() be called even for cancellation DisplayAssistantFunctionCall(name, args, result); } @@ -1394,6 +1402,9 @@ private void AddAutoDenyToolDefaults() private string _displayBuffer = ""; // Track last displayed content for accurate saving private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC private const int DisplayBufferSize = 50; // Track last 50 characters displayed + + // Function call cancellation tracking + private bool _lastFunctionCallWasCancelled = false; } From 5fa6ae5582d5cc52b1c68f6ae2a8c1ca8c0b3eb2 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 13:08:54 -0800 Subject: [PATCH 07/22] Code Review - minor refactorings Moved UserWantsControlException and function call decision enum inside ChatCommand for better encapsulation. Replaced CallDeniedMessage with CancelledFunctionResultMessage for clarity. Removed redundant cancellation tracking and streamlined function call completion logic. --- src/cycod/ChatClient/FunctionCallingChat.cs | 12 ++--- src/cycod/CommandLineCommands/ChatCommand.cs | 52 ++++++++------------ 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index 97cb7754..9ce3ba20 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -130,11 +130,10 @@ public async Task CompleteChatStreamingAsync( if (string.IsNullOrEmpty(displayBuffer)) { // No content was displayed, don't add any message - // (This handles issue #1 - early interrupt before any text shown) } else if (!string.IsNullOrEmpty(contentToReturn)) { - // Trim contentToReturn to match displayBuffer (handles issue #2) + // Trim saved content to match what user actually saw var trimmedContent = TrimContentToDisplayBuffer(contentToReturn, displayBuffer); if (!string.IsNullOrEmpty(trimmedContent)) { @@ -142,7 +141,6 @@ public async Task CompleteChatStreamingAsync( messageCallback?.Invoke(Conversation.Messages); } } - // Re-throw so ChatCommand can handle the display throw; } @@ -157,7 +155,7 @@ public async Task CompleteChatStreamingAsync( Conversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent)); messageCallback?.Invoke(Conversation.Messages); } - catch (Exception ex) when (ex.GetType().Name == "UserWantsControlException") + catch (ChatCommand.UserWantsControlException) { // User cancelled function call - exit streaming entirely and return control to user _functionCallDetector.Clear(); @@ -208,7 +206,7 @@ private bool TryCallFunctions(string responseContent, Func(); @@ -220,7 +218,7 @@ private bool TryCallFunctions(string responseContent, Func CallFunctions(List ExecuteAsync(bool interactive) (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), (name, args) => ConvertFunctionCallDecision(HandleFunctionCallApproval(factory, name, args!)), - (name, args, result) => HandleFunctionCallCompleted(name, args, result)); + (name, args, result) => DisplayAssistantFunctionCall(name, args, result)); // Check for notifications that may have been generated during the assistant's response @@ -597,7 +597,6 @@ private async Task CompleteChatStreamingAsyncWithInterruptPolling( } catch (OperationCanceledException) { - // Expected - streaming was cancelled, ignore } return ""; } @@ -842,7 +841,7 @@ private void CheckForDoubleEscapeInterrupt() // Check if there are any keys available while (Console.KeyAvailable) { - var keyInfo = Console.ReadKey(true); // Read key without displaying it + var keyInfo = Console.ReadKey(true); if (keyInfo.Key == ConsoleKey.Escape) { @@ -965,16 +964,6 @@ private FunctionCallDecision HandleFunctionCallApproval(McpFunctionFactory facto } } - private void HandleFunctionCallCompleted(string name, string args, object? result) - { - // Track if this is a cancellation for other logic - var isCancellation = result?.ToString() == CallDeniedMessage; - _lastFunctionCallWasCancelled = isCancellation; - - // Always use normal flow - let DisplayAssistantLabel() be called even for cancellation - DisplayAssistantFunctionCall(name, args, result); - } - private void DisplayUserPrompt() { ConsoleHelpers.Write("\rUser: ", ConsoleColor.Green); @@ -1403,8 +1392,7 @@ private void AddAutoDenyToolDefaults() private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC private const int DisplayBufferSize = 50; // Track last 50 characters displayed - // Function call cancellation tracking - private bool _lastFunctionCallWasCancelled = false; + } From c6c556c29b274b810d10aac9f01c0e53f7c96a2a Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 13:15:21 -0800 Subject: [PATCH 08/22] Improve readability --- src/cycod/ChatClient/FunctionCallingChat.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index 9ce3ba20..f489dccd 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -126,14 +126,11 @@ public async Task CompleteChatStreamingAsync( { // User interrupted - trim content to match what was actually displayed var displayBuffer = getDisplayBuffer?.Invoke() ?? ""; - - if (string.IsNullOrEmpty(displayBuffer)) - { - // No content was displayed, don't add any message - } - else if (!string.IsNullOrEmpty(contentToReturn)) + + // If we have both the display buffer and the content to return, trim the content to match what was displayed + // This avoids showing content that was generated but not actually seen by the user due to the slight delay in cancellation + if (!string.IsNullOrEmpty(displayBuffer) && !string.IsNullOrEmpty(contentToReturn)) { - // Trim saved content to match what user actually saw var trimmedContent = TrimContentToDisplayBuffer(contentToReturn, displayBuffer); if (!string.IsNullOrEmpty(trimmedContent)) { From 52650cb72ffe7f99dd094ecbcf94e243c8ca61e7 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 13:22:51 -0800 Subject: [PATCH 09/22] Comments --- src/cycod/ChatClient/FunctionCallingChat.cs | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index f489dccd..bb918290 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -87,7 +87,7 @@ public async Task CompleteChatStreamingAsync( Func? getDisplayBuffer = null) { var message = CreateUserMessageWithImages(userPrompt, imageFiles); - + Conversation.Messages.Add(message); messageCallback?.Invoke(Conversation.Messages); @@ -95,14 +95,14 @@ public async Task CompleteChatStreamingAsync( while (true) { var responseContent = string.Empty; - + try { await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) { // Check for cancellation before processing each update cancellationToken.ThrowIfCancellationRequested(); - + _functionCallDetector.CheckForFunctionCall(update); var content = string.Join("", update.Contents @@ -163,6 +163,14 @@ public async Task CompleteChatStreamingAsync( } } + /// + /// Trims the full content to match what was actually displayed in the display buffer. + /// This is used to ensure that if the user cancels the response, we only show the content that was actually seen by the user, + /// and not any additional content that may have been generated but not displayed due to cancellation. + /// + /// Content generated by the AI so far (usually more than what has been displayed) + /// The tail end of the content that was actually displayed to the user + /// All content that was shown on screen to the user private string TrimContentToDisplayBuffer(string fullContent, string displayBuffer) { if (string.IsNullOrEmpty(displayBuffer) || string.IsNullOrEmpty(fullContent)) @@ -203,29 +211,36 @@ private bool TryCallFunctions(string responseContent, Func(); - + // For each cancelled function call, replicate the exact approve/deny callback pattern foreach (var functionCall in readyToCallFunctionCalls) { - // First callback: null result (shows "...") + // Zeroth callback: already showed the approval message, so we can skip that. + // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => Cancelled` + + // First callback: null result + // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => ...` functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, null); - - // Second callback: actual result (shows cancellation message) + + // Second callback: shows the AI response to the function call + // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => User did not approve function call` var cancelResult = ChatCommand.CancelledFunctionResultMessage; functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, cancelResult); - + // Create the FunctionResultContent for the Tool message functionResultContents.Add(new FunctionResultContent(functionCall.CallId, cancelResult)); } - + // Add Tool message (same pattern as normal flow) Conversation.Messages.Add(new ChatMessage(ChatRole.Tool, functionResultContents)); messageCallback?.Invoke(Conversation.Messages); - + // Re-throw so the main loop knows to exit throw; } From 1543f79944457b8e4b582bd3249ad4b9abee8631 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 13:24:16 -0800 Subject: [PATCH 10/22] Update ChatCommand.cs --- src/cycod/CommandLineCommands/ChatCommand.cs | 76 ++++++++------------ 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 0c174ac6..154020c0 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -202,7 +202,7 @@ public override async Task ExecuteAsync(bool interactive) var imageFiles = ImagePatterns.Any() ? ImageResolver.ResolveImagePatterns(ImagePatterns) : new List(); ImagePatterns.Clear(); - var response = await CompleteChatStreamingAsyncWithInterruptPolling(chat, giveAssistant, imageFiles, + var response = await CompleteChatStreamingAsync(chat, giveAssistant, imageFiles, (messages) => HandleUpdateMessages(messages), (update) => HandleStreamingChatCompletionUpdate(update), (name, args) => ConvertFunctionCallDecision(HandleFunctionCallApproval(factory, name, args!)), @@ -536,15 +536,42 @@ private async Task CompleteChatStreamingAsync( try { - var response = await chat.CompleteChatStreamingAsync(userPrompt, imageFiles, + // Start the AI streaming task + var streamingTask = chat.CompleteChatStreamingAsync(userPrompt, imageFiles, (messages) => messageCallback?.Invoke(messages), (update) => streamingCallback?.Invoke(update), (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, (name, args, result) => functionCallCallback?.Invoke(name, args, result), _interruptTokenSource.Token, () => _displayBuffer); - - return response; + + // Poll for interrupts throughout the entire streaming at AI token frequency + const int pollIntervalMs = 50; // Same frequency as typical AI token arrival + + while (!streamingTask.IsCompleted) + { + CheckForDoubleEscapeInterrupt(); + if (_interruptTokenSource?.Token.IsCancellationRequested == true) + { + // Interrupt detected during polling - handle immediately + ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); + + // Properly cancel and await the streaming task to prevent duplicate messages + try + { + await streamingTask; + } + catch (OperationCanceledException) + { + } + return ""; + } + + await Task.Delay(pollIntervalMs); + } + + // Return the completed streaming result + return await streamingTask; } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) { @@ -567,47 +594,6 @@ private async Task CompleteChatStreamingAsync( } } - private async Task CompleteChatStreamingAsyncWithInterruptPolling( - FunctionCallingChat chat, - string userPrompt, - IEnumerable imageFiles, - Action>? messageCallback = null, - Action? streamingCallback = null, - Func? approveFunctionCall = null, - Action? functionCallCallback = null) - { - // Start the AI streaming task - var streamingTask = CompleteChatStreamingAsync(chat, userPrompt, imageFiles, messageCallback, streamingCallback, approveFunctionCall, functionCallCallback); - - // Poll for interrupts throughout the entire streaming at AI token frequency - const int pollIntervalMs = 50; // Same frequency as typical AI token arrival - - while (!streamingTask.IsCompleted) - { - CheckForDoubleEscapeInterrupt(); - if (_interruptTokenSource?.Token.IsCancellationRequested == true) - { - // Interrupt detected during polling - handle immediately - ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); - - // Properly cancel and await the streaming task to prevent duplicate messages - try - { - await streamingTask; // This will hit the catch block but won't display message again - } - catch (OperationCanceledException) - { - } - return ""; - } - - await Task.Delay(pollIntervalMs); - } - - // Either streaming started or polling timed out - return the streaming result - return await streamingTask; - } - private string? ReadLineOrSimulateInput(List inputInstructions, string? defaultOnEndOfInput = null) { while (inputInstructions?.Count > 0) From f72c2542b332311a97d2c8a9539ff52b595af3f9 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 10 Nov 2025 13:27:34 -0800 Subject: [PATCH 11/22] Update ChatCommand.cs --- src/cycod/CommandLineCommands/ChatCommand.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 154020c0..06c91a08 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -1377,9 +1377,5 @@ private void AddAutoDenyToolDefaults() private string _displayBuffer = ""; // Track last displayed content for accurate saving private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC private const int DisplayBufferSize = 50; // Track last 50 characters displayed - - - - } From 50a9f743f6fe9c132d2cf91a60bbd65d4405dcb3 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Fri, 14 Nov 2025 12:46:33 -0800 Subject: [PATCH 12/22] Update KnownSettings.cs --- src/common/Configuration/KnownSettings.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/Configuration/KnownSettings.cs b/src/common/Configuration/KnownSettings.cs index efaded06..3d12ab6b 100644 --- a/src/common/Configuration/KnownSettings.cs +++ b/src/common/Configuration/KnownSettings.cs @@ -352,7 +352,8 @@ public static class KnownSettings AppAutoSaveLog, AppChatCompletionTimeout, AppAutoApprove, - AppAutoDeny + AppAutoDeny, + AppAutoGenerateTitles }; #endregion From f0f28f8f4d913a430e07297f2cd10cb46f90529e Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 17 Nov 2025 11:05:31 -0800 Subject: [PATCH 13/22] Add className null checks for dorny-test-reporter Potential fix for TRX files. If className is null, dorny will be unable to parse. We need checks around that. --- src/cycodt/TestFramework/TrxXmlTestReporter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cycodt/TestFramework/TrxXmlTestReporter.cs b/src/cycodt/TestFramework/TrxXmlTestReporter.cs index 10755ff4..930df7ec 100644 --- a/src/cycodt/TestFramework/TrxXmlTestReporter.cs +++ b/src/cycodt/TestFramework/TrxXmlTestReporter.cs @@ -133,7 +133,9 @@ public static string WriteResultsFile(TestRun testRun, string? resultsFile = nul { var executionId = testRun.GetExecutionId(testCase).ToString(); var qualifiedParts = testCase.FullyQualifiedName.Split('.'); - var className = string.Join(".", qualifiedParts.Take(qualifiedParts.Length - 1)); + var className = qualifiedParts.Length > 1 + ? string.Join(".", qualifiedParts.Take(qualifiedParts.Length - 1)) + : "CycodtTests"; // Default class name when no dots in FullyQualifiedName var name = qualifiedParts.Last(); var atIndex = name.LastIndexOf('@'); if (atIndex > -1) name = name.Substring(0, atIndex); From 163753e162b11b7d5c71f665ee05e3cf093f9222 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 17 Nov 2025 11:15:49 -0800 Subject: [PATCH 14/22] Revert "Add className null checks for dorny-test-reporter" This reverts commit f0f28f8f4d913a430e07297f2cd10cb46f90529e. --- src/cycodt/TestFramework/TrxXmlTestReporter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cycodt/TestFramework/TrxXmlTestReporter.cs b/src/cycodt/TestFramework/TrxXmlTestReporter.cs index 930df7ec..10755ff4 100644 --- a/src/cycodt/TestFramework/TrxXmlTestReporter.cs +++ b/src/cycodt/TestFramework/TrxXmlTestReporter.cs @@ -133,9 +133,7 @@ public static string WriteResultsFile(TestRun testRun, string? resultsFile = nul { var executionId = testRun.GetExecutionId(testCase).ToString(); var qualifiedParts = testCase.FullyQualifiedName.Split('.'); - var className = qualifiedParts.Length > 1 - ? string.Join(".", qualifiedParts.Take(qualifiedParts.Length - 1)) - : "CycodtTests"; // Default class name when no dots in FullyQualifiedName + var className = string.Join(".", qualifiedParts.Take(qualifiedParts.Length - 1)); var name = qualifiedParts.Last(); var atIndex = name.LastIndexOf('@'); if (atIndex > -1) name = name.Substring(0, atIndex); From 2c8882711a89b5cf3264586cee1b5d5001e47316 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 17 Nov 2025 11:31:48 -0800 Subject: [PATCH 15/22] Ignore interrupts during title generation Refrains from polling for ESC (interrupts) while generating titles. This prevents the polling process from accidentally consuming valid input. --- src/cycod/CommandLineCommands/ChatCommand.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 06c91a08..b7ddaa2f 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -821,7 +821,9 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) private void CheckForDoubleEscapeInterrupt() { // Only check for ESC if console input is not redirected and we're in an interactive session - if (Console.IsInputRedirected || !Console.KeyAvailable) + // Skip during title generation to avoid consuming subprocess input + var titleGenerating = _currentChat?.Notifications.GetGenerationState(NotificationType.Title) != GenerationState.Idle; + if (Console.IsInputRedirected || !Console.KeyAvailable || titleGenerating) return; // Check if there are any keys available From 7245aff635d16e0f2a3f61fb66247a88a36c1f84 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 17 Nov 2025 16:09:56 -0800 Subject: [PATCH 16/22] Increase timeout to prevent race conditions Previously there was a race condition between title generation and "waste time". --- src/cycod/ChatClient/TestChatClient.cs | 2 +- tests/cycod-yaml/cycod-slash-title-commands.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cycod/ChatClient/TestChatClient.cs b/src/cycod/ChatClient/TestChatClient.cs index 5b893657..43bdd4cd 100644 --- a/src/cycod/ChatClient/TestChatClient.cs +++ b/src/cycod/ChatClient/TestChatClient.cs @@ -9,7 +9,7 @@ public class TestChatClient : IChatClient { // Timing constants with environment variable overrides private const int DEFAULT_RESPONSE_DELAY_MS = 100; // Fast by default - private const int DEFAULT_WASTE_TIME_DELAY_MS = 3000; // Longer for time burning + private const int DEFAULT_WASTE_TIME_DELAY_MS = 5000; // Longer for time burning private const int DEFAULT_STREAMING_DELAY_MS = 50; private const int DEFAULT_STREAMING_CHUNK_SIZE = 3; // Words per chunk diff --git a/tests/cycod-yaml/cycod-slash-title-commands.yaml b/tests/cycod-yaml/cycod-slash-title-commands.yaml index 3ee5a3d2..29d84ac4 100644 --- a/tests/cycod-yaml/cycod-slash-title-commands.yaml +++ b/tests/cycod-yaml/cycod-slash-title-commands.yaml @@ -1022,6 +1022,8 @@ tests: bash: cp testfiles/unlocked-title-full-conversation.jsonl testfiles/refresh-verification-test.jsonl - name: Start title refresh process + env: + TEST_WASTE_TIME_DELAY_MS: "5000" run: cycod --chat-history testfiles/refresh-verification-test.jsonl --input "/title refresh" --input "waste time" --input "/title view" --input "exit" --auto-save-chat-history 1 --use-test expect-regex: | User: /title refresh\r?\n From 9355797d9e22e23373f72df5a223bc6496dc5686 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 17 Nov 2025 16:19:50 -0800 Subject: [PATCH 17/22] Revert "Increase timeout to prevent race conditions" This reverts commit 7245aff635d16e0f2a3f61fb66247a88a36c1f84. --- src/cycod/ChatClient/TestChatClient.cs | 2 +- tests/cycod-yaml/cycod-slash-title-commands.yaml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cycod/ChatClient/TestChatClient.cs b/src/cycod/ChatClient/TestChatClient.cs index 43bdd4cd..5b893657 100644 --- a/src/cycod/ChatClient/TestChatClient.cs +++ b/src/cycod/ChatClient/TestChatClient.cs @@ -9,7 +9,7 @@ public class TestChatClient : IChatClient { // Timing constants with environment variable overrides private const int DEFAULT_RESPONSE_DELAY_MS = 100; // Fast by default - private const int DEFAULT_WASTE_TIME_DELAY_MS = 5000; // Longer for time burning + private const int DEFAULT_WASTE_TIME_DELAY_MS = 3000; // Longer for time burning private const int DEFAULT_STREAMING_DELAY_MS = 50; private const int DEFAULT_STREAMING_CHUNK_SIZE = 3; // Words per chunk diff --git a/tests/cycod-yaml/cycod-slash-title-commands.yaml b/tests/cycod-yaml/cycod-slash-title-commands.yaml index 29d84ac4..3ee5a3d2 100644 --- a/tests/cycod-yaml/cycod-slash-title-commands.yaml +++ b/tests/cycod-yaml/cycod-slash-title-commands.yaml @@ -1022,8 +1022,6 @@ tests: bash: cp testfiles/unlocked-title-full-conversation.jsonl testfiles/refresh-verification-test.jsonl - name: Start title refresh process - env: - TEST_WASTE_TIME_DELAY_MS: "5000" run: cycod --chat-history testfiles/refresh-verification-test.jsonl --input "/title refresh" --input "waste time" --input "/title view" --input "exit" --auto-save-chat-history 1 --use-test expect-regex: | User: /title refresh\r?\n From 5a1531016a3567a516b7c9f4e7ff4d07a978e375 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Thu, 4 Dec 2025 16:04:58 -0800 Subject: [PATCH 18/22] Improve readability of cancellation detection --- src/common/Configuration/KnownSettings.cs | 3 +- src/cycod/CommandLineCommands/ChatCommand.cs | 36 +++++++------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/common/Configuration/KnownSettings.cs b/src/common/Configuration/KnownSettings.cs index 3d12ab6b..efaded06 100644 --- a/src/common/Configuration/KnownSettings.cs +++ b/src/common/Configuration/KnownSettings.cs @@ -352,8 +352,7 @@ public static class KnownSettings AppAutoSaveLog, AppChatCompletionTimeout, AppAutoApprove, - AppAutoDeny, - AppAutoGenerateTitles + AppAutoDeny }; #endregion diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index b7ddaa2f..538f1cc9 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -538,39 +538,26 @@ private async Task CompleteChatStreamingAsync( { // Start the AI streaming task var streamingTask = chat.CompleteChatStreamingAsync(userPrompt, imageFiles, - (messages) => messageCallback?.Invoke(messages), - (update) => streamingCallback?.Invoke(update), - (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, - (name, args, result) => functionCallCallback?.Invoke(name, args, result), - _interruptTokenSource.Token, - () => _displayBuffer); - - // Poll for interrupts throughout the entire streaming at AI token frequency - const int pollIntervalMs = 50; // Same frequency as typical AI token arrival + (messages) => messageCallback?.Invoke(messages), + (update) => streamingCallback?.Invoke(update), + (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, + (name, args, result) => functionCallCallback?.Invoke(name, args, result), + _interruptTokenSource.Token, + () => _displayBuffer); + // Continuously poll for ESC key interrupts while streaming while (!streamingTask.IsCompleted) { CheckForDoubleEscapeInterrupt(); - if (_interruptTokenSource?.Token.IsCancellationRequested == true) + if (_interruptTokenSource.Token.IsCancellationRequested) { - // Interrupt detected during polling - handle immediately ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); - - // Properly cancel and await the streaming task to prevent duplicate messages - try - { - await streamingTask; - } - catch (OperationCanceledException) - { - } - return ""; + break; } - - await Task.Delay(pollIntervalMs); + await Task.Delay(PollIntervalMs, CancellationToken.None); } - // Return the completed streaming result + // Always await the task to get result or handle cancellation return await streamingTask; } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) @@ -1379,5 +1366,6 @@ private void AddAutoDenyToolDefaults() private string _displayBuffer = ""; // Track last displayed content for accurate saving private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC private const int DisplayBufferSize = 50; // Track last 50 characters displayed + private const int PollIntervalMs = 50; } From a7830b70993b61ae65aaf9ca5571291dcace45c0 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Thu, 4 Dec 2025 16:17:48 -0800 Subject: [PATCH 19/22] Use "DontCallFunction" instead of custom logic --- src/cycod/ChatClient/FunctionCallingChat.cs | 29 ++++++--------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/cycod/ChatClient/FunctionCallingChat.cs b/src/cycod/ChatClient/FunctionCallingChat.cs index bb918290..197cf4a0 100644 --- a/src/cycod/ChatClient/FunctionCallingChat.cs +++ b/src/cycod/ChatClient/FunctionCallingChat.cs @@ -87,7 +87,6 @@ public async Task CompleteChatStreamingAsync( Func? getDisplayBuffer = null) { var message = CreateUserMessageWithImages(userPrompt, imageFiles); - Conversation.Messages.Add(message); messageCallback?.Invoke(Conversation.Messages); @@ -96,6 +95,7 @@ public async Task CompleteChatStreamingAsync( { var responseContent = string.Empty; + // Surround streaming with try/catch to handle user interruption via OperationCancelledException try { await foreach (var update in _chatClient.GetStreamingResponseAsync(Conversation.Messages, _options, cancellationToken)) @@ -141,6 +141,7 @@ public async Task CompleteChatStreamingAsync( throw; } + // Surround assistant response handling with try/catch to handle user interruption via ChatCommand.UserWantsControlException try { if (TryCallFunctions(responseContent, approveFunctionCall, functionCallCallback, messageCallback)) @@ -212,36 +213,22 @@ private bool TryCallFunctions(string responseContent, Func(); - - // For each cancelled function call, replicate the exact approve/deny callback pattern + foreach (var functionCall in readyToCallFunctionCalls) { - // Zeroth callback: already showed the approval message, so we can skip that. - // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => Cancelled` - - // First callback: null result - // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => ...` - functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, null); - - // Second callback: shows the AI response to the function call - // `assistant-function: CreateFile {"path":"foo.txt","fileText":""} => User did not approve function call` - var cancelResult = ChatCommand.CancelledFunctionResultMessage; - functionCallCallback?.Invoke(functionCall.Name, functionCall.Arguments, cancelResult); - - // Create the FunctionResultContent for the Tool message + var cancelResult = DontCallFunction(functionCall, functionCallCallback); functionResultContents.Add(new FunctionResultContent(functionCall.CallId, cancelResult)); } - // Add Tool message (same pattern as normal flow) Conversation.Messages.Add(new ChatMessage(ChatRole.Tool, functionResultContents)); messageCallback?.Invoke(Conversation.Messages); - - // Re-throw so the main loop knows to exit + + // Re-throw so the main loop (CompleteChatStreamingAsync()) knows + // to stop processing and return control to the user throw; } From 527e23a29aacaa22bde1ecb546e78d01cf611c75 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Thu, 4 Dec 2025 16:23:36 -0800 Subject: [PATCH 20/22] Update ChatCommand.cs --- src/cycod/CommandLineCommands/ChatCommand.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index 538f1cc9..fea0deaa 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -538,12 +538,12 @@ private async Task CompleteChatStreamingAsync( { // Start the AI streaming task var streamingTask = chat.CompleteChatStreamingAsync(userPrompt, imageFiles, - (messages) => messageCallback?.Invoke(messages), - (update) => streamingCallback?.Invoke(update), - (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, - (name, args, result) => functionCallCallback?.Invoke(name, args, result), - _interruptTokenSource.Token, - () => _displayBuffer); + (messages) => messageCallback?.Invoke(messages), + (update) => streamingCallback?.Invoke(update), + (name, args) => approveFunctionCall?.Invoke(name, args) ?? true, + (name, args, result) => functionCallCallback?.Invoke(name, args, result), + _interruptTokenSource.Token, + () => _displayBuffer); // Continuously poll for ESC key interrupts while streaming while (!streamingTask.IsCompleted) @@ -800,8 +800,6 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) .Select(x => x.Text) .ToList()); DisplayAssistantResponse(text); - - // Track displayed content for accurate interrupt saving UpdateDisplayBuffer(text); } From d9514b9290af5078597a8184fc1835b404708863 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Thu, 4 Dec 2025 17:07:18 -0800 Subject: [PATCH 21/22] Event-Based Interrupts Removed polling-based implementation that cost lots of CPU, in favor of an event-based system. --- src/cycod/CommandLineCommands/ChatCommand.cs | 83 +++++----- src/cycod/Helpers/SimpleInterruptManager.cs | 161 +++++++++++++++++++ 2 files changed, 198 insertions(+), 46 deletions(-) create mode 100644 src/cycod/Helpers/SimpleInterruptManager.cs diff --git a/src/cycod/CommandLineCommands/ChatCommand.cs b/src/cycod/CommandLineCommands/ChatCommand.cs index fea0deaa..5c553773 100644 --- a/src/cycod/CommandLineCommands/ChatCommand.cs +++ b/src/cycod/CommandLineCommands/ChatCommand.cs @@ -528,14 +528,18 @@ private async Task CompleteChatStreamingAsync( streamingCallback = TryCatchHelpers.NoThrowWrap(streamingCallback); functionCallCallback = TryCatchHelpers.NoThrowWrap(functionCallCallback); - // Create a new cancellation token source for this streaming session + // Create cancellation token source and interrupt manager for this streaming session _interruptTokenSource = new CancellationTokenSource(); - _lastEscKeyTime = null; // Reset ESC tracking _suppressAssistantDisplay = false; // Reset display suppression _displayBuffer = ""; // Reset display buffer for new response + using var interruptManager = new SimpleInterruptManager(); + try { + // Start monitoring for interrupts + interruptManager.StartMonitoring(); + // Start the AI streaming task var streamingTask = chat.CompleteChatStreamingAsync(userPrompt, imageFiles, (messages) => messageCallback?.Invoke(messages), @@ -545,24 +549,35 @@ private async Task CompleteChatStreamingAsync( _interruptTokenSource.Token, () => _displayBuffer); - // Continuously poll for ESC key interrupts while streaming - while (!streamingTask.IsCompleted) + // Wait for either streaming completion or user interrupt + var interruptTask = interruptManager.WaitForInterruptAsync(); + var completedTask = await Task.WhenAny(streamingTask, interruptTask); + + if (completedTask == interruptTask) { - CheckForDoubleEscapeInterrupt(); - if (_interruptTokenSource.Token.IsCancellationRequested) + // User interrupt detected + var interruptResult = await interruptTask; + HandleInterrupt(interruptResult); + _interruptTokenSource.Cancel(); + + try { - ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); - break; + // Wait for streaming to finish cancellation and get partial result + return await streamingTask; + } + catch (OperationCanceledException) + { + // Clean cancellation - return empty + return ""; } - await Task.Delay(PollIntervalMs, CancellationToken.None); } - // Always await the task to get result or handle cancellation + // Normal completion return await streamingTask; } catch (OperationCanceledException) when (_interruptTokenSource.Token.IsCancellationRequested) { - // Interruption handled by polling - just return empty (no message display needed) + // Interruption handled - just return empty return ""; } catch (Exception ex) @@ -803,39 +818,18 @@ private void HandleStreamingChatCompletionUpdate(ChatResponseUpdate update) UpdateDisplayBuffer(text); } - private void CheckForDoubleEscapeInterrupt() + private void HandleInterrupt(InterruptResult interruptResult) { - // Only check for ESC if console input is not redirected and we're in an interactive session - // Skip during title generation to avoid consuming subprocess input - var titleGenerating = _currentChat?.Notifications.GetGenerationState(NotificationType.Title) != GenerationState.Idle; - if (Console.IsInputRedirected || !Console.KeyAvailable || titleGenerating) - return; - - // Check if there are any keys available - while (Console.KeyAvailable) + switch (interruptResult.Type) { - var keyInfo = Console.ReadKey(true); - - if (keyInfo.Key == ConsoleKey.Escape) - { - var currentTime = DateTime.UtcNow; - - if (_lastEscKeyTime.HasValue && - (currentTime - _lastEscKeyTime.Value).TotalMilliseconds <= DoubleEscTimeoutMs) - { - // Double ESC detected - suppress further display and show interruption indicator - _suppressAssistantDisplay = true; - _interruptTokenSource?.Cancel(); - return; - } - - _lastEscKeyTime = currentTime; - } - else - { - // Reset ESC tracking if any other key is pressed - _lastEscKeyTime = null; - } + case InterruptType.DoubleEscape: + ConsoleHelpers.Write("[User Interrupt]", ConsoleColor.Yellow); + _suppressAssistantDisplay = true; + break; + default: + ConsoleHelpers.Write("[Interrupt]", ConsoleColor.Yellow); + _suppressAssistantDisplay = true; + break; } } @@ -1357,13 +1351,10 @@ private void AddAutoDenyToolDefaults() private HashSet _approvedFunctionCallNames = new HashSet(); private HashSet _deniedFunctionCallNames = new HashSet(); - // Double-ESC interrupt tracking - private DateTime? _lastEscKeyTime = null; + // Event-driven interrupt tracking private CancellationTokenSource? _interruptTokenSource = null; private bool _suppressAssistantDisplay = false; private string _displayBuffer = ""; // Track last displayed content for accurate saving - private const int DoubleEscTimeoutMs = 500; // Maximum time between ESC presses to count as double-ESC private const int DisplayBufferSize = 50; // Track last 50 characters displayed - private const int PollIntervalMs = 50; } diff --git a/src/cycod/Helpers/SimpleInterruptManager.cs b/src/cycod/Helpers/SimpleInterruptManager.cs new file mode 100644 index 00000000..9e6ca379 --- /dev/null +++ b/src/cycod/Helpers/SimpleInterruptManager.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Lightweight event-driven interrupt manager for handling user interrupts during AI streaming. +/// Replaces the polling-based approach with a more efficient event-driven system. +/// +public class SimpleInterruptManager : IDisposable +{ + private readonly TaskCompletionSource _interruptSignal = new(); + private readonly CancellationTokenSource _shutdownToken = new(); + private Task? _monitorTask; + private bool _disposed = false; + + /// + /// Waits asynchronously for an interrupt to occur. + /// + /// A task that completes when an interrupt is detected. + public Task WaitForInterruptAsync() + { + return _interruptSignal.Task; + } + + /// + /// Starts monitoring for user interrupts in a background task. + /// + public void StartMonitoring() + { + if (_disposed) + throw new ObjectDisposedException(nameof(SimpleInterruptManager)); + + _monitorTask = Task.Run(MonitorForInterruptsAsync, _shutdownToken.Token); + } + + /// + /// Background task that monitors for interrupt conditions. + /// + private async Task MonitorForInterruptsAsync() + { + var escTracker = new EscKeyTracker(); + + try + { + while (!_shutdownToken.Token.IsCancellationRequested) + { + // Only check for input if console is available and not redirected + if (!Console.IsInputRedirected && Console.KeyAvailable) + { + var keyInfo = Console.ReadKey(true); + + if (escTracker.ProcessKey(keyInfo)) + { + var result = new InterruptResult + { + Type = InterruptType.DoubleEscape, + Timestamp = DateTime.UtcNow + }; + + _interruptSignal.TrySetResult(result); + return; + } + } + + // Short delay - much more responsive than 50ms polling + await Task.Delay(10, _shutdownToken.Token); + } + } + catch (OperationCanceledException) + { + // Expected when shutting down + } + catch (Exception ex) + { + // Signal error through the task completion source + _interruptSignal.TrySetException(ex); + } + } + + /// + /// Disposes resources and stops monitoring. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + _shutdownToken.Cancel(); + _monitorTask?.Wait(TimeSpan.FromMilliseconds(100)); + } + catch (Exception) + { + // Ignore cleanup exceptions + } + finally + { + _shutdownToken.Dispose(); + _monitorTask?.Dispose(); + } + } +} + +/// +/// Tracks ESC key presses to detect double-ESC interrupt pattern. +/// +public class EscKeyTracker +{ + private DateTime? _lastEscTime; + private const int DoubleEscTimeoutMs = 500; + + /// + /// Processes a key press and returns true if double-ESC is detected. + /// + /// The key that was pressed. + /// True if double-ESC pattern is detected, false otherwise. + public bool ProcessKey(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Escape) + { + var currentTime = DateTime.UtcNow; + + if (_lastEscTime.HasValue && + (currentTime - _lastEscTime.Value).TotalMilliseconds <= DoubleEscTimeoutMs) + { + // Double ESC detected + _lastEscTime = null; // Reset for next sequence + return true; + } + + _lastEscTime = currentTime; + } + else + { + // Reset ESC tracking if any other key is pressed + _lastEscTime = null; + } + + return false; + } +} + +/// +/// Represents the result of an interrupt detection. +/// +public class InterruptResult +{ + public InterruptType Type { get; set; } + public DateTime Timestamp { get; set; } +} + +/// +/// Types of interrupts that can be detected. +/// +public enum InterruptType +{ + DoubleEscape +} \ No newline at end of file From cce71093c32e11acb03525940747c759701ed2e4 Mon Sep 17 00:00:00 2001 From: Jac Chambers Date: Mon, 8 Dec 2025 17:34:23 -0800 Subject: [PATCH 22/22] Catch FileLogger Failure during Title Generation If FileLogger fails in subprocess, the warning message propagates back up to the title process, which uses that warning message as the title. This new logic catches this behavior and returns null. This should be improved upon in the future; improve subprocess logging to prevent contamination in the first place. --- .../FunctionCallingTools/CycoDmdCliWrapper.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs b/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs index 528adf6d..813a7733 100644 --- a/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs +++ b/src/cycod/FunctionCallingTools/CycoDmdCliWrapper.cs @@ -30,6 +30,12 @@ public class CycoDmdCliWrapper /// private const string TimeoutErrorSuffix = "timed out"; + /// + /// Error message pattern when FileLogger fails in subprocess. + /// This can contaminate title generation output when cycodmd subprocess encounters logging issues. + /// + private const string FileLoggerWarningPattern = "WARNING: FileLogger failed"; + #endregion /// /// Truncates command output according to line and total character limits. @@ -588,6 +594,14 @@ private string BuildRunCommandsAndFormatOutputArguments( return null; } + // Check if output is contaminated with FileLogger warnings from subprocess + // TODO: Improve subprocess logging to prevent this contamination in the first place + if (rawOutput.Contains(FileLoggerWarningPattern)) + { + Logger.Warning("Title generation output contaminated with FileLogger warning - returning null"); + return null; + } + return TruncateCommandOutput(rawOutput, maxCharsPerLine, maxTotalChars); } finally