diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index f6198ee61..4d36832df 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.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 991461ea7..88aae83ad 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 035163fbd..65c086854 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.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs index 8fc6e5afa..7f1610b49 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 2433a48be..2d205dd43 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() @@ -1003,9 +1202,27 @@ public async Task AbortAsync() await _session.AbortAsync(); } + 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; + } + + 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; @@ -1034,6 +1251,10 @@ public async Task LoadSessionAsync(string sessionId) public async ValueTask DisposeAsync() { ReleasePendingInputRequests(); + DeleteSubmittedAttachments(); + + foreach (var filePath in GetTemporaryAttachmentPaths()) + DeleteTemporaryAttachment(filePath); if (_attachmentTrackingInitialized) { @@ -1137,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 e32e1db73..64dec434a 100644 --- a/src/OneWare.Essentials/Services/IChatService.cs +++ b/src/OneWare.Essentials/Services/IChatService.cs @@ -63,10 +63,27 @@ 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. /// 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.Terminal/Provider/IPseudoTerminal.cs b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs index c6474ed2c..b73402c68 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 a79b4e8e6..247e96bcd 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 149ffd934..267bc2ccf 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 2ddefc43b..700892834 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 05bec604f..e8fce1c8f 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 1e569259c..855b81fbb 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 3a71080b6..284972d1e 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,8 +146,10 @@ 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); string? markerCommand = null; if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -174,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; @@ -183,7 +187,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 +210,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 +228,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,10 +237,23 @@ 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; + using var completionProbeCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var completionProbeTask = markerCommand == null + ? Task.CompletedTask + : ProbeForMissedCompletionAsync( + terminal, + markerCommand, + resultTcs.Task, + () => Volatile.Read(ref lastOutputTimestamp), + completionProbeCancellation.Token); try { @@ -235,23 +265,28 @@ 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); } finally { + await completionProbeCancellation.CancelAsync(); + await completionProbeTask; terminal.Connection.DataReceived -= OnDataReceived; terminal.Connection.Closed -= OnConnectionClosed; if (closeWhenDone) terminal.Close(); @@ -260,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) { @@ -336,34 +400,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,