diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index f6198ee61..47f594348 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -94,16 +94,21 @@ public ICollection GetTools() public void CancelActiveFunctions() { - foreach (var cancellationSource in _activeFunctions.Values) + foreach (var id in _activeFunctions.Keys) + CancelFunction(id); + } + + public void CancelFunction(string id) + { + if (!_activeFunctions.TryGetValue(id, out var cancellationSource)) return; + + try { - try - { - cancellationSource.Cancel(); - } - catch (ObjectDisposedException) - { - // The function completed while cancellation was being requested. - } + cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // The function completed while cancellation was being requested. } } @@ -143,7 +148,7 @@ await Dispatcher.UIThread.InvokeAsync(() => { Id = id, Result = exception == null, - ToolOutput = exception?.ToString() + ToolOutput = exception is OperationCanceledException ? "Cancelled." : exception?.ToString() })); } @@ -190,6 +195,13 @@ private sealed class RegisteredOneWareAiFunction( return await InvokeDefinitionAsync(context, arguments, functionCancellationSource.Token); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Only this tool call was cancelled (e.g. via its stop button) — report it to the + // model as a result instead of failing the whole chat turn. + exception = ex; + return "The tool call was stopped by the user before it finished."; + } catch (Exception ex) { exception = ex; diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs index 584477d76..ad6415045 100644 --- a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageToolViewModel.cs @@ -1,5 +1,6 @@ using System.Runtime.Serialization; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using OneWare.Essentials.Controls; namespace OneWare.Chat.ViewModels.ChatMessages; @@ -32,6 +33,13 @@ public bool IsToolRunning get; set => SetProperty(ref field, value); } + + /// Cancels this tool invocation while it is running. + public IRelayCommand? StopCommand + { + get; + set => SetProperty(ref field, value); + } [DataMember] public bool IsSuccessful diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 991461ea7..0ccaf0663 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -19,6 +19,7 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService public const string IconKey = "Bootstrap.ChatLeft"; private readonly IMainDockService _mainDockService; + private readonly IAiFunctionProvider _aiFunctionProvider; private readonly string _statePath; private readonly string _historyRootPath; @@ -35,6 +36,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 +45,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, @@ -60,6 +64,7 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma aiFunctionProvider.FunctionCompleted += OnFunctionCompleted; aiFunctionProvider.FunctionProgress += OnFunctionProgress; + _aiFunctionProvider = aiFunctionProvider; _mainDockService = mainDockService; var chatDirectory = Path.Combine(paths.AppDataDirectory, "Chat"); @@ -73,8 +78,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 +219,8 @@ public IChatService? SelectedChatService public AsyncRelayCommand AbortCommand { get; } + public AsyncRelayCommand RemoveQueuedMessageCommand { get; } + public AsyncRelayCommand InitializeCurrentCommand { get; } public override void InitializeContent() @@ -270,6 +280,7 @@ private async Task NewChatAsync() Messages.Clear(); QueuedMessages.Clear(); _pendingLocalMessages.Clear(); + _cancelledQueuedMessages.Clear(); WorkingStatusText = DefaultWorkingStatus; _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); @@ -338,6 +349,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 +373,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 +439,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 +633,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 +690,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 +717,9 @@ private void OnSessionReset(object? sender, EventArgs e) Dispatcher.UIThread.Post(() => { Messages.Clear(); + QueuedMessages.Clear(); + _pendingLocalMessages.Clear(); + _cancelledQueuedMessages.Clear(); _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); @@ -645,6 +742,9 @@ private void OnFunctionStarted(object? sender, AiFunctionStartedEvent function) IsToolRunning = true, ToolOutput = $"{function.Detail}" }; + newMessage.StopCommand = new RelayCommand( + () => _aiFunctionProvider.CancelFunction(newMessage.Id), + () => newMessage.IsToolRunning); AddMessage(newMessage); ContentAdded?.Invoke(this, EventArgs.Empty); } @@ -655,6 +755,7 @@ private void OnFunctionCompleted(object? sender, AiFunctionCompletedEvent functi if (toolFinished == null) return; toolFinished.IsToolRunning = false; + toolFinished.StopCommand = null; toolFinished.IsSuccessful = function.Result; if (!string.IsNullOrWhiteSpace(function.ToolOutput)) { diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageToolView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageToolView.axaml index 4b0705367..ef848badc 100644 --- a/src/OneWare.Chat/Views/ChatMessages/ChatMessageToolView.axaml +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageToolView.axaml @@ -11,7 +11,7 @@ - + @@ -22,6 +22,13 @@ + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index 4b00f76f0..6e4e85af6 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -58,13 +58,13 @@ public class CopilotModule : OneWareModuleBase [ new PackageVersion() { - Version = "1.0.70", + Version = "1.0.74", Targets = [ new PackageTarget() { Target = "win-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-win32-x64.zip", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-win32-x64.zip", AutoSetting = [ new PackageAutoSetting @@ -77,7 +77,7 @@ public class CopilotModule : OneWareModuleBase new PackageTarget() { Target = "win-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-win32-arm64.zip", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-win32-arm64.zip", AutoSetting = [ new PackageAutoSetting @@ -90,7 +90,7 @@ public class CopilotModule : OneWareModuleBase new PackageTarget() { Target = "linux-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-linux-x64.tar.gz", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-linux-x64.tar.gz", AutoSetting = [ new PackageAutoSetting @@ -103,7 +103,7 @@ public class CopilotModule : OneWareModuleBase new PackageTarget() { Target = "linux-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-linux-arm64.tar.gz", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-linux-arm64.tar.gz", AutoSetting = [ new PackageAutoSetting @@ -116,7 +116,7 @@ public class CopilotModule : OneWareModuleBase new PackageTarget() { Target = "osx-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-darwin-x64.tar.gz", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-darwin-x64.tar.gz", AutoSetting = [ new PackageAutoSetting @@ -129,7 +129,7 @@ public class CopilotModule : OneWareModuleBase new PackageTarget() { Target = "osx-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.70/copilot-darwin-arm64.tar.gz", + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.74/copilot-darwin-arm64.tar.gz", AutoSetting = [ new PackageAutoSetting diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj index 541945d9f..e75411a6d 100644 --- a/src/OneWare.Copilot/OneWare.Copilot.csproj +++ b/src/OneWare.Copilot/OneWare.Copilot.csproj @@ -12,7 +12,7 @@ - + \ No newline at end of file diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 2433a48be..69d6798b8 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/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs index 772796708..38607e15c 100644 --- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs +++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs @@ -24,6 +24,11 @@ void CancelActiveFunctions() { } + /// Cancels the single running AI function with the given invocation id. + void CancelFunction(string id) + { + } + /// Registers an additional AI function (e.g. from plugins). void RegisterFunction(IOneWareAiFunction function); diff --git a/src/OneWare.Essentials/Services/IChatService.cs b/src/OneWare.Essentials/Services/IChatService.cs index e32e1db73..f84672b26 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.Terminal/Provider/IPseudoTerminal.cs b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs index c6474ed2c..1aa4f43da 100644 --- a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs @@ -10,4 +10,11 @@ public interface IPseudoTerminal : IDisposable Task WriteAsync(byte[] buffer, int offset, int count); Task ReadAsync(byte[] buffer, int offset, int count); + + /// + /// Returns the exit code of the terminal's child process once it has exited, + /// or if it is still running or the code cannot be determined. + /// May block briefly while the process finishes terminating. + /// + int? GetExitCode(); } \ No newline at end of file diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index a79b4e8e6..9d2a2817a 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -1,53 +1,57 @@ -using System.Collections.Generic; using VtNetCore.Avalonia; namespace OneWare.Terminal.Provider; -public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, IOutputFilter, IOutputSuppressor, IDisposable +public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, IDisposable { - private const int TolerantSuppressionPrefixLength = 4; + private readonly ShellIntegrationParser _parser = new(); private CancellationTokenSource? _cancellationSource; - private readonly object _suppressLock = new(); - private readonly Queue _suppressQueue = new(); - private readonly List _pendingSuppression = new(); - private byte[]? _activeSuppression; - private int _matchedSuppressionLength; + private int _closedRaised; public bool IsConnected { get; private set; } + /// + /// The exit code of the shell process, available after has been + /// raised because the process exited. Null while running or when undeterminable. + /// + public int? ProcessExitCode { get; private set; } + public event EventHandler? DataReceived; public event EventHandler? Closed; + /// + /// Raised, in stream order relative to , for every + /// shell-integration event (command started / command completed) emitted by the shell. + /// The sequences themselves are stripped from the data before it is forwarded. + /// + public event EventHandler? IntegrationEvent; + + /// + /// Raised whenever data is written to the pty (user keystrokes or automation input), + /// before the write happens. Allows consumers to correlate input with its echo. + /// + public event EventHandler? DataSent; + public bool Connect() { _cancellationSource = new CancellationTokenSource(); - _ = Task.Run(async () => - { - var data = new byte[4096]; - - while (!_cancellationSource.IsCancellationRequested) - { - var bytesReceived = await terminal.ReadAsync(data, 0, data.Length); - - if (bytesReceived > 0) - { - var receivedData = new byte[bytesReceived]; - - Buffer.BlockCopy(data, 0, receivedData, 0, bytesReceived); - - DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = receivedData }); - } - - await Task.Delay(5); - } - }, _cancellationSource.Token); + _ = ReadOutputAsync(_cancellationSource.Token); IsConnected = true; - terminal.Process.EnableRaisingEvents = true; - terminal.Process.Exited += Process_Exited; + try + { + terminal.Process.EnableRaisingEvents = true; + terminal.Process.Exited += Process_Exited; + } + catch (Exception) + { + // Exit notifications are not available for every process handle (e.g. a + // forkpty child attached via GetProcessById). The read loop detects the + // pty closing (EOF/EIO) and raises Closed instead. + } return IsConnected; } @@ -60,6 +64,7 @@ public void Disconnect() public void SendData(byte[] data) { + DataSent?.Invoke(this, new DataReceivedEventArgs { Data = data }); _ = terminal.WriteAsync(data, 0, data.Length); } @@ -76,120 +81,77 @@ public void KillProcess() } } - public void SuppressOutput(byte[] sequence) + public void SetTerminalWindowSize(int columns, int rows) { - if (sequence.Length == 0) return; - lock (_suppressLock) - { - _suppressQueue.Enqueue(sequence); - } + terminal.SetSize(columns, rows); } - public byte[] FilterOutput(byte[] data) + public void Dispose() { - if (data.Length == 0) return data; + _cancellationSource?.Cancel(); - lock (_suppressLock) - { - if (_activeSuppression == null && _suppressQueue.Count == 0) return data; + Disconnect(); + } - var output = new List(data.Length + 8); - var index = 0; + private void Process_Exited(object? sender, EventArgs e) + { + terminal.Process.Exited -= Process_Exited; + RaiseClosed(); + } - while (index < data.Length) - { - if (_activeSuppression == null && _suppressQueue.Count > 0) - { - _activeSuppression = _suppressQueue.Dequeue(); - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; - } + private void RaiseClosed() + { + if (Interlocked.Exchange(ref _closedRaised, 1) != 0) return; - if (_activeSuppression == null) - { - output.Add(data[index++]); - continue; - } + ProcessExitCode = terminal.GetExitCode(); + IsConnected = false; + _cancellationSource?.Cancel(); - var b = data[index++]; - if (b == _activeSuppression[_matchedSuppressionLength]) - { - _pendingSuppression.Add(b); - _matchedSuppressionLength++; - if (_matchedSuppressionLength == _activeSuppression.Length) - { - ResetActiveSuppression(); - } - - continue; - } + Closed?.Invoke(this, EventArgs.Empty); + } - if (_matchedSuppressionLength >= - Math.Min(TolerantSuppressionPrefixLength, _activeSuppression.Length)) - { - // Interactive shells may insert cursor movement and redraw bytes while - // echoing a command that wraps. Keep matching the requested sequence as a - // subsequence so those terminal-control bytes do not expose the command. - _pendingSuppression.Add(b); - if (b == (byte)'\n') - { - output.AddRange(_pendingSuppression); - ResetActiveSuppression(); - } - - continue; - } + private async Task ReadOutputAsync(CancellationToken cancellationToken) + { + var data = new byte[4096]; + try + { + while (!cancellationToken.IsCancellationRequested) + { + var bytesReceived = await terminal.ReadAsync(data, 0, data.Length); + if (bytesReceived <= 0) break; - if (_pendingSuppression.Count > 0) - { - output.AddRange(_pendingSuppression); - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; - } + var receivedData = new byte[bytesReceived]; + Buffer.BlockCopy(data, 0, receivedData, 0, bytesReceived); - if (b == _activeSuppression[0]) - { - _pendingSuppression.Add(b); - _matchedSuppressionLength = 1; - if (_activeSuppression.Length == 1) - { - ResetActiveSuppression(); - } - } - else + foreach (var segment in _parser.Feed(receivedData)) { - output.Add(b); + if (segment.Data != null) + DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = segment.Data }); + else if (segment.Event is { } integrationEvent) + IntegrationEvent?.Invoke(this, new ShellIntegrationEventArgs(integrationEvent)); } } - - return output.Count == data.Length ? data : output.ToArray(); } - } - - private void ResetActiveSuppression() - { - _activeSuppression = null; - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; - } + catch (Exception) + { + // Reading a pty whose child has exited fails (EOF on Windows, EIO on Linux). + // Treated the same as a clean EOF: the connection is closed below. + } - public void SetTerminalWindowSize(int columns, int rows) - { - terminal.SetSize(columns, rows); + // The shell exited (e.g. the user or an automation command ran "exit") or the + // pty was torn down. Notify consumers so pending executions do not hang forever. + if (!cancellationToken.IsCancellationRequested) + RaiseClosed(); } +} - public void Dispose() - { - _cancellationSource?.Cancel(); +public sealed class ShellIntegrationEventArgs(ShellIntegrationEvent integrationEvent) : EventArgs +{ + public ShellIntegrationEvent Event { get; } = integrationEvent; - Disconnect(); - } + public bool IsCommandStarted => Event.Command == 'C'; - private void Process_Exited(object? sender, EventArgs e) - { - terminal.Process.Exited -= Process_Exited; - _cancellationSource?.Cancel(); + public bool IsCommandCompleted => Event.Command == 'D'; - Closed?.Invoke(this, EventArgs.Empty); - } + public int ExitCode => int.TryParse(Event.Argument, out var exitCode) ? exitCode : 0; } diff --git a/src/OneWare.Terminal/Provider/ShellIntegrationParser.cs b/src/OneWare.Terminal/Provider/ShellIntegrationParser.cs new file mode 100644 index 000000000..e102f2cd2 --- /dev/null +++ b/src/OneWare.Terminal/Provider/ShellIntegrationParser.cs @@ -0,0 +1,184 @@ +using System.Text; + +namespace OneWare.Terminal.Provider; + +/// +/// Streaming parser that extracts OneWare shell-integration sequences (OSC 633, terminated +/// by BEL or ST) from raw terminal output and passes everything else through unchanged. +/// The integration sequences are emitted invisibly by shell prompt hooks, so stripping them +/// here guarantees they never reach the user-facing terminal or captured command output. +/// The parser is chunk-safe: sequences may be split across any number of reads. +/// +public sealed class ShellIntegrationParser +{ + private const string OscIntegrationPrefix = "633;"; + private const int MaxPayloadLength = 256; + + private enum State + { + Text, + Escape, + OscPrefix, + OscPayload, + OscPayloadEscape + } + + private readonly List _held = new(); + private readonly StringBuilder _payload = new(); + private State _state = State.Text; + private int _prefixMatched; + + /// + /// A parsed piece of terminal output: either passthrough bytes or an integration event. + /// Segments are returned in stream order so consumers can correlate output with events. + /// + public readonly record struct Segment(byte[]? Data, ShellIntegrationEvent? Event); + + public List Feed(byte[] data) + { + var segments = new List(); + var text = new List(data.Length); + + foreach (var b in data) + Process(b, text, segments); + + FlushText(text, segments); + return segments; + } + + private void Process(byte b, List text, List segments) + { + switch (_state) + { + case State.Text: + if (b == 0x1B) + { + _held.Add(b); + _state = State.Escape; + } + else + { + text.Add(b); + } + + break; + + case State.Escape: + if (b == (byte)']') + { + _held.Add(b); + _prefixMatched = 0; + _state = State.OscPrefix; + } + else + { + // Not an OSC introducer: release the held ESC and reprocess this byte. + ReleaseHeld(text); + _state = State.Text; + Process(b, text, segments); + } + + break; + + case State.OscPrefix: + if (b == (byte)OscIntegrationPrefix[_prefixMatched]) + { + _held.Add(b); + _prefixMatched++; + if (_prefixMatched == OscIntegrationPrefix.Length) + { + _payload.Clear(); + _state = State.OscPayload; + } + } + else + { + // A different OSC sequence (window title, hyperlink, ...): pass it through. + ReleaseHeld(text); + text.Add(b); + _state = State.Text; + } + + break; + + case State.OscPayload: + if (b == 0x07) + { + EmitEvent(text, segments); + } + else if (b == 0x1B) + { + _held.Add(b); + _state = State.OscPayloadEscape; + } + else + { + _held.Add(b); + _payload.Append((char)b); + if (_payload.Length > MaxPayloadLength) + { + // Runaway sequence without terminator: give up and pass it through. + ReleaseHeld(text); + _state = State.Text; + } + } + + break; + + case State.OscPayloadEscape: + if (b == (byte)'\\') + { + EmitEvent(text, segments); + } + else + { + _held.Add(b); + _payload.Append((char)0x1B); + _payload.Append((char)b); + _state = State.OscPayload; + } + + break; + } + } + + private void EmitEvent(List text, List segments) + { + FlushText(text, segments); + segments.Add(new Segment(null, ShellIntegrationEvent.Parse(_payload.ToString()))); + _held.Clear(); + _payload.Clear(); + _state = State.Text; + } + + private void ReleaseHeld(List text) + { + text.AddRange(_held); + _held.Clear(); + _payload.Clear(); + } + + private static void FlushText(List text, List segments) + { + if (text.Count == 0) return; + segments.Add(new Segment(text.ToArray(), null)); + text.Clear(); + } +} + +/// +/// A single OSC 633 shell-integration event, e.g. command started ("C") +/// or command finished with exit code ("D;0"). +/// +public readonly record struct ShellIntegrationEvent(char Command, string? Argument) +{ + public static ShellIntegrationEvent Parse(string payload) + { + if (payload.Length == 0) return new ShellIntegrationEvent('\0', null); + + var separator = payload.IndexOf(';'); + return separator < 0 + ? new ShellIntegrationEvent(payload[0], null) + : new ShellIntegrationEvent(payload[0], payload[(separator + 1)..]); + } +} diff --git a/src/OneWare.Terminal/Provider/Unix/Native.cs b/src/OneWare.Terminal/Provider/Unix/Native.cs index 149ffd934..b3837b881 100644 --- a/src/OneWare.Terminal/Provider/Unix/Native.cs +++ b/src/OneWare.Terminal/Provider/Unix/Native.cs @@ -15,7 +15,7 @@ internal static class NativeDelegates public delegate int chdir([MarshalAs(UnmanagedType.LPStr)] string path); - public delegate void close(int fd); + public delegate int close(int fd); public delegate int dup(int fd); @@ -42,7 +42,7 @@ public delegate void execve([MarshalAs(UnmanagedType.LPStr)] string path, public delegate int openpty(out int amaster, out int aslave, IntPtr name, IntPtr termp, IntPtr winp); - public delegate int pipe(IntPtr[] fds); + public delegate int pipe([Out] int[] fds); public delegate int posix_spawn_file_actions_addclose(IntPtr file_actions, int fildes); @@ -65,6 +65,8 @@ public delegate int posix_spawnp(out IntPtr pid, string path, IntPtr fileActions public delegate int unlockpt(int fd); + public delegate int waitpid(int pid, ref int status, int options); + public delegate int write(int fd, IntPtr buffer, int length); [DllImport("libdl.so.2", EntryPoint = "dlopen")] @@ -151,9 +153,12 @@ internal static class Native public static NativeDelegates.posix_spawnp posix_spawnp = NativeDelegates.GetProc(); public static NativeDelegates.dup dup = NativeDelegates.GetProc(); public static NativeDelegates.dup2 dup2 = NativeDelegates.GetProc(); + public static NativeDelegates.close close = NativeDelegates.GetProc(); + public static NativeDelegates.pipe pipe = NativeDelegates.GetProc(); public static NativeDelegates.setsid setsid = NativeDelegates.GetProc(); public static NativeDelegates.ioctl ioctl = NativeDelegates.GetProc(); public static NativeDelegates.kill kill = NativeDelegates.GetProc(); + public static NativeDelegates.waitpid waitpid = NativeDelegates.GetProc(); public static NativeDelegates.execve execve = NativeDelegates.GetProc(); public static NativeDelegates.fork fork = NativeDelegates.GetProc(); public static NativeDelegates._exit _exit = NativeDelegates.GetProc(); diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs index 2ddefc43b..d1459d90b 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs @@ -10,6 +10,7 @@ public class UnixPseudoTerminal : IPseudoTerminal private readonly Stream _stdin; private readonly Stream _stdout; private bool _isDisposed; + private int? _exitCode; public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout) { @@ -21,6 +22,42 @@ public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout) public Process Process { get; } + public int? GetExitCode() + { + // waitpid reaps the child exactly once; cache the result for later callers. + if (_exitCode != null) return _exitCode; + + try + { + const int WNOHANG = 1; + var status = 0; + + // Called right after pty EOF, so the child is dead or exiting. Poll briefly + // instead of blocking forever in case the EOF had another cause. + for (var i = 0; i < 100; i++) + { + var result = Native.waitpid(Process.Id, ref status, WNOHANG); + if (result == Process.Id) + { + if ((status & 0x7f) == 0) + _exitCode = (status >> 8) & 0xff; // WIFEXITED → WEXITSTATUS + else + _exitCode = 128 + (status & 0x7f); // killed by signal, shell convention + return _exitCode; + } + + if (result < 0) return null; // not our child / already reaped elsewhere + Thread.Sleep(10); + } + } + catch + { + // Best effort only. + } + + return null; + } + public void Dispose() { if (_isDisposed) return; diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs index 648b729f4..5c0c6c89e 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs @@ -38,6 +38,7 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider var value = entry.Substring(separatorIndex + 1); envMap[key] = value; } + } var envVars = new List(envMap.Count + 2); @@ -59,6 +60,13 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider argvList.Add(null!); var argvArray = argvList.ToArray(); + // Warm up the P/Invoke marshalling stubs used in the child while still in the + // parent: after forkpty the child cannot JIT-compile or allocate (the runtime's + // GC/JIT locks may be held by now-frozen threads), so the very first spawn in a + // process would otherwise deadlock inside the marshaller and leave a dead shell. + Native.chdir("."); + Native.execve("/nonexistent/oneware-marshal-warmup", argvArray, envArray); + var pid = Native.forkpty(out var masterFd, IntPtr.Zero, IntPtr.Zero, ref winsize); //pid will be 0 on the forked process @@ -69,6 +77,9 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider Native._exit(1); } + if (pid < 0) + return null; + var stdin = Native.dup(masterFd); var process = Process.GetProcessById(pid); diff --git a/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs b/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs new file mode 100644 index 000000000..ebf1b154b --- /dev/null +++ b/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs @@ -0,0 +1,76 @@ +namespace OneWare.Terminal.Provider; + +/// +/// Best-effort removal of locally echoed user keystrokes from the captured output of an +/// automation command. While an automation command runs, anything the user types into the +/// terminal is echoed back by the pty and would otherwise show up in the command's captured +/// output. This filter matches received bytes against recently sent user input and drops the +/// echo. It is only applied to the capture stream — the visible terminal is unaffected. +/// On any mismatch (echo disabled, interleaved program output) it stops guessing and passes +/// data through unchanged, so the worst failure mode is the old behavior. +/// +public sealed class UserInputEchoFilter +{ + private const int MaxPending = 4096; + private static readonly TimeSpan EchoWindow = TimeSpan.FromMilliseconds(500); + + private readonly Queue _pending = new(); + private DateTimeOffset _lastInput = DateTimeOffset.MinValue; + private bool _dropNextLf; + + /// Records user input written to the pty so its echo can be recognized. + public void OnUserInput(byte[] data) + { + foreach (var b in data) + _pending.Enqueue(b); + + while (_pending.Count > MaxPending) + _pending.Dequeue(); + + _lastInput = DateTimeOffset.Now; + } + + /// Returns with pending user-input echo removed. + public byte[] Filter(byte[] data) + { + if (_pending.Count == 0 && !_dropNextLf) + return data; + + // Echo arrives within milliseconds of the keystroke. If the input is older than + // that, it was not echoed (e.g. a password prompt) — never eat real output for it. + if (_pending.Count > 0 && DateTimeOffset.Now - _lastInput > EchoWindow) + _pending.Clear(); + + var result = new List(data.Length); + foreach (var b in data) + { + if (_dropNextLf) + { + _dropNextLf = false; + if (b == (byte)'\n') + continue; + } + + if (_pending.Count == 0) + { + result.Add(b); + continue; + } + + if (b == _pending.Peek()) + { + _pending.Dequeue(); + // Enter is sent as CR but echoed as CRLF. + if (b == (byte)'\r') + _dropNextLf = true; + continue; + } + + // The echo does not match — stop guessing and pass everything through. + _pending.Clear(); + result.Add(b); + } + + return result.ToArray(); + } +} diff --git a/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs index 65fecd932..1487ad978 100644 --- a/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs +++ b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs @@ -92,6 +92,14 @@ public struct StartupInfoEx public IntPtr lpAttributeList; } + [StructLayout(LayoutKind.Sequential)] + public struct SecurityAttributes + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle; + } + [StructLayout(LayoutKind.Sequential)] public struct ProcessInformation { diff --git a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs index 05bec604f..eb1c944fe 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs @@ -21,6 +21,18 @@ public Win32ConPtyPseudoTerminal(Process process, IntPtr pseudoConsole, SafeFile public Process Process { get; } + public int? GetExitCode() + { + try + { + return Process.WaitForExit(2000) ? Process.ExitCode : null; + } + catch + { + return null; // Best effort only. + } + } + public void SetSize(int columns, int rows) { if (_pseudoConsole != IntPtr.Zero && columns >= 1 && rows >= 1) @@ -53,4 +65,4 @@ public void Dispose() if (_pseudoConsole != IntPtr.Zero) ConPtyNative.ClosePseudoConsole(_pseudoConsole); } -} \ No newline at end of file +} diff --git a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs index 009512212..d52ba10f1 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs @@ -1,5 +1,7 @@ +using System.Collections; using System.ComponentModel; using System.Diagnostics; +using System.IO.Pipes; using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32.SafeHandles; @@ -56,17 +58,16 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider lpAttributeList = attributeList }; - if (!string.IsNullOrWhiteSpace(environment)) environmentBlock = Marshal.StringToHGlobalUni(environment); + environmentBlock = Marshal.StringToHGlobalUni(BuildEnvironmentBlock(environment)); var creationFlags = ConPtyNative.EXTENDED_STARTUPINFO_PRESENT | - (environmentBlock != IntPtr.Zero ? ConPtyNative.CREATE_UNICODE_ENVIRONMENT : 0); + ConPtyNative.CREATE_UNICODE_ENVIRONMENT; var commandLineBuilder = new StringBuilder(commandLine); if (!ConPtyNative.CreateProcessW(null, commandLineBuilder, IntPtr.Zero, IntPtr.Zero, false, creationFlags, environmentBlock, initialDirectory, ref startupInfo, out var processInformation)) { - ConPtyNative.ClosePseudoConsole(pseudoConsole); return null; } @@ -115,18 +116,62 @@ private static string BuildCommandLine(string command, string? arguments) return command; } - private static void CreatePipePair(out SafeFileHandle readPipe, out SafeFileHandle writePipe) + private static void CreatePipePair(out SafeFileHandle readPipe, out SafeFileHandle writePipe, + bool inheritRead = false, bool inheritWrite = false) { - if (!ConPtyNative.CreatePipe(out readPipe, out writePipe, IntPtr.Zero, 0)) - throw new Win32Exception(Marshal.GetLastWin32Error()); + var securityAttributes = new ConPtyNative.SecurityAttributes + { + nLength = Marshal.SizeOf(), + bInheritHandle = true + }; + var securityAttributesPointer = Marshal.AllocHGlobal(securityAttributes.nLength); + try + { + Marshal.StructureToPtr(securityAttributes, securityAttributesPointer, false); + if (!ConPtyNative.CreatePipe(out readPipe, out writePipe, securityAttributesPointer, 0)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + finally + { + Marshal.FreeHGlobal(securityAttributesPointer); + } - if (!ConPtyNative.SetHandleInformation(readPipe, ConPtyNative.HANDLE_FLAG_INHERIT, 0)) + if (!inheritRead && + !ConPtyNative.SetHandleInformation(readPipe, ConPtyNative.HANDLE_FLAG_INHERIT, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); - if (!ConPtyNative.SetHandleInformation(writePipe, ConPtyNative.HANDLE_FLAG_INHERIT, 0)) + if (!inheritWrite && + !ConPtyNative.SetHandleInformation(writePipe, ConPtyNative.HANDLE_FLAG_INHERIT, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); } + private static string BuildEnvironmentBlock(string? environment) + { + var values = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is string key && entry.Value is string value) + values[key] = value; + } + + if (!string.IsNullOrWhiteSpace(environment)) + { + foreach (var entry in environment.Split('\0')) + { + if (string.IsNullOrEmpty(entry)) continue; + var separator = entry.IndexOf('='); + if (separator > 0) + values[entry[..separator]] = entry[(separator + 1)..]; + } + } + + var builder = new StringBuilder(); + foreach (var pair in values) + builder.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); + builder.Append('\0'); + return builder.ToString(); + } + private static void InitializeAttributeList(IntPtr pseudoConsole, out IntPtr attributeList) { attributeList = IntPtr.Zero; @@ -148,5 +193,6 @@ private static void InitializeAttributeList(IntPtr pseudoConsole, out IntPtr att ConPtyNative.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, pseudoConsole, IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } } \ No newline at end of file diff --git a/src/OneWare.Terminal/ShellIntegration.cs b/src/OneWare.Terminal/ShellIntegration.cs new file mode 100644 index 000000000..16413da4b --- /dev/null +++ b/src/OneWare.Terminal/ShellIntegration.cs @@ -0,0 +1,197 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace OneWare.Terminal; + +/// +/// Generates the shell startup scripts and spawn configuration that install OneWare's +/// shell integration (VS Code-style). The integration makes the shell emit invisible +/// OSC 633 sequences at prompt boundaries: +/// ESC]633;C BEL — a command has been read and is about to execute +/// ESC]633;D;<exit> BEL — the command finished with the given exit code +/// Because the hooks are installed via startup files (never typed into the terminal), +/// nothing is echoed and nothing can leak into the user-facing terminal. +/// +public static class ShellIntegration +{ + public sealed record SpawnConfig(string? Arguments, string? Environment); + + private static readonly Lazy ScriptDirectory = new(CreateScriptDirectory); + + public static SpawnConfig GetSpawnConfig(string shellExecutable) + { + var shellName = Path.GetFileNameWithoutExtension(shellExecutable).ToLowerInvariant(); + + try + { + return shellName switch + { + // Note: the Unix provider splits arguments on spaces and passes them + // verbatim to execve, so the path must not be quoted. The script lives + // in the temp directory, which does not contain spaces on supported setups. + "bash" => new SpawnConfig($"--init-file {WriteScript("integration.bash", BashScript)}", + null), + "zsh" => CreateZshConfig(), + "powershell" or "pwsh" => CreatePowerShellConfig(shellExecutable), + _ => new SpawnConfig(null, null) + }; + } + catch (Exception) + { + // If the integration scripts cannot be written the terminal still works, + // it just cannot report command completion. + return new SpawnConfig(null, null); + } + } + + private static SpawnConfig CreateZshConfig() + { + var dir = ScriptDirectory.Value; + WriteScript(".zshenv", ZshEnvScript); + WriteScript(".zshrc", ZshRcScript); + + var userZdotdir = Environment.GetEnvironmentVariable("ZDOTDIR") + ?? Environment.GetEnvironmentVariable("HOME") ?? "~"; + + return new SpawnConfig("-i", $"ZDOTDIR={dir}\0OW_USER_ZDOTDIR={userZdotdir}"); + } + + private static SpawnConfig CreatePowerShellConfig(string shellExecutable) + { + var script = WriteScript("integration.ps1", PowerShellScript); + var escaped = script.Replace("'", "''"); + + // The arguments string must contain the full command line because the ConPTY + // provider uses it verbatim when it is not empty. + var exe = Path.GetFileName(shellExecutable); + return new SpawnConfig( + $"{exe} -NoLogo -NoProfile -NoExit -Command \". '{escaped}'\"", null); + } + + private static readonly Lock WriteLock = new(); + + private static string WriteScript(string fileName, string content) + { + var path = Path.Combine(ScriptDirectory.Value, fileName); + + lock (WriteLock) + { + if (!File.Exists(path) || File.ReadAllText(path) != content) + { + // Write atomically so a shell starting in another instance/thread can + // never observe a partially written script. + var temp = Path.Combine(ScriptDirectory.Value, $"{fileName}.{Guid.NewGuid():N}.tmp"); + File.WriteAllText(temp, content, new UTF8Encoding(false)); + File.Move(temp, path, true); + } + } + + return path; + } + + private static string CreateScriptDirectory() + { + // Per-user, owner-controlled location. Deliberately NOT the shared temp directory: + // /tmp is world-writable, so a fixed path there could be pre-created by another + // (malicious) user, who could then swap the scripts our shells source (TOCTOU) or + // simply block other users from writing them. A dot-directory in the user's home + // is also space-free on Unix, which the pty argument handling requires. + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) home = Path.GetTempPath(); + + var dir = Path.Combine(home, ".oneware", "shell-integration"); + Directory.CreateDirectory(dir); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + File.SetUnixFileMode(dir, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch + { + // Permissions are a best effort; default umask is fine. + } + } + + return dir; + } + + private const string BashScript = + """ + # OneWare shell integration for bash. + # Source the user's regular startup files first so their environment is intact. + if [ -r /etc/bash.bashrc ]; then . /etc/bash.bashrc; fi + if [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi + + __oneware_prompt_cmd() { + local __ow_exit=$? + printf '\033]633;D;%s\007' "$__ow_exit" + return $__ow_exit + } + + # Report the exit code at every prompt (runs before anything already configured). + PROMPT_COMMAND="__oneware_prompt_cmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + + # PS0 is expanded after a command has been read and before it executes. + # Note: readline markers \[ \] must NOT be used here — outside of PS1 they + # are emitted as literal \x01/\x02 bytes. + PS0='\e]633;C\a'"$PS0" + """; + + private const string ZshEnvScript = + """ + # OneWare shell integration for zsh: restore the user's ZDOTDIR files. + OW_USER_ZDOTDIR=${OW_USER_ZDOTDIR:-$HOME} + if [ -f "$OW_USER_ZDOTDIR/.zshenv" ]; then + ZDOTDIR=$OW_USER_ZDOTDIR . "$OW_USER_ZDOTDIR/.zshenv" + fi + """; + + private const string ZshRcScript = + """ + # OneWare shell integration for zsh. + OW_USER_ZDOTDIR=${OW_USER_ZDOTDIR:-$HOME} + if [ -f "$OW_USER_ZDOTDIR/.zshrc" ]; then + ZDOTDIR=$OW_USER_ZDOTDIR . "$OW_USER_ZDOTDIR/.zshrc" + fi + + __oneware_preexec() { + printf '\033]633;C\007' + } + + __oneware_precmd() { + printf '\033]633;D;%s\007' "$?" + } + + autoload -Uz add-zsh-hook + add-zsh-hook preexec __oneware_preexec + add-zsh-hook precmd __oneware_precmd + """; + + private const string PowerShellScript = + """ + # OneWare shell integration for PowerShell. + $Global:__OneWareOriginalPrompt = $function:Prompt + + function Global:Prompt() { + $__ow_success = $? + $__ow_exit = if ($__ow_success) { 0 } + elseif ($Global:LASTEXITCODE -is [int] -and $Global:LASTEXITCODE -ne 0) { $Global:LASTEXITCODE } + else { 1 } + $__ow_out = "$([char]0x1b)]633;D;$__ow_exit$([char]0x07)" + $__ow_out += if ($Global:__OneWareOriginalPrompt) { $Global:__OneWareOriginalPrompt.Invoke() } else { "PS $PWD> " } + return $__ow_out + } + + # Emit the command-started marker once a line has been read, before it executes. + if (Get-Module -Name PSReadLine) { + function Global:PSConsoleHostReadLine { + $__ow_line = [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext) + [Console]::Write("$([char]0x1b)]633;C$([char]0x07)") + return $__ow_line + } + } + """; +} diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 1e569259c..d9b6bdfc7 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -1,6 +1,3 @@ -using System.Collections; -using System.Collections.Generic; -using System.IO; using System.Runtime.InteropServices; using System.Text; using Avalonia.Threading; @@ -27,9 +24,7 @@ public class TerminalViewModel : ObservableObject public TerminalViewModel(string workingDir, string? startArguments = null) { WorkingDir = workingDir; - StartArguments = startArguments ?? (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? BuildWindowsStartArguments(WorkingDir) - : null); + StartArguments = startArguments; } public string? StartArguments { get; } @@ -62,6 +57,12 @@ public bool TerminalLoading public event EventHandler? TerminalReady; + /// + /// Raised when the underlying shell exits or the pty closes on its own, + /// i.e. not through . Lets owners close the containing tab. + /// + public event EventHandler? ConnectionClosed; + public void Redraw() { if (TerminalVisible) @@ -84,7 +85,7 @@ public void CreateConnection() lock (_createLock) { CloseConnection(); - + var shellExecutable = PlatformHelper.Platform switch { PlatformId.WinX64 or PlatformId.WinArm64 => PlatformHelper.GetFullPath("powershell.exe"), @@ -96,16 +97,14 @@ public void CreateConnection() if (!string.IsNullOrEmpty(shellExecutable)) { - var environment = BuildTerminalEnvironment(shellExecutable); - var startArguments = StartArguments; - if (string.IsNullOrWhiteSpace(startArguments) && - Path.GetFileName(shellExecutable).Equals("zsh", StringComparison.OrdinalIgnoreCase)) - { - // Ensure zsh runs interactively so precmd hooks fire. - startArguments = "-i"; - } + // Shell integration is installed via startup files, so the shell emits + // invisible OSC 633 command lifecycle sequences without anything being + // typed into (or echoed by) the terminal. + var integration = ShellIntegration.GetSpawnConfig(shellExecutable); + var startArguments = StartArguments ?? integration.Arguments; - var terminal = SProvider.Create(80, 32, WorkingDir, shellExecutable, environment, startArguments); + var terminal = SProvider.Create(80, 32, WorkingDir, shellExecutable, integration.Environment, + startArguments); if (terminal == null) { @@ -114,15 +113,15 @@ public void CreateConnection() } Connection = new PseudoTerminalConnection(terminal); + Connection.Closed += OnConnectionClosed; Terminal = new VirtualTerminalController(); - _ = Dispatcher.UIThread.InvokeAsync(async () => + Dispatcher.UIThread.Post(() => { - TerminalVisible = true; Connection.Connect(); - await Task.Delay(500); + TerminalVisible = true; TerminalLoading = false; @@ -134,7 +133,7 @@ public void CreateConnection() public void Send(string command) { - if (Connection?.IsConnected ?? false) Connection.SendData(Encoding.ASCII.GetBytes($"{command}\r")); + if (Connection?.IsConnected ?? false) Connection.SendData(Encoding.UTF8.GetBytes($"{command}\r")); } public void SendInterrupt() @@ -151,173 +150,27 @@ public void KillProcess() if (Connection is PseudoTerminalConnection ptc) ptc.KillProcess(); } - public void SuppressEcho(byte[] data) - { - if (Connection is IOutputSuppressor suppressor) - { - suppressor.SuppressOutput(data); - } - } - public void CloseConnection() { if (Connection != null) { + Connection.Closed -= OnConnectionClosed; Connection.Disconnect(); Connection = null; } } - public void Close() + private void OnConnectionClosed(object? sender, EventArgs e) { - CloseConnection(); - } + if (sender is IConnection connection) + connection.Closed -= OnConnectionClosed; + if (!ReferenceEquals(sender, Connection)) return; - private static string BuildWindowsStartArguments(string workingDir) - { - // The arguments string must include the full command line because - // Win32ConPtyPseudoTerminalProvider.BuildCommandLine returns just the arguments when provided - var escapedDir = workingDir.Replace("'", "''"); - - // Keep bootstrap inline so we do not execute external .ps1 files. - // This avoids requiring users to change PowerShell execution policy. - var bootstrapCmd = - "if (Test-Path function:prompt) { $function:__ow_original_prompt = $function:prompt }; " + - "function global:prompt { " + - "$esc = [char]27; $bel = [char]7; " + - "$code = if ($global:LASTEXITCODE -ne $null) { [int]$global:LASTEXITCODE } elseif ($?) { 0 } else { 1 }; " + - "Write-Host ($esc + ']9;OW_DONE:' + $code + $bel) -NoNewline; " + - "if (Test-Path function:__ow_original_prompt) { & __ow_original_prompt } else { " + - "'PS ' + $executionContext.SessionState.Path.CurrentLocation + ('>' * ($nestedPromptLevel + 1)) + ' ' " + - "} " + - "}; " + - $"Set-Location '{escapedDir}'"; - - return $"powershell.exe -NoProfile -NoExit -Command \"{bootstrapCmd}\""; + ConnectionClosed?.Invoke(this, EventArgs.Empty); } - private static string? BuildTerminalEnvironment(string? shellExecutable) - { - if (string.IsNullOrWhiteSpace(shellExecutable)) return null; - - var shellName = Path.GetFileName(shellExecutable); - - if (shellName.Equals("bash", StringComparison.OrdinalIgnoreCase)) - { - var existingPromptCommand = Environment.GetEnvironmentVariable("PROMPT_COMMAND"); - var markerCommand = "printf '\\033]9;OW_DONE:%s\\007' $?"; - var combined = string.IsNullOrWhiteSpace(existingPromptCommand) - ? markerCommand - : markerCommand + ";" + existingPromptCommand; - - var overrides = new Dictionary(StringComparer.Ordinal) - { - ["PROMPT_COMMAND"] = combined - }; - - return BuildEnvironmentBlock(overrides); - } - - if (shellName.Equals("zsh", StringComparison.OrdinalIgnoreCase)) - { - var zshDotDir = EnsureZshDotDir(); - if (string.IsNullOrWhiteSpace(zshDotDir)) return null; - - var overrides = new Dictionary(StringComparer.Ordinal) - { - ["ZDOTDIR"] = zshDotDir - }; - - return BuildEnvironmentBlock(overrides); - } - - return null; - } - - private static string? EnsureZshDotDir() - { - try - { - var tempDir = Path.Combine(Path.GetTempPath(), "oneware", "zsh"); - Directory.CreateDirectory(tempDir); - - var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var baseZdotdir = Environment.GetEnvironmentVariable("ZDOTDIR"); - if (string.IsNullOrWhiteSpace(baseZdotdir)) baseZdotdir = userHome; - - if (!string.IsNullOrWhiteSpace(baseZdotdir)) - { - WriteZshFile(Path.Combine(tempDir, ".zshenv"), Path.Combine(baseZdotdir, ".zshenv"), - "# oneware zshenv"); - WriteZshFile(Path.Combine(tempDir, ".zprofile"), Path.Combine(baseZdotdir, ".zprofile"), - "# oneware zprofile"); - WriteZshFile(Path.Combine(tempDir, ".zlogin"), Path.Combine(baseZdotdir, ".zlogin"), - "# oneware zlogin"); - } - - var zshrcPath = Path.Combine(tempDir, ".zshrc"); - var zshrcBuilder = new StringBuilder(); - zshrcBuilder.AppendLine("# oneware zshrc"); - - var originalZshrc = !string.IsNullOrWhiteSpace(baseZdotdir) - ? Path.Combine(baseZdotdir, ".zshrc") - : null; - - if (!string.IsNullOrWhiteSpace(originalZshrc) && File.Exists(originalZshrc)) - { - zshrcBuilder.Append("source ").Append('"').Append(originalZshrc).Append('"').AppendLine(); - } - - zshrcBuilder.AppendLine("autoload -Uz add-zsh-hook"); - zshrcBuilder.AppendLine("_ow_precmd() { printf '\\033]9;OW_DONE:%s\\007' $?; }"); - zshrcBuilder.AppendLine("add-zsh-hook precmd _ow_precmd"); - - File.WriteAllText(zshrcPath, zshrcBuilder.ToString(), Encoding.ASCII); - return tempDir; - } - catch - { - return null; - } - } - - private static void WriteZshFile(string destinationPath, string sourcePath, string header) - { - var builder = new StringBuilder(); - builder.AppendLine(header); - if (File.Exists(sourcePath)) - { - builder.Append("source ").Append('"').Append(sourcePath).Append('"').AppendLine(); - } - - File.WriteAllText(destinationPath, builder.ToString(), Encoding.ASCII); - } - - private static string BuildEnvironmentBlock(Dictionary overrides) + public void Close() { - var comparer = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - var env = new SortedDictionary(comparer); - foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) - { - if (entry.Key is not string key || entry.Value is not string value) continue; - env[key] = value; - } - - foreach (var pair in overrides) - { - env[pair.Key] = pair.Value; - } - - var builder = new StringBuilder(); - foreach (var pair in env) - { - builder.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); - } - - builder.Append('\0'); - return builder.ToString(); + CloseConnection(); } } diff --git a/src/OneWare.TerminalManager/OneWare.TerminalManager.csproj b/src/OneWare.TerminalManager/OneWare.TerminalManager.csproj index fdebcb482..00bb26934 100644 --- a/src/OneWare.TerminalManager/OneWare.TerminalManager.csproj +++ b/src/OneWare.TerminalManager/OneWare.TerminalManager.csproj @@ -6,6 +6,7 @@ + \ No newline at end of file diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 3a71080b6..ed9de92d8 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -9,6 +9,7 @@ using OneWare.Essentials.Models; using OneWare.Essentials.Services; using OneWare.Essentials.ViewModels; +using OneWare.Terminal.Provider; using OneWare.Terminal.ViewModels; using OneWare.TerminalManager.Models; @@ -17,7 +18,12 @@ namespace OneWare.TerminalManager.ViewModels; public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService { public const string IconKey = "Material.Console"; - private const string PromptMarkerPrefix = "\u001b]9;OW_DONE:"; + + // After a command reports completion, wait briefly for another command-start event. + // Multi-line command blocks execute line by line (already buffered in the pty), so the + // next line's start marker arrives within milliseconds; only the final completion is + // followed by silence. + private static readonly TimeSpan MultiCommandGracePeriod = TimeSpan.FromMilliseconds(250); // Automation terminals are pooled per id so that concurrent commands (e.g. an AI agent // running several shell commands at once) each get their own terminal tab instead of @@ -97,6 +103,8 @@ public TerminalTabModel NewTerminal(string name, string? workingDirectory = null var title = GetUniqueTitle(name); var tab = new TerminalTabModel(title, new TerminalViewModel(homeFolder), this); + // Close the tab when its shell exits (e.g. the user or an automation command runs "exit"). + tab.Terminal.ConnectionClosed += (_, _) => Dispatcher.UIThread.Post(tab.Close); Terminals.Add(tab); if (select) SelectedTerminalTab = tab; @@ -135,93 +143,134 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo terminal.TerminalReady -= OnReady; - if (terminal.Connection == null) + if (terminal.Connection is not PseudoTerminalConnection connection) { if (closeWhenDone) terminal.Close(); return new TerminalExecutionResult(string.Empty, -1, true); } var output = new StringBuilder(); - var outputLock = new object(); - var resultTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stateLock = new object(); + var resultTcs = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var commandSent = false; - var markerPrefix = PromptMarkerPrefix; - var commandToSend = command; - string? markerCommand = null; + // True between a command-start (OSC 633;C) and command-complete (OSC 633;D) marker, + // i.e. while the terminal output belongs to the command we sent. Prompt drawing, + // command echo and integration startup noise all happen outside that window. + var capturing = false; + var lastExitCode = 0; + CancellationTokenSource? graceCts = null; + // Strips echoed user keystrokes from the captured output only (not the visible terminal). + var echoFilter = new UserInputEchoFilter(); + + void CompleteWithResult() + { + string finalOutput; + int exitCode; + lock (stateLock) + { + finalOutput = output.ToString(); + exitCode = lastExitCode; + } - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + resultTcs.TrySetResult(new TerminalExecutionResult(finalOutput, exitCode, false)); + } + + async Task CompleteAfterGraceAsync(CancellationToken graceToken) { - var executionId = Guid.NewGuid().ToString("N"); - markerPrefix = $"{PromptMarkerPrefix}{executionId}:"; - - // Do not depend on PROMPT_COMMAND/precmd for automation completion. User shell - // configuration can replace those hooks, leaving the shell visibly idle while the - // caller waits forever. Appending a per-command marker also prevents startup or - // unrelated prompt markers from completing the wrong invocation. - markerCommand = - $"__ow_exit=$?; printf '\\033[1A\\r\\033[2K\\033]9;OW_DONE:{executionId}:%s\\007' \"$__ow_exit\""; - commandToSend = $"{command}\n{markerCommand}"; + try + { + await Task.Delay(MultiCommandGracePeriod, graceToken); + } + catch (OperationCanceledException) + { + return; // Another command line started; keep waiting for its completion. + } + + CompleteWithResult(); } void OnConnectionClosed(object? sender, EventArgs args) { string partialOutput; - lock (outputLock) + int exitCode; + lock (stateLock) + { partialOutput = output.ToString(); + exitCode = capturing ? -1 : lastExitCode; + } + + // The shell itself exited (e.g. the command was "exit 3"). Prefer the real + // process exit code over the marker-based one, which can never arrive. + if ((sender as PseudoTerminalConnection)?.ProcessExitCode is { } processExitCode) + exitCode = processExitCode; - resultTcs.TrySetResult(new TerminalExecutionResult(partialOutput, -1, true)); + resultTcs.TrySetResult(new TerminalExecutionResult( + partialOutput + "\n[terminal session ended]", exitCode, false)); } - void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) + void OnDataSent(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) { - var text = Encoding.UTF8.GetString(args.Data); - string current; - int? completedExitCode = null; - - lock (outputLock) + lock (stateLock) { - output.Append(text); - current = output.ToString(); - - while (TryExtractOscMarker(current, markerPrefix, out var exitCode, out var cleaned)) - { - while (TryExtractOscMarker(cleaned, PromptMarkerPrefix, out _, out var promptCleaned)) - cleaned = promptCleaned; - - current = cleaned; - output.Clear(); - output.Append(cleaned); - - if (!commandSent) continue; - - completedExitCode = exitCode; - break; - } + // Input sent while our command runs is user input typed into the terminal; + // remember it so its echo can be removed from the captured output. + if (capturing) + echoFilter.OnUserInput(args.Data); } + } - if (completedExitCode is { } exitCodeResult) + void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) + { + string? current = null; + + lock (stateLock) { - outputProgress?.Report(current); - resultTcs.TrySetResult(new TerminalExecutionResult(current, exitCodeResult, false)); - return; + if (!capturing) return; + var filtered = echoFilter.Filter(args.Data); + if (filtered.Length == 0) return; + output.Append(Encoding.UTF8.GetString(filtered)); + current = output.ToString(); } - if (commandSent && !resultTcs.Task.IsCompleted) + if (!resultTcs.Task.IsCompleted) outputProgress?.Report(current); } - terminal.Connection.DataReceived += OnDataReceived; - terminal.Connection.Closed += OnConnectionClosed; - if (markerCommand != null) + void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args) { - // The shell echoes queued input before executing it. Hide the internal marker - // command independently of the PTY's line-ending mode. The marker then moves back - // and clears the intermediate prompt before the final prompt is rendered. - terminal.SuppressEcho(Encoding.UTF8.GetBytes(markerCommand)); + lock (stateLock) + { + if (args.IsCommandStarted) + { + capturing = true; + graceCts?.Cancel(); + graceCts = null; + } + else if (args.IsCommandCompleted && capturing) + { + // Completion markers arriving before any command started (e.g. the + // shell's very first prompt or the user pressing enter on an empty + // prompt) do not belong to our command and are ignored. + capturing = false; + lastExitCode = args.ExitCode; + graceCts?.Cancel(); + graceCts = new CancellationTokenSource(); + _ = CompleteAfterGraceAsync(graceCts.Token); + } + } } - commandSent = true; - terminal.Send(commandToSend); + connection.DataReceived += OnDataReceived; + connection.DataSent += OnDataSent; + connection.Closed += OnConnectionClosed; + connection.IntegrationEvent += OnIntegrationEvent; + + if (!resultTcs.Task.IsCompleted && !cancellationToken.IsCancellationRequested) + { + commandSent = true; + terminal.Send(command); + } TerminalExecutionResult result; @@ -232,28 +281,39 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg catch (OperationCanceledException) { string partialOutput; - lock (outputLock) + lock (stateLock) 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 { - terminal.Connection.DataReceived -= OnDataReceived; - terminal.Connection.Closed -= OnConnectionClosed; + lock (stateLock) + { + graceCts?.Cancel(); + graceCts = null; + } + + connection.DataReceived -= OnDataReceived; + connection.DataSent -= OnDataSent; + connection.Closed -= OnConnectionClosed; + connection.IntegrationEvent -= OnIntegrationEvent; if (closeWhenDone) terminal.Close(); } @@ -266,8 +326,8 @@ private static async Task TryRecoverPromptAsync(TerminalViewModel terminal terminal.SendInterrupt(); try { - // Give the shell a moment to process the interrupt and emit a fresh - // prompt marker so the terminal can be reused by the next command. + // The interrupt makes the shell print a fresh prompt, whose integration + // marker completes the pending result and keeps the terminal reusable. await resultTask.WaitAsync(TimeSpan.FromSeconds(3)); return true; } @@ -333,39 +393,6 @@ private static async Task WaitForResultAsync( return await resultTask.WaitAsync(linkedCts.Token); } - // prompt marker is injected via terminal environment during shell startup - - private static bool TryExtractOscMarker(string current, string markerPrefix, out int exitCode, - out string cleanedOutput) - { - exitCode = 0; - cleanedOutput = current; - - var markerIndex = current.IndexOf(markerPrefix, StringComparison.Ordinal); - if (markerIndex < 0) return false; - - var belIndex = current.IndexOf('\u0007', markerIndex); - var stIndex = current.IndexOf("\u001b\\", markerIndex, StringComparison.Ordinal); - - var endIndex = belIndex; - var endLength = 1; - - 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)); - int.TryParse(markerContent, out exitCode); - - cleanedOutput = current.Remove(markerIndex, endIndex - markerIndex + endLength); - return true; - } - public async Task ExecuteInTerminalAsync(string command, string id, string? workingDirectory = null, bool showInUi = false, TimeSpan? timeout = null, IProgress? outputProgress = null, CancellationToken cancellationToken = default) diff --git a/src/VtNetCore.Avalonia b/src/VtNetCore.Avalonia index ac3077126..82078aa6b 160000 --- a/src/VtNetCore.Avalonia +++ b/src/VtNetCore.Avalonia @@ -1 +1 @@ -Subproject commit ac3077126317e460562cc335ce3c2655f559a89d +Subproject commit 82078aa6b5082117970bf170a82fa14aad045825 diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index 82c6bc86f..80f5ce3f4 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -1,52 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Text; +using System.Threading.Tasks; +using OneWare.Terminal; using OneWare.Terminal.Provider; +using OneWare.Terminal.Provider.Unix; using Xunit; namespace OneWare.Studio.Desktop.UnitTests; -public class PseudoTerminalConnectionTests +public class ShellIntegrationParserTests { - private const string MarkerCommand = - "__ow_exit=$?; printf '\\033[1A\\r\\033[2K\\033]9;OW_DONE:0123456789abcdef:%s\\007' \"$__ow_exit\""; + private static string FeedText(ShellIntegrationParser parser, string input, + List? events = null) + { + var text = new StringBuilder(); + foreach (var segment in parser.Feed(Encoding.UTF8.GetBytes(input))) + { + if (segment.Data != null) text.Append(Encoding.UTF8.GetString(segment.Data)); + else if (segment.Event is { } e) events?.Add(e); + } + + return text.ToString(); + } + + [Fact] + public void Feed_StripsIntegrationSequencesAndRaisesEvents() + { + var parser = new ShellIntegrationParser(); + var events = new List(); + + var text = FeedText(parser, "before\u001b]633;C\u0007output\u001b]633;D;3\u0007after", events); + + Assert.Equal("beforeoutputafter", text); + Assert.Equal(2, events.Count); + Assert.Equal('C', events[0].Command); + Assert.Equal('D', events[1].Command); + Assert.Equal("3", events[1].Argument); + } + + [Fact] + public void Feed_HandlesSequencesSplitAcrossChunks() + { + var parser = new ShellIntegrationParser(); + var events = new List(); + var input = "abc\u001b]633;D;127\u0007def"; + + var text = new StringBuilder(); + foreach (var chunk in input.Select(c => c.ToString())) + text.Append(FeedText(parser, chunk, events)); + + Assert.Equal("abcdef", text.ToString()); + var integrationEvent = Assert.Single(events); + Assert.Equal('D', integrationEvent.Command); + Assert.Equal("127", integrationEvent.Argument); + } + + [Fact] + public void Feed_SupportsStringTerminator() + { + var parser = new ShellIntegrationParser(); + var events = new List(); + + var text = FeedText(parser, "x\u001b]633;D;0\u001b\\y", events); + + Assert.Equal("xy", text); + var integrationEvent = Assert.Single(events); + Assert.Equal('D', integrationEvent.Command); + Assert.Equal("0", integrationEvent.Argument); + } + + [Fact] + public void Feed_PassesOtherOscSequencesThrough() + { + var parser = new ShellIntegrationParser(); + var events = new List(); + const string title = "pre\u001b]0;window title\u0007post"; + + var text = FeedText(parser, title, events); + + Assert.Equal(title, text); + Assert.Empty(events); + } + + [Fact] + public void Feed_PassesCsiAndPlainEscapesThrough() + { + var parser = new ShellIntegrationParser(); + const string input = "a\u001b[31mred\u001b[0m\u001b(Bb"; + + var text = FeedText(parser, input); + + Assert.Equal(input, text); + } +} +public class PseudoTerminalConnectionTests +{ [Fact] - public void FilterOutput_SuppressesSequenceAcrossChunks() + public async Task ShellIntegration_ReportsCommandLifecycleOnUnix() { - var connection = new PseudoTerminalConnection(null!); - var marker = Encoding.ASCII.GetBytes(MarkerCommand); - connection.SuppressOutput(marker); + if (OperatingSystem.IsWindows()) return; + + var bash = "/bin/bash"; + var config = ShellIntegration.GetSpawnConfig(bash); + Assert.NotNull(config.Arguments); + + var provider = new UnixPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), bash, config.Environment, + config.Arguments); + Assert.NotNull(terminal); - var first = connection.FilterOutput(Encoding.ASCII.GetBytes($"before{MarkerCommand[..30]}")); - var second = connection.FilterOutput(Encoding.ASCII.GetBytes($"{MarkerCommand[30..]}after")); + using var connection = new PseudoTerminalConnection(terminal); + var output = new StringBuilder(); + var outputLock = new object(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.DataReceived += (_, args) => + { + lock (outputLock) + output.Append(Encoding.UTF8.GetString(args.Data)); + }; + connection.IntegrationEvent += (_, args) => + { + if (args.IsCommandStarted) + started.TrySetResult(); + else if (args.IsCommandCompleted && started.Task.IsCompleted) + completed.TrySetResult(args.ExitCode); + }; + connection.Connect(); - Assert.Equal("before", Encoding.ASCII.GetString(first)); - Assert.Equal("after", Encoding.ASCII.GetString(second)); + connection.SendData(Encoding.UTF8.GetBytes("printf 'integration-output\\n'; exit_test() { return 0; }\r")); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var exitCode = await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, exitCode); + string text; + lock (outputLock) + text = output.ToString(); + Assert.Contains("integration-output", text); + Assert.DoesNotContain("633;", text); + // The PS0 hook must not leak readline prompt markers (\x01/\x02) into the output. + Assert.DoesNotContain('\u0001', text); + Assert.DoesNotContain('\u0002', text); } [Fact] - public void FilterOutput_SuppressesWrappedReadlineEcho() + public async Task ShellIntegration_ReportsFailingExitCodeOnUnix() { - var connection = new PseudoTerminalConnection(null!); - connection.SuppressOutput(Encoding.ASCII.GetBytes(MarkerCommand)); + if (OperatingSystem.IsWindows()) return; + + var bash = "/bin/bash"; + var config = ShellIntegration.GetSpawnConfig(bash); - var wrappedEcho = MarkerCommand - .Replace("\\033[1A", "\\\\\r\\033[1A") - .Replace("$__ow_exit\"", "$__ow_ex\rxit\""); - var first = connection.FilterOutput(Encoding.ASCII.GetBytes($"prompt{wrappedEcho[..48]}")); - var second = connection.FilterOutput(Encoding.ASCII.GetBytes(wrappedEcho[48..])); + var provider = new UnixPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), bash, config.Environment, + config.Arguments); + Assert.NotNull(terminal); - Assert.Equal("prompt", Encoding.ASCII.GetString(first)); - Assert.Empty(second); + using var connection = new PseudoTerminalConnection(terminal); + var started = false; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.IntegrationEvent += (_, args) => + { + if (args.IsCommandStarted) + started = true; + else if (args.IsCommandCompleted && started) + completed.TrySetResult(args.ExitCode); + }; + connection.Connect(); + + connection.SendData(Encoding.UTF8.GetBytes("exit_code_test() { return 42; }; exit_code_test\r")); + + var exitCode = await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(42, exitCode); } [Fact] - public void FilterOutput_ReleasesIncompleteSuppressionAtLineEnd() + public async Task ShellExit_RaisesClosedInsteadOfHanging() { - var connection = new PseudoTerminalConnection(null!); - connection.SuppressOutput(Encoding.ASCII.GetBytes(MarkerCommand)); + if (OperatingSystem.IsWindows()) return; + + var bash = "/bin/bash"; + var config = ShellIntegration.GetSpawnConfig(bash); + + var provider = new UnixPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), bash, config.Environment, + config.Arguments); + Assert.NotNull(terminal); + + using var connection = new PseudoTerminalConnection(terminal); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += (_, _) => closed.TrySetResult(); + connection.Connect(); - var output = connection.FilterOutput(Encoding.ASCII.GetBytes("__ow_broken\r\nnext")); + // "exit 3" terminates the shell itself: no completion marker will ever arrive, + // so the connection must report closure to unblock pending executions. + connection.SendData(Encoding.UTF8.GetBytes("exit 3\r")); - Assert.Equal("__ow_broken\r\nnext", Encoding.ASCII.GetString(output)); + await closed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(connection.IsConnected); + Assert.Equal(3, connection.ProcessExitCode); } } diff --git a/tests/OneWare.Studio.Desktop.UnitTests/ShellIntegrationTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ShellIntegrationTests.cs new file mode 100644 index 000000000..27213f708 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/ShellIntegrationTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using OneWare.Terminal; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class ShellIntegrationTests +{ + [Fact] + public void GetSpawnConfig_Bash_UsesInitFileWithIntegrationScript() + { + var config = ShellIntegration.GetSpawnConfig("/bin/bash"); + + Assert.NotNull(config.Arguments); + Assert.StartsWith("--init-file ", config.Arguments); + + var scriptPath = config.Arguments!["--init-file ".Length..]; + Assert.True(File.Exists(scriptPath)); + + var script = File.ReadAllText(scriptPath); + Assert.Contains("633;C", script); + Assert.Contains("633;D", script); + Assert.Contains("$HOME/.bashrc", script); + } + + [Fact] + public void GetSpawnConfig_Zsh_RedirectsZdotdir() + { + var config = ShellIntegration.GetSpawnConfig("/usr/bin/zsh"); + + Assert.Equal("-i", config.Arguments); + Assert.NotNull(config.Environment); + Assert.Contains("ZDOTDIR=", config.Environment); + Assert.Contains("OW_USER_ZDOTDIR=", config.Environment); + + var zdotdir = config.Environment!.Split('\0')[0]["ZDOTDIR=".Length..]; + Assert.True(File.Exists(Path.Combine(zdotdir, ".zshrc"))); + Assert.True(File.Exists(Path.Combine(zdotdir, ".zshenv"))); + } + + [Fact] + public void GetSpawnConfig_PowerShell_DotSourcesIntegrationScript() + { + var config = ShellIntegration.GetSpawnConfig("powershell.exe"); + + Assert.NotNull(config.Arguments); + Assert.StartsWith("powershell.exe ", config.Arguments); + Assert.Contains("-NoExit", config.Arguments); + Assert.Contains("integration.ps1", config.Arguments); + } + + [Fact] + public void GetSpawnConfig_UnknownShell_ReturnsNoIntegration() + { + var config = ShellIntegration.GetSpawnConfig("/usr/bin/fish"); + + Assert.Null(config.Arguments); + Assert.Null(config.Environment); + } +} diff --git a/tests/OneWare.Studio.Desktop.UnitTests/UserInputEchoFilterTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/UserInputEchoFilterTests.cs new file mode 100644 index 000000000..9e8eff66e --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/UserInputEchoFilterTests.cs @@ -0,0 +1,64 @@ +using System.Text; +using OneWare.Terminal.Provider; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class UserInputEchoFilterTests +{ + private static string Filter(UserInputEchoFilter filter, string input) + { + return Encoding.UTF8.GetString(filter.Filter(Encoding.UTF8.GetBytes(input))); + } + + [Fact] + public void PassesThroughWithoutUserInput() + { + var filter = new UserInputEchoFilter(); + Assert.Equal("program output\n", Filter(filter, "program output\n")); + } + + [Fact] + public void RemovesEchoedUserInput() + { + var filter = new UserInputEchoFilter(); + filter.OnUserInput(Encoding.UTF8.GetBytes("yes")); + Assert.Equal("", Filter(filter, "yes")); + Assert.Equal("more output", Filter(filter, "more output")); + } + + [Fact] + public void RemovesEchoAcrossChunks() + { + var filter = new UserInputEchoFilter(); + filter.OnUserInput(Encoding.UTF8.GetBytes("hello")); + Assert.Equal("", Filter(filter, "he")); + Assert.Equal("!", Filter(filter, "llo!")); + } + + [Fact] + public void EnterEchoedAsCrLfIsRemoved() + { + var filter = new UserInputEchoFilter(); + filter.OnUserInput(Encoding.UTF8.GetBytes("y\r")); + Assert.Equal("output", Filter(filter, "y\r\noutput")); + } + + [Fact] + public void MismatchStopsFiltering() + { + var filter = new UserInputEchoFilter(); + filter.OnUserInput(Encoding.UTF8.GetBytes("abc")); + // Program output that does not match the pending input must survive intact. + Assert.Equal("xyz", Filter(filter, "xyz")); + Assert.Equal("abc", Filter(filter, "abc")); + } + + [Fact] + public void PartialMatchThenMismatchPassesRemainderThrough() + { + var filter = new UserInputEchoFilter(); + filter.OnUserInput(Encoding.UTF8.GetBytes("ab")); + Assert.Equal("Z", Filter(filter, "aZ")); + } +}