From a5906bcd83eac1f0b3bd3945f17bb3d9818d1e4c Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 16:25:56 +0200 Subject: [PATCH 1/3] Fix chat terminal and queue cancellation Prevent completed AI terminal commands from hanging when their invocation marker is missed. Add queued-message removal and clear pending messages before aborting a chat turn. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Chat/ViewModels/ChatViewModel.cs | 97 +++++++++++++++- src/OneWare.Chat/Views/ChatView.axaml | 19 +++ .../Services/CopilotChatService.cs | 14 +++ .../Services/IChatService.cs | 11 ++ .../ViewModels/TerminalManagerViewModel.cs | 109 ++++++++++++------ 5 files changed, 215 insertions(+), 35 deletions(-) diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 991461ea..88aae83a 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -35,6 +35,7 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService // FIFO of messages sent locally so the echoed ChatUserMessageEvent can be matched // (suppressed for normal/steered sends, or used to activate a queued message). private readonly Queue _pendingLocalMessages = new(); + private readonly List _cancelledQueuedMessages = []; private const string DefaultWorkingStatus = "Working…"; @@ -43,6 +44,8 @@ private sealed record PendingLocalMessage( ChatSendMode Mode, ChatMessageUserViewModel? QueuedView); + private sealed record CancelledQueuedMessage(string Content, DateTimeOffset ExpiresAt); + private static readonly JsonSerializerOptions ChatStateSerializerOptions = new() { WriteIndented = true, @@ -73,8 +76,11 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma SteerCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Steer), CanSteerOrQueue); QueueCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Queue), CanSteerOrQueue); AbortCommand = new AsyncRelayCommand(AbortAsync, CanAbort); + RemoveQueuedMessageCommand = + new AsyncRelayCommand(RemoveQueuedMessageAsync, CanRemoveQueuedMessage); InitializeCurrentCommand = new AsyncRelayCommand(InitializeCurrentAsync); + QueuedMessages.CollectionChanged += (_, _) => RemoveQueuedMessageCommand.NotifyCanExecuteChanged(); applicationStateService.RegisterShutdownAction(SaveState); } @@ -211,6 +217,8 @@ public IChatService? SelectedChatService public AsyncRelayCommand AbortCommand { get; } + public AsyncRelayCommand RemoveQueuedMessageCommand { get; } + public AsyncRelayCommand InitializeCurrentCommand { get; } public override void InitializeContent() @@ -270,6 +278,7 @@ private async Task NewChatAsync() Messages.Clear(); QueuedMessages.Clear(); _pendingLocalMessages.Clear(); + _cancelledQueuedMessages.Clear(); WorkingStatusText = DefaultWorkingStatus; _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); @@ -338,6 +347,7 @@ private async Task SendInternalAsync(ChatSendMode mode) if (mode == ChatSendMode.Queue) { QueuedMessages.Remove(userMessage); + RemovePendingLocalMessage(userMessage); } else if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) { @@ -361,16 +371,56 @@ private async Task AbortAsync() { if (SelectedChatService == null) return; + var queueCleared = QueuedMessages.Count == 0; + if (!queueCleared) + { + try + { + queueCleared = await SelectedChatService.ClearQueuedMessagesAsync(); + } + catch (Exception ex) + { + AddErrorMessage($"Failed to clear queued messages: {ex.Message}"); + } + } + try { await SelectedChatService.AbortAsync(); } finally { - IsBusy = false; + if (queueCleared) + CancelQueuedMessagesLocally(); + + IsBusy = !queueCleared && QueuedMessages.Count > 0; + } + } + + private async Task RemoveQueuedMessageAsync(ChatMessageUserViewModel? message) + { + if (message == null || SelectedChatService == null || !CanRemoveQueuedMessage(message)) return; + + bool removed; + try + { + removed = await SelectedChatService.RemoveMostRecentQueuedMessageAsync(); + } + catch (Exception ex) + { + AddErrorMessage($"Failed to remove queued message: {ex.Message}"); + return; } + + if (!removed || !QueuedMessages.Contains(message)) return; + + QueuedMessages.Remove(message); + RemovePendingLocalMessage(message); } + private bool CanRemoveQueuedMessage(ChatMessageUserViewModel? message) => + message != null && ReferenceEquals(QueuedMessages.LastOrDefault(), message); + private bool CanSend() => IsConnected && !string.IsNullOrWhiteSpace(CurrentMessage); private bool CanAbort() => IsConnected && IsBusy; @@ -387,6 +437,43 @@ private bool TryDequeuePendingLocal(string content, out PendingLocalMessage pend return false; } + private bool TryDiscardCancelledQueuedMessage(string content) + { + var now = DateTimeOffset.Now; + _cancelledQueuedMessages.RemoveAll(x => x.ExpiresAt <= now); + + var index = _cancelledQueuedMessages.FindIndex(x => + string.Equals(x.Content, content, StringComparison.Ordinal)); + if (index < 0) return false; + + _cancelledQueuedMessages.RemoveAt(index); + return true; + } + + private void CancelQueuedMessagesLocally() + { + var expiresAt = DateTimeOffset.Now.AddSeconds(30); + foreach (var pending in _pendingLocalMessages.Where(x => x.Mode == ChatSendMode.Queue)) + _cancelledQueuedMessages.Add(new CancelledQueuedMessage(pending.Content, expiresAt)); + + var remaining = _pendingLocalMessages.Where(x => x.Mode != ChatSendMode.Queue).ToArray(); + _pendingLocalMessages.Clear(); + foreach (var pending in remaining) + _pendingLocalMessages.Enqueue(pending); + + QueuedMessages.Clear(); + } + + private void RemovePendingLocalMessage(ChatMessageUserViewModel message) + { + var remaining = _pendingLocalMessages + .Where(x => !ReferenceEquals(x.QueuedView, message)) + .ToArray(); + _pendingLocalMessages.Clear(); + foreach (var pending in remaining) + _pendingLocalMessages.Enqueue(pending); + } + private void AddMessage(IChatMessage message) { if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) @@ -544,6 +631,8 @@ private void OnEventReceived(object? sender, ChatEvent e) return; } + if (TryDiscardCancelledQueuedMessage(x.Content)) return; + // Originates from a remote session user; show it and mark busy. AddMessage(new ChatMessageUserViewModel(x.Content)); IsBusy = true; @@ -599,6 +688,9 @@ private void OnEventReceived(object? sender, ChatEvent e) Dispatcher.UIThread.Post(() => { Messages.Clear(); + QueuedMessages.Clear(); + _pendingLocalMessages.Clear(); + _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); if (SelectedChatService != null) @@ -623,6 +715,9 @@ private void OnSessionReset(object? sender, EventArgs e) Dispatcher.UIThread.Post(() => { Messages.Clear(); + QueuedMessages.Clear(); + _pendingLocalMessages.Clear(); + _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); diff --git a/src/OneWare.Chat/Views/ChatView.axaml b/src/OneWare.Chat/Views/ChatView.axaml index 035163fb..65c08685 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml +++ b/src/OneWare.Chat/Views/ChatView.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:OneWare.Chat.ViewModels" + xmlns:chatMessages="clr-namespace:OneWare.Chat.ViewModels.ChatMessages" xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials" xmlns:behaviors="clr-namespace:OneWare.Chat.Behaviors" xmlns:behaviors1="clr-namespace:OneWare.Essentials.Behaviors;assembly=OneWare.Essentials" @@ -93,6 +94,24 @@ + + + + + + + + + + diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 2433a48b..69d6798b 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -1003,6 +1003,20 @@ public async Task AbortAsync() await _session.AbortAsync(); } + public async Task RemoveMostRecentQueuedMessageAsync() + { + if (_session == null) return false; + var result = await _session.Rpc.Queue.RemoveMostRecentAsync(); + return result.Removed; + } + + public async Task ClearQueuedMessagesAsync() + { + if (_session == null) return false; + await _session.Rpc.Queue.ClearAsync(); + return true; + } + public async Task NewChatAsync() { ReleasePendingInputRequests(); diff --git a/src/OneWare.Essentials/Services/IChatService.cs b/src/OneWare.Essentials/Services/IChatService.cs index e32e1db7..f84672b2 100644 --- a/src/OneWare.Essentials/Services/IChatService.cs +++ b/src/OneWare.Essentials/Services/IChatService.cs @@ -67,6 +67,17 @@ public interface IChatService : INotifyPropertyChanged, IAsyncDisposable /// Aborts the current request. /// Task AbortAsync(); + + /// + /// Removes the most recently queued message, if the service supports queue management. + /// + Task RemoveMostRecentQueuedMessageAsync() => Task.FromResult(false); + + /// + /// Clears all queued messages, returning whether the service queue was cleared. + /// + Task ClearQueuedMessagesAsync() => Task.FromResult(false); + /// /// Starts a new chat session. /// diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 3a71080b..77265888 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -147,6 +147,7 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var commandSent = false; var markerPrefix = PromptMarkerPrefix; var commandToSend = command; + var commandEchoPrefix = GetCommandEchoPrefix(command); string? markerCommand = null; if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -183,7 +184,21 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg output.Append(text); current = output.ToString(); - while (TryExtractOscMarker(current, markerPrefix, out var exitCode, out var cleaned)) + var markerFound = TryExtractOscMarker(current, markerPrefix, out var exitCode, out var cleaned); + var commandEchoIndex = commandEchoPrefix.Length == 0 + ? -1 + : current.IndexOf(commandEchoPrefix, StringComparison.Ordinal); + if (!markerFound && markerPrefix != PromptMarkerPrefix && commandEchoIndex >= 0) + { + // The shell's prompt hook is an independent completion signal. Prefer the + // invocation-specific marker. Only consider prompt markers that occur after + // this command's echo so a late marker from a pooled terminal cannot complete + // the wrong invocation. + markerFound = TryExtractOscMarker(current, PromptMarkerPrefix, out exitCode, out cleaned, + commandEchoIndex + commandEchoPrefix.Length); + } + + if (markerFound) { while (TryExtractOscMarker(cleaned, PromptMarkerPrefix, out _, out var promptCleaned)) cleaned = promptCleaned; @@ -192,10 +207,8 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg output.Clear(); output.Append(cleaned); - if (!commandSent) continue; - - completedExitCode = exitCode; - break; + if (commandSent) + completedExitCode = exitCode; } } @@ -212,6 +225,7 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg terminal.Connection.DataReceived += OnDataReceived; terminal.Connection.Closed += OnConnectionClosed; + if (markerCommand != null) { // The shell echoes queued input before executing it. Hide the internal marker @@ -220,8 +234,11 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg terminal.SuppressEcho(Encoding.UTF8.GetBytes(markerCommand)); } - commandSent = true; - terminal.Send(commandToSend); + if (!resultTcs.Task.IsCompleted && !cancellationToken.IsCancellationRequested) + { + commandSent = true; + terminal.Send(commandToSend); + } TerminalExecutionResult result; @@ -235,17 +252,20 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg lock (outputLock) partialOutput = output.ToString(); - // The command exceeded its timeout or was cancelled but is still running - // in the shell. First try a gentle interrupt (Ctrl+C) so the shell returns - // to a usable prompt and the terminal stays reusable. - var recovered = await TryRecoverPromptAsync(terminal, resultTcs.Task); - if (!recovered) + if (commandSent) { - // The interrupt did not free the shell (the process ignores SIGINT or is - // itself hung). Forcibly kill the process tree and discard this terminal - // so it is never reused in a stuck state by a subsequent command. - terminal.KillProcess(); - DiscardAutomationTerminal(terminal); + // The command exceeded its timeout or was cancelled but is still running + // in the shell. First try a gentle interrupt (Ctrl+C) so the shell returns + // to a usable prompt and the terminal stays reusable. + var recovered = await TryRecoverPromptAsync(terminal, resultTcs.Task); + if (!recovered) + { + // The interrupt did not free the shell (the process ignores SIGINT or is + // itself hung). Forcibly kill the process tree and discard this terminal + // so it is never reused in a stuck state by a subsequent command. + terminal.KillProcess(); + DiscardAutomationTerminal(terminal); + } } result = new TerminalExecutionResult(partialOutput, -1, true); @@ -336,34 +356,55 @@ private static async Task WaitForResultAsync( // prompt marker is injected via terminal environment during shell startup private static bool TryExtractOscMarker(string current, string markerPrefix, out int exitCode, - out string cleanedOutput) + out string cleanedOutput, int searchIndex = 0) { exitCode = 0; cleanedOutput = current; - var markerIndex = current.IndexOf(markerPrefix, StringComparison.Ordinal); - if (markerIndex < 0) return false; + while (true) + { + var markerIndex = current.IndexOf(markerPrefix, searchIndex, StringComparison.Ordinal); + if (markerIndex < 0) return false; - var belIndex = current.IndexOf('\u0007', markerIndex); - var stIndex = current.IndexOf("\u001b\\", markerIndex, StringComparison.Ordinal); + var belIndex = current.IndexOf('\u0007', markerIndex); + var stIndex = current.IndexOf("\u001b\\", markerIndex, StringComparison.Ordinal); - var endIndex = belIndex; - var endLength = 1; + var endIndex = belIndex; + var endLength = 1; - if (endIndex < 0 || (stIndex >= 0 && stIndex < endIndex)) - { - endIndex = stIndex; - endLength = 2; + if (endIndex < 0 || (stIndex >= 0 && stIndex < endIndex)) + { + endIndex = stIndex; + endLength = 2; + } + + if (endIndex < 0) return false; + + var markerContent = current.Substring(markerIndex + markerPrefix.Length, + endIndex - (markerIndex + markerPrefix.Length)); + if (!int.TryParse(markerContent, out exitCode)) + { + searchIndex = endIndex + endLength; + continue; + } + + cleanedOutput = current.Remove(markerIndex, endIndex - markerIndex + endLength); + return true; } + } - if (endIndex < 0) return false; + private static string GetCommandEchoPrefix(string command) + { + var firstLine = command.TrimStart(); + var lineEnd = firstLine.IndexOfAny(['\r', '\n']); + if (lineEnd >= 0) + firstLine = firstLine[..lineEnd]; - var markerContent = current.Substring(markerIndex + markerPrefix.Length, - endIndex - (markerIndex + markerPrefix.Length)); - int.TryParse(markerContent, out exitCode); + const int minimumDistinctiveLength = 16; + const int maximumPrefixLength = 48; + if (firstLine.Length < minimumDistinctiveLength) return string.Empty; - cleanedOutput = current.Remove(markerIndex, endIndex - markerIndex + endLength); - return true; + return firstLine[..Math.Min(firstLine.Length, maximumPrefixLength)]; } public async Task ExecuteInTerminalAsync(string command, string id, From 8696773624318a4811c773d1c25648229c0f6efc Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 17:19:30 +0200 Subject: [PATCH 2/3] Support pasting images into Copilot chat Convert clipboard bitmaps into transient PNG attachments while preserving normal platform text paste behavior and queued-message lifetimes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Chat/Views/ChatView.axaml.cs | 40 ++- .../Services/CopilotChatService.cs | 232 +++++++++++++++++- .../Services/IChatService.cs | 6 + 3 files changed, 264 insertions(+), 14 deletions(-) diff --git a/src/OneWare.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs index 8fc6e5af..7f1610b4 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml.cs +++ b/src/OneWare.Chat/Views/ChatView.axaml.cs @@ -3,6 +3,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Threading; using OneWare.Chat.ViewModels; @@ -54,13 +55,30 @@ private void ScrollToEndDeferred() Dispatcher.UIThread.Post(() => ScrollViewer.ScrollToEnd(), DispatcherPriority.Background); } - private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) + private async void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) { - if (e.Key is not (Key.Enter or Key.Return)) return; if (DataContext is not ChatViewModel vm) return; var modifiers = e.KeyModifiers; + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.PlatformSettings?.HotkeyConfiguration.Paste.Any(gesture => gesture.Matches(e)) == true) + { + e.Handled = true; + try + { + await PasteClipboardAsync(vm, topLevel); + } + catch (TimeoutException) + { + // Match Avalonia TextBox behavior when the platform clipboard is temporarily busy. + } + + return; + } + + if (e.Key is not (Key.Enter or Key.Return)) return; + // Shift+Enter inserts a newline — let the TextBox handle it. if (modifiers.HasFlag(KeyModifiers.Shift)) return; @@ -81,6 +99,24 @@ private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) Execute(vm.IsBusy ? vm.SteerCommand : vm.SendCommand, e); } + private async Task PasteClipboardAsync(ChatViewModel viewModel, TopLevel topLevel) + { + if (topLevel.Clipboard == null) return; + + if (viewModel.SelectedChatService != null && + await viewModel.SelectedChatService.TryAddClipboardAttachmentAsync(topLevel)) + return; + + var clipboardText = await topLevel.Clipboard.TryGetTextAsync(); + if (string.IsNullOrEmpty(clipboardText)) return; + + var text = CommandBox.Text ?? string.Empty; + var selectionStart = Math.Min(CommandBox.SelectionStart, CommandBox.SelectionEnd); + var selectionEnd = Math.Max(CommandBox.SelectionStart, CommandBox.SelectionEnd); + CommandBox.Text = text[..selectionStart] + clipboardText + text[selectionEnd..]; + CommandBox.CaretIndex = selectionStart + clipboardText.Length; + } + private static void Execute(System.Windows.Input.ICommand command, KeyEventArgs e) { e.Handled = true; diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 69d6798b..2d205dd4 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; +using Avalonia.Input.Platform; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -51,6 +52,11 @@ public sealed class CopilotChatService( private string? _requestedSessionId; private readonly List> _pendingInputRequests = new(); private readonly HashSet _sessionApprovedTools = new(); + private readonly HashSet _temporaryAttachmentPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _submittedAttachmentsLock = new(); + private readonly List _submittedAttachmentBatches = []; + + private sealed record SubmittedAttachmentBatch(string Content, ChatSendMode Mode, string[] TemporaryPaths); // Usage tracking public long LastInputTokens @@ -557,6 +563,7 @@ private void RemoveAttachment(CopilotAttachmentViewModel attachment) else { Attachments.Remove(attachment); + DeleteTemporaryAttachment(attachment.FilePath); } } @@ -579,24 +586,196 @@ private async Task AddAttachmentAsync(Visual? source) } } - private IList? CollectAttachments() + public async Task TryAddClipboardAttachmentAsync(TopLevel topLevel) + { + if (topLevel.Clipboard is not { } clipboard) return false; + + using var bitmap = await clipboard.TryGetBitmapAsync(); + if (bitmap == null) return false; + + var attachmentDirectory = Path.Combine(paths.SessionDirectory, "CopilotAttachments"); + var filePath = Path.Combine(attachmentDirectory, $"clipboard-{Guid.NewGuid():N}.png"); + try + { + Directory.CreateDirectory(attachmentDirectory); + bitmap.Save(filePath); + } + catch (IOException ex) + { + ContainerLocator.Container.Resolve() + ?.LogWarning(ex, "Failed to save clipboard image for Copilot."); + TryDeleteFile(filePath); + return false; + } + catch (UnauthorizedAccessException ex) + { + ContainerLocator.Container.Resolve() + ?.LogWarning(ex, "Failed to save clipboard image for Copilot."); + TryDeleteFile(filePath); + return false; + } + + lock (_submittedAttachmentsLock) + _temporaryAttachmentPaths.Add(filePath); + + Attachments.Add(new CopilotAttachmentViewModel( + filePath, + $"Pasted image {Attachments.Count + 1}", + isActiveFile: false, + RemoveAttachment, + iconResourceKey: "VsImageLib.Image16X")); + + return true; + } + + private (IList? Attachments, CopilotAttachmentViewModel[] ExplicitAttachments, + string[] TemporaryPaths, bool IncludesActiveFile) CollectAttachments() { // Rebuild the active-file chip from the live editor so the sent payload reflects the // current selection even if the chip display lagged. RefreshActiveFileAttachment(focusChanged: false); + var activeFileAttachment = ActiveFileAttachment; + var explicitAttachments = Attachments.ToArray(); var result = new List(); - if (ActiveFileAttachment != null) result.Add(ActiveFileAttachment.ToSdkAttachment()); - result.AddRange(Attachments.Select(a => a.ToSdkAttachment())); + if (activeFileAttachment != null) result.Add(activeFileAttachment.ToSdkAttachment()); + result.AddRange(explicitAttachments.Select(a => a.ToSdkAttachment())); - return result.Count > 0 ? result : null; + return ( + result.Count > 0 ? result : null, + explicitAttachments, + explicitAttachments.Select(x => x.FilePath) + .Where(IsTemporaryAttachment) + .ToArray(), + activeFileAttachment != null); } - private void ClearAttachmentsAfterSend() + private void ClearAttachmentsAfterSend(CopilotAttachmentViewModel[] submittedAttachments, + bool includedActiveFile) { - Attachments.Clear(); - _activeFileDismissed = false; - RefreshActiveFileAttachment(focusChanged: false); + foreach (var attachment in submittedAttachments) + Attachments.Remove(attachment); + + if (includedActiveFile) + { + _activeFileDismissed = false; + RefreshActiveFileAttachment(focusChanged: false); + } + } + + private void RestoreAttachmentsAfterFailedSend(CopilotAttachmentViewModel[] submittedAttachments, + string[] temporaryPaths, bool includedActiveFile) + { + for (var index = submittedAttachments.Length - 1; index >= 0; index--) + { + var attachment = submittedAttachments[index]; + if (temporaryPaths.Contains(attachment.FilePath, StringComparer.OrdinalIgnoreCase) && + !IsTemporaryAttachment(attachment.FilePath)) + continue; + + if (!Attachments.Contains(attachment)) + Attachments.Insert(0, attachment); + } + + if (includedActiveFile) + RefreshActiveFileAttachment(focusChanged: false); + } + + private void DeleteTemporaryAttachment(string filePath) + { + lock (_submittedAttachmentsLock) + { + if (!_temporaryAttachmentPaths.Contains(filePath)) return; + } + + if (!TryDeleteFile(filePath)) return; + + lock (_submittedAttachmentsLock) + _temporaryAttachmentPaths.Remove(filePath); + } + + private bool IsTemporaryAttachment(string filePath) + { + lock (_submittedAttachmentsLock) + return _temporaryAttachmentPaths.Contains(filePath); + } + + private string[] GetTemporaryAttachmentPaths() + { + lock (_submittedAttachmentsLock) + return _temporaryAttachmentPaths.ToArray(); + } + + private static bool TryDeleteFile(string filePath) + { + try + { + File.Delete(filePath); + return true; + } + catch (IOException ex) + { + ContainerLocator.Container.Resolve() + ?.LogWarning(ex, "Failed to delete temporary Copilot attachment {FilePath}.", filePath); + return false; + } + catch (UnauthorizedAccessException ex) + { + ContainerLocator.Container.Resolve() + ?.LogWarning(ex, "Failed to delete temporary Copilot attachment {FilePath}.", filePath); + return false; + } + } + + private void DeleteSubmittedAttachments(string content) + { + SubmittedAttachmentBatch? batch; + lock (_submittedAttachmentsLock) + { + batch = _submittedAttachmentBatches.FirstOrDefault(x => + string.Equals(x.Content, content, StringComparison.Ordinal)); + if (batch != null) + _submittedAttachmentBatches.Remove(batch); + } + + if (batch != null) + DeleteTemporaryAttachments(batch); + } + + private void DeleteMostRecentSubmittedAttachments(ChatSendMode mode) + { + SubmittedAttachmentBatch? batch; + lock (_submittedAttachmentsLock) + { + batch = _submittedAttachmentBatches.LastOrDefault(x => x.Mode == mode); + if (batch != null) + _submittedAttachmentBatches.Remove(batch); + } + + if (batch != null) + DeleteTemporaryAttachments(batch); + } + + private void DeleteSubmittedAttachments(ChatSendMode? mode = null) + { + SubmittedAttachmentBatch[] batches; + lock (_submittedAttachmentsLock) + { + batches = _submittedAttachmentBatches + .Where(x => mode == null || x.Mode == mode) + .ToArray(); + foreach (var batch in batches) + _submittedAttachmentBatches.Remove(batch); + } + + foreach (var batch in batches) + DeleteTemporaryAttachments(batch); + } + + private void DeleteTemporaryAttachments(SubmittedAttachmentBatch batch) + { + foreach (var filePath in batch.TemporaryPaths) + DeleteTemporaryAttachment(filePath); } #endregion @@ -979,8 +1158,8 @@ public async Task SendAsync(string prompt, ChatSendMode mode) if (_session == null) return; var options = new MessageOptions { Prompt = prompt }; - var attachments = CollectAttachments(); - if (attachments != null) options.Attachments = attachments; + var attachmentSnapshot = CollectAttachments(); + if (attachmentSnapshot.Attachments != null) options.Attachments = attachmentSnapshot.Attachments; var sdkMode = mode switch { @@ -990,9 +1169,29 @@ public async Task SendAsync(string prompt, ChatSendMode mode) }; if (sdkMode != null) options.Mode = sdkMode; - await _session.SendAsync(options).ConfigureAwait(false); + var submittedBatch = new SubmittedAttachmentBatch(prompt, mode, attachmentSnapshot.TemporaryPaths); + lock (_submittedAttachmentsLock) + _submittedAttachmentBatches.Add(submittedBatch); + + await Dispatcher.UIThread.InvokeAsync(() => ClearAttachmentsAfterSend( + attachmentSnapshot.ExplicitAttachments, + attachmentSnapshot.IncludesActiveFile)); - Dispatcher.UIThread.Post(ClearAttachmentsAfterSend); + try + { + await _session.SendAsync(options).ConfigureAwait(false); + } + catch + { + lock (_submittedAttachmentsLock) + _submittedAttachmentBatches.Remove(submittedBatch); + + await Dispatcher.UIThread.InvokeAsync(() => RestoreAttachmentsAfterFailedSend( + attachmentSnapshot.ExplicitAttachments, + attachmentSnapshot.TemporaryPaths, + attachmentSnapshot.IncludesActiveFile)); + throw; + } } public async Task AbortAsync() @@ -1007,6 +1206,8 @@ public async Task RemoveMostRecentQueuedMessageAsync() { if (_session == null) return false; var result = await _session.Rpc.Queue.RemoveMostRecentAsync(); + if (result.Removed) + DeleteMostRecentSubmittedAttachments(ChatSendMode.Queue); return result.Removed; } @@ -1014,12 +1215,14 @@ public async Task ClearQueuedMessagesAsync() { if (_session == null) return false; await _session.Rpc.Queue.ClearAsync(); + DeleteSubmittedAttachments(ChatSendMode.Queue); return true; } public async Task NewChatAsync() { ReleasePendingInputRequests(); + DeleteSubmittedAttachments(); lock (_sessionApprovedTools) _sessionApprovedTools.Clear(); _requestedSessionId = null; @@ -1048,6 +1251,10 @@ public async Task LoadSessionAsync(string sessionId) public async ValueTask DisposeAsync() { ReleasePendingInputRequests(); + DeleteSubmittedAttachments(); + + foreach (var filePath in GetTemporaryAttachmentPaths()) + DeleteTemporaryAttachment(filePath); if (_attachmentTrackingInitialized) { @@ -1151,6 +1358,7 @@ private void HandleSessionEvent(SessionEvent evt) } case UserMessageEvent x: { + DeleteSubmittedAttachments(x.Data.Content); EventReceived?.Invoke(this, new ChatUserMessageEvent(x.Data.Content)); break; diff --git a/src/OneWare.Essentials/Services/IChatService.cs b/src/OneWare.Essentials/Services/IChatService.cs index f84672b2..64dec434 100644 --- a/src/OneWare.Essentials/Services/IChatService.cs +++ b/src/OneWare.Essentials/Services/IChatService.cs @@ -63,6 +63,12 @@ public interface IChatService : INotifyPropertyChanged, IAsyncDisposable /// should override this. /// Task SendAsync(string prompt, ChatSendMode mode) => SendAsync(prompt); + + /// + /// Adds an image from the clipboard as an attachment, if supported. + /// + Task TryAddClipboardAttachmentAsync(TopLevel topLevel) => Task.FromResult(false); + /// /// Aborts the current request. /// From 5ad101b7b66a850e508f8b4961632beeff0aeee4 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 17:51:51 +0200 Subject: [PATCH 3/3] Recover missed AI terminal completion Probe the completion marker again after Unix reports the shell owns the PTY foreground and output has settled. Display canceled tools without an exception stack trace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/AiFunctionProvider.cs | 2 +- .../Provider/IPseudoTerminal.cs | 1 + .../Provider/PseudoTerminalConnection.cs | 2 + src/OneWare.Terminal/Provider/Unix/Native.cs | 3 ++ .../Provider/Unix/UnixPseudoTerminal.cs | 10 +++++ .../Win32/Win32ConPtyPseudoTerminal.cs | 2 + .../ViewModels/TerminalViewModel.cs | 3 ++ .../ViewModels/TerminalManagerViewModel.cs | 44 +++++++++++++++++++ 8 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index f6198ee6..4d36832d 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -143,7 +143,7 @@ await Dispatcher.UIThread.InvokeAsync(() => { Id = id, Result = exception == null, - ToolOutput = exception?.ToString() + ToolOutput = exception is OperationCanceledException ? "Cancelled." : exception?.ToString() })); } diff --git a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs index c6474ed2..b73402c6 100644 --- a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs @@ -5,6 +5,7 @@ namespace OneWare.Terminal.Provider; public interface IPseudoTerminal : IDisposable { Process Process { get; } + bool? IsShellForeground { get; } void SetSize(int columns, int rows); Task WriteAsync(byte[] buffer, int offset, int count); diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index a79b4e8e..247e96bc 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -19,6 +19,8 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I public event EventHandler? Closed; + public bool? IsShellForeground => terminal.IsShellForeground; + public bool Connect() { _cancellationSource = new CancellationTokenSource(); diff --git a/src/OneWare.Terminal/Provider/Unix/Native.cs b/src/OneWare.Terminal/Provider/Unix/Native.cs index 149ffd93..267bc2cc 100644 --- a/src/OneWare.Terminal/Provider/Unix/Native.cs +++ b/src/OneWare.Terminal/Provider/Unix/Native.cs @@ -63,6 +63,8 @@ public delegate int posix_spawnp(out IntPtr pid, string path, IntPtr fileActions public delegate void setsid(); + public delegate int tcgetpgrp(int fd); + public delegate int unlockpt(int fd); public delegate int write(int fd, IntPtr buffer, int length); @@ -152,6 +154,7 @@ internal static class Native public static NativeDelegates.dup dup = NativeDelegates.GetProc(); public static NativeDelegates.dup2 dup2 = NativeDelegates.GetProc(); public static NativeDelegates.setsid setsid = NativeDelegates.GetProc(); + public static NativeDelegates.tcgetpgrp tcgetpgrp = NativeDelegates.GetProc(); public static NativeDelegates.ioctl ioctl = NativeDelegates.GetProc(); public static NativeDelegates.kill kill = NativeDelegates.GetProc(); public static NativeDelegates.execve execve = NativeDelegates.GetProc(); diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs index 2ddefc43..70089283 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs @@ -21,6 +21,16 @@ public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout) public Process Process { get; } + public bool? IsShellForeground + { + get + { + if (_isDisposed || Process.HasExited) return null; + var foregroundProcessGroup = Native.tcgetpgrp(_cfg); + return foregroundProcessGroup >= 0 ? foregroundProcessGroup == Process.Id : null; + } + } + public void Dispose() { if (_isDisposed) return; diff --git a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs index 05bec604..e8fce1c8 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs @@ -21,6 +21,8 @@ public Win32ConPtyPseudoTerminal(Process process, IntPtr pseudoConsole, SafeFile public Process Process { get; } + public bool? IsShellForeground => null; + public void SetSize(int columns, int rows) { if (_pseudoConsole != IntPtr.Zero && columns >= 1 && rows >= 1) diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 1e569259..855b81fb 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -151,6 +151,9 @@ public void KillProcess() if (Connection is PseudoTerminalConnection ptc) ptc.KillProcess(); } + public bool? IsShellForeground => + Connection is PseudoTerminalConnection ptc ? ptc.IsShellForeground : null; + public void SuppressEcho(byte[] data) { if (Connection is IOutputSuppressor suppressor) diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 77265888..284972d1 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Diagnostics; using System.Linq; using System.Text; using System.Reactive.Linq; @@ -145,6 +146,7 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var outputLock = new object(); var resultTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var commandSent = false; + var lastOutputTimestamp = Stopwatch.GetTimestamp(); var markerPrefix = PromptMarkerPrefix; var commandToSend = command; var commandEchoPrefix = GetCommandEchoPrefix(command); @@ -175,6 +177,7 @@ void OnConnectionClosed(object? sender, EventArgs args) void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) { + Interlocked.Exchange(ref lastOutputTimestamp, Stopwatch.GetTimestamp()); var text = Encoding.UTF8.GetString(args.Data); string current; int? completedExitCode = null; @@ -241,6 +244,16 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg } TerminalExecutionResult result; + using var completionProbeCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var completionProbeTask = markerCommand == null + ? Task.CompletedTask + : ProbeForMissedCompletionAsync( + terminal, + markerCommand, + resultTcs.Task, + () => Volatile.Read(ref lastOutputTimestamp), + completionProbeCancellation.Token); try { @@ -272,6 +285,8 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg } finally { + await completionProbeCancellation.CancelAsync(); + await completionProbeTask; terminal.Connection.DataReceived -= OnDataReceived; terminal.Connection.Closed -= OnConnectionClosed; if (closeWhenDone) terminal.Close(); @@ -280,6 +295,35 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg return result; } + private static async Task ProbeForMissedCompletionAsync( + TerminalViewModel terminal, + string markerCommand, + Task resultTask, + Func getLastOutputTimestamp, + CancellationToken cancellationToken) + { + try + { + while (!resultTask.IsCompleted) + { + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + if (resultTask.IsCompleted) return; + if (Stopwatch.GetElapsedTime(getLastOutputTimestamp()) < TimeSpan.FromSeconds(1)) continue; + if (terminal.IsShellForeground != true) continue; + + // The command has returned control to the shell but its queued completion marker + // was not observed. Re-send the same marker once; if a shell builtin is still + // executing, the terminal queues it until that builtin returns. + terminal.SuppressEcho(Encoding.UTF8.GetBytes(markerCommand)); + terminal.Send(markerCommand); + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + private static async Task TryRecoverPromptAsync(TerminalViewModel terminal, Task resultTask) {