From 901ad1fbaf21f960be9f041cac891ae0a6777c3c Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 22:48:48 +0200 Subject: [PATCH 1/8] progress --- src/OneWare.Copilot/OneWare.Copilot.csproj | 2 +- .../Provider/OutputSequenceSuppressor.cs | 106 ++++++++++++++++++ .../Provider/PseudoTerminalConnection.cs | 99 +--------------- .../OneWare.TerminalManager.csproj | 1 + .../ViewModels/TerminalManagerViewModel.cs | 64 ++++++++++- .../PseudoTerminalConnectionTests.cs | 17 +++ .../TerminalManagerViewModelTests.cs | 43 +++++++ 7 files changed, 233 insertions(+), 99 deletions(-) create mode 100644 src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs 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.Terminal/Provider/OutputSequenceSuppressor.cs b/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs new file mode 100644 index 000000000..29b8d8d7e --- /dev/null +++ b/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using VtNetCore.Avalonia; + +namespace OneWare.Terminal.Provider; + +public sealed class OutputSequenceSuppressor : IOutputFilter, IOutputSuppressor +{ + private const int TolerantSuppressionPrefixLength = 4; + private readonly object _lock = new(); + private readonly Queue _queue = new(); + private readonly List _pending = new(); + private byte[]? _active; + private int _matchedLength; + + public void SuppressOutput(byte[] sequence) + { + if (sequence.Length == 0) return; + + lock (_lock) + { + _queue.Enqueue(sequence); + } + } + + public byte[] FilterOutput(byte[] data) + { + if (data.Length == 0) return data; + + lock (_lock) + { + if (_active == null && _queue.Count == 0) return data; + + var output = new List(data.Length + 8); + var index = 0; + + while (index < data.Length) + { + if (_active == null && _queue.Count > 0) + { + _active = _queue.Dequeue(); + _pending.Clear(); + _matchedLength = 0; + } + + if (_active == null) + { + output.Add(data[index++]); + continue; + } + + var b = data[index++]; + if (b == _active[_matchedLength]) + { + _pending.Add(b); + _matchedLength++; + if (_matchedLength == _active.Length) + ResetActiveSuppression(); + + continue; + } + + if (_matchedLength >= Math.Min(TolerantSuppressionPrefixLength, _active.Length)) + { + // Interactive shells may insert cursor movement and redraw bytes while + // echoing a command that wraps. Match the command as a subsequence. + _pending.Add(b); + if (b == (byte)'\n') + { + output.AddRange(_pending); + ResetActiveSuppression(); + } + + continue; + } + + if (_pending.Count > 0) + { + output.AddRange(_pending); + _pending.Clear(); + _matchedLength = 0; + } + + if (b == _active[0]) + { + _pending.Add(b); + _matchedLength = 1; + if (_active.Length == 1) + ResetActiveSuppression(); + } + else + { + output.Add(b); + } + } + + return output.Count == data.Length ? data : output.ToArray(); + } + } + + private void ResetActiveSuppression() + { + _active = null; + _pending.Clear(); + _matchedLength = 0; + } +} diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index a79b4e8e6..85a296ac9 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -1,17 +1,11 @@ -using System.Collections.Generic; using VtNetCore.Avalonia; namespace OneWare.Terminal.Provider; public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, IOutputFilter, IOutputSuppressor, IDisposable { - private const int TolerantSuppressionPrefixLength = 4; 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 readonly OutputSequenceSuppressor _outputSuppressor = new(); public bool IsConnected { get; private set; } @@ -78,99 +72,12 @@ public void KillProcess() public void SuppressOutput(byte[] sequence) { - if (sequence.Length == 0) return; - lock (_suppressLock) - { - _suppressQueue.Enqueue(sequence); - } + _outputSuppressor.SuppressOutput(sequence); } public byte[] FilterOutput(byte[] data) { - if (data.Length == 0) return data; - - lock (_suppressLock) - { - if (_activeSuppression == null && _suppressQueue.Count == 0) return data; - - var output = new List(data.Length + 8); - var index = 0; - - while (index < data.Length) - { - if (_activeSuppression == null && _suppressQueue.Count > 0) - { - _activeSuppression = _suppressQueue.Dequeue(); - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; - } - - if (_activeSuppression == null) - { - output.Add(data[index++]); - continue; - } - - var b = data[index++]; - if (b == _activeSuppression[_matchedSuppressionLength]) - { - _pendingSuppression.Add(b); - _matchedSuppressionLength++; - if (_matchedSuppressionLength == _activeSuppression.Length) - { - ResetActiveSuppression(); - } - - continue; - } - - 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; - } - - if (_pendingSuppression.Count > 0) - { - output.AddRange(_pendingSuppression); - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; - } - - if (b == _activeSuppression[0]) - { - _pendingSuppression.Add(b); - _matchedSuppressionLength = 1; - if (_activeSuppression.Length == 1) - { - ResetActiveSuppression(); - } - } - else - { - output.Add(b); - } - } - - return output.Count == data.Length ? data : output.ToArray(); - } - } - - private void ResetActiveSuppression() - { - _activeSuppression = null; - _pendingSuppression.Clear(); - _matchedSuppressionLength = 0; + return _outputSuppressor.FilterOutput(data); } public void SetTerminalWindowSize(int columns, int rows) 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..abf3b66f7 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; @@ -18,6 +19,7 @@ public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService { public const string IconKey = "Material.Console"; private const string PromptMarkerPrefix = "\u001b]9;OW_DONE:"; + private const string CompletionMarkerControlPrefix = "\u001b[1A\r\u001b[2K"; // 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 @@ -148,6 +150,7 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var markerPrefix = PromptMarkerPrefix; var commandToSend = command; string? markerCommand = null; + OutputSequenceSuppressor? chatOutputSuppressor = null; if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -161,6 +164,8 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo markerCommand = $"__ow_exit=$?; printf '\\033[1A\\r\\033[2K\\033]9;OW_DONE:{executionId}:%s\\007' \"$__ow_exit\""; commandToSend = $"{command}\n{markerCommand}"; + chatOutputSuppressor = new OutputSequenceSuppressor(); + chatOutputSuppressor.SuppressOutput(Encoding.UTF8.GetBytes(markerCommand)); } void OnConnectionClosed(object? sender, EventArgs args) @@ -174,7 +179,10 @@ void OnConnectionClosed(object? sender, EventArgs args) void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) { - var text = Encoding.UTF8.GetString(args.Data); + var data = chatOutputSuppressor?.FilterOutput(args.Data) ?? args.Data; + if (data.Length == 0) return; + + var text = Encoding.UTF8.GetString(data); string current; int? completedExitCode = null; @@ -183,7 +191,9 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg output.Append(text); current = output.ToString(); - while (TryExtractOscMarker(current, markerPrefix, out var exitCode, out var cleaned)) + while ((chatOutputSuppressor != null + ? TryExtractCompletionMarker(current, markerPrefix, out var exitCode, out var cleaned) + : TryExtractOscMarker(current, markerPrefix, out exitCode, out cleaned))) { while (TryExtractOscMarker(cleaned, PromptMarkerPrefix, out _, out var promptCleaned)) cleaned = promptCleaned; @@ -366,6 +376,56 @@ private static bool TryExtractOscMarker(string current, string markerPrefix, out return true; } + internal static bool TryExtractCompletionMarker(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; + + if (endIndex < 0 || (stIndex >= 0 && stIndex < endIndex)) + endIndex = stIndex; + + if (endIndex < 0) return false; + + var markerContent = current.Substring(markerIndex + markerPrefix.Length, + endIndex - (markerIndex + markerPrefix.Length)); + int.TryParse(markerContent, out exitCode); + + var promptMarkerIndex = markerIndex > 0 + ? current.LastIndexOf(PromptMarkerPrefix, markerIndex - 1, StringComparison.Ordinal) + : -1; + if (promptMarkerIndex >= 0) + { + cleanedOutput = current[..promptMarkerIndex]; + return true; + } + + var clearSequenceIndex = markerIndex - CompletionMarkerControlPrefix.Length; + if (clearSequenceIndex >= 0 && + current.AsSpan(clearSequenceIndex, CompletionMarkerControlPrefix.Length) + .SequenceEqual(CompletionMarkerControlPrefix)) + { + var promptLineEnd = clearSequenceIndex > 0 + ? current.LastIndexOf('\n', clearSequenceIndex - 1) + : -1; + var previousLineEnd = promptLineEnd > 0 + ? current.LastIndexOf('\n', promptLineEnd - 1) + : -1; + cleanedOutput = previousLineEnd >= 0 ? current[..(previousLineEnd + 1)] : string.Empty; + return true; + } + + cleanedOutput = current[..markerIndex]; + 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/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index 82c6bc86f..4b9dc7cf2 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -49,4 +49,21 @@ public void FilterOutput_ReleasesIncompleteSuppressionAtLineEnd() Assert.Equal("__ow_broken\r\nnext", Encoding.ASCII.GetString(output)); } + + [Fact] + public void FilterOutput_UsesIndependentStateForSeparateConsumers() + { + var marker = Encoding.ASCII.GetBytes(MarkerCommand); + var terminalFilter = new OutputSequenceSuppressor(); + var chatFilter = new OutputSequenceSuppressor(); + terminalFilter.SuppressOutput(marker); + chatFilter.SuppressOutput(marker); + var rawOutput = Encoding.ASCII.GetBytes($"before{MarkerCommand}after"); + + var terminalOutput = terminalFilter.FilterOutput(rawOutput); + var chatOutput = chatFilter.FilterOutput(rawOutput); + + Assert.Equal("beforeafter", Encoding.ASCII.GetString(terminalOutput)); + Assert.Equal("beforeafter", Encoding.ASCII.GetString(chatOutput)); + } } diff --git a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs new file mode 100644 index 000000000..eff390403 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs @@ -0,0 +1,43 @@ +using OneWare.TerminalManager.ViewModels; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class TerminalManagerViewModelTests +{ + private const string ExecutionMarkerPrefix = "\u001b]9;OW_DONE:execution:"; + + [Fact] + public void TryExtractCompletionMarker_RemovesPromptMarkedShellOutput() + { + var output = "20/20 seconds\r\nFinished after 20.0 seconds.\r\n"; + var current = output + + "\u001b]9;OW_DONE:0\u0007hmenn@aurora:/project$ \r\n" + + "\u001b[1A\r\u001b[2K" + ExecutionMarkerPrefix + "0\u0007" + + "\u001b]9;OW_DONE:0\u0007hmenn@aurora:/project$ "; + + var found = TerminalManagerViewModel.TryExtractCompletionMarker( + current, ExecutionMarkerPrefix, out var exitCode, out var cleaned); + + Assert.True(found); + Assert.Equal(0, exitCode); + Assert.Equal(output, cleaned); + } + + [Fact] + public void TryExtractCompletionMarker_RemovesUnmarkedShellPrompts() + { + var output = "20/20 seconds\r\nFinished after 20.0 seconds.\r\n"; + var current = output + + "hmenn@aurora:/project$ \r\n" + + "\u001b[1A\r\u001b[2K" + ExecutionMarkerPrefix + "0\u0007" + + "hmenn@aurora:/project$ "; + + var found = TerminalManagerViewModel.TryExtractCompletionMarker( + current, ExecutionMarkerPrefix, out var exitCode, out var cleaned); + + Assert.True(found); + Assert.Equal(0, exitCode); + Assert.Equal(output, cleaned); + } +} From c80e3ee562954c9e4bd189e85e208b9b41120441 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 23:26:44 +0200 Subject: [PATCH 2/8] Use out-of-band terminal completion channel Keep AI commands in the real interactive terminal while reporting completion and exit status through inherited pipes. Recover the completion handshake after interactive Ctrl+C and remove prompt-marker parsing from chat output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Provider/IPseudoTerminal.cs | 4 + .../Provider/OutputSequenceSuppressor.cs | 9 + .../Provider/PseudoTerminalConnection.cs | 141 +++++++++++-- src/OneWare.Terminal/Provider/Unix/Native.cs | 6 +- .../Provider/Unix/UnixPseudoTerminal.cs | 21 +- .../Unix/UnixPseudoTerminalProvider.cs | 38 +++- .../Provider/Win32/ConPtyNative.cs | 9 + .../Win32/Win32ConPtyPseudoTerminal.cs | 20 +- .../Win32ConPtyPseudoTerminalProvider.cs | 110 ++++++++-- .../ViewModels/TerminalViewModel.cs | 170 +++------------- .../ViewModels/TerminalManagerViewModel.cs | 191 ++++++------------ src/VtNetCore.Avalonia | 2 +- .../PseudoTerminalConnectionTests.cs | 59 +++++- .../TerminalManagerViewModelTests.cs | 29 +-- 14 files changed, 472 insertions(+), 337 deletions(-) diff --git a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs index c6474ed2c..8aaf8d54d 100644 --- a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs @@ -10,4 +10,8 @@ public interface IPseudoTerminal : IDisposable Task WriteAsync(byte[] buffer, int offset, int count); Task ReadAsync(byte[] buffer, int offset, int count); + + Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); + + Task WriteControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs b/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs index 29b8d8d7e..b26b475d0 100644 --- a/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs +++ b/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs @@ -22,6 +22,15 @@ public void SuppressOutput(byte[] sequence) } } + public void Reset() + { + lock (_lock) + { + _queue.Clear(); + ResetActiveSuppression(); + } + } + public byte[] FilterOutput(byte[] data) { if (data.Length == 0) return data; diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index 85a296ac9..3953e23f9 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -1,3 +1,4 @@ +using System.Text; using VtNetCore.Avalonia; namespace OneWare.Terminal.Provider; @@ -6,6 +7,7 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I { private CancellationTokenSource? _cancellationSource; private readonly OutputSequenceSuppressor _outputSuppressor = new(); + private long _outputVersion; public bool IsConnected { get; private set; } @@ -13,30 +15,16 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I public event EventHandler? Closed; + public event EventHandler? CommandCompleted; + + public event EventHandler? UserInterrupted; + 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); + _ = ReadControlAsync(_cancellationSource.Token); IsConnected = true; @@ -55,6 +43,8 @@ public void Disconnect() public void SendData(byte[] data) { _ = terminal.WriteAsync(data, 0, data.Length); + if (data.Contains((byte)0x03) && _cancellationSource is { } cancellationSource) + _ = NotifyUserInterruptedAsync(cancellationSource.Token); } public void KillProcess() @@ -80,6 +70,11 @@ public byte[] FilterOutput(byte[] data) return _outputSuppressor.FilterOutput(data); } + public void ResetOutputSuppression() + { + _outputSuppressor.Reset(); + } + public void SetTerminalWindowSize(int columns, int rows) { terminal.SetSize(columns, rows); @@ -99,4 +94,110 @@ private void Process_Exited(object? sender, EventArgs e) Closed?.Invoke(this, EventArgs.Empty); } + + 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; + + var receivedData = new byte[bytesReceived]; + Buffer.BlockCopy(data, 0, receivedData, 0, bytesReceived); + DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = receivedData }); + Interlocked.Increment(ref _outputVersion); + } + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task ReadControlAsync(CancellationToken cancellationToken) + { + var data = new byte[256]; + var pending = new StringBuilder(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var bytesReceived = await terminal.ReadControlAsync(data, 0, data.Length, cancellationToken); + if (bytesReceived <= 0) break; + + pending.Append(Encoding.ASCII.GetString(data, 0, bytesReceived)); + while (TryReadControlFrame(pending, out var executionId, out var exitCode)) + { + await WaitForOutputDrainAsync(cancellationToken); + CommandCompleted?.Invoke(this, new TerminalCommandCompletedEventArgs(executionId, exitCode)); + + var acknowledgement = Encoding.ASCII.GetBytes($"{executionId}\n"); + await terminal.WriteControlAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); + } + } + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task WaitForOutputDrainAsync(CancellationToken cancellationToken) + { + var previousVersion = Volatile.Read(ref _outputVersion); + var stableSamples = 0; + + while (stableSamples < 3) + { + await Task.Delay(20, cancellationToken); + var currentVersion = Volatile.Read(ref _outputVersion); + if (currentVersion == previousVersion) + { + stableSamples++; + } + else + { + previousVersion = currentVersion; + stableSamples = 0; + } + } + } + + private async Task NotifyUserInterruptedAsync(CancellationToken cancellationToken) + { + try + { + await WaitForOutputDrainAsync(cancellationToken); + UserInterrupted?.Invoke(this, EventArgs.Empty); + } + catch (OperationCanceledException) + { + } + } + + internal static bool TryReadControlFrame(StringBuilder pending, out string executionId, out int exitCode) + { + executionId = string.Empty; + exitCode = 0; + + var newlineIndex = pending.ToString().IndexOf('\n'); + if (newlineIndex < 0) return false; + + var line = pending.ToString(0, newlineIndex).TrimEnd('\r'); + pending.Remove(0, newlineIndex + 1); + + var separatorIndex = line.LastIndexOf(':'); + if (separatorIndex <= 0 || !int.TryParse(line[(separatorIndex + 1)..], out exitCode)) + return false; + + executionId = line[..separatorIndex]; + return true; + } +} + +public sealed class TerminalCommandCompletedEventArgs(string executionId, int exitCode) : EventArgs +{ + public string ExecutionId { get; } = executionId; + public int ExitCode { get; } = exitCode; } diff --git a/src/OneWare.Terminal/Provider/Unix/Native.cs b/src/OneWare.Terminal/Provider/Unix/Native.cs index 149ffd934..41d58035c 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); @@ -151,6 +151,8 @@ 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(); diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs index 2ddefc43b..ad5d92299 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs @@ -9,13 +9,18 @@ public class UnixPseudoTerminal : IPseudoTerminal private readonly int _cfg; private readonly Stream _stdin; private readonly Stream _stdout; + private readonly Stream _controlInput; + private readonly Stream _controlOutput; private bool _isDisposed; - public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout) + public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout, Stream controlInput, + Stream controlOutput) { Process = process; _stdin = stdin; _stdout = stdout; + _controlInput = controlInput; + _controlOutput = controlOutput; _cfg = cfg; } @@ -27,6 +32,8 @@ public void Dispose() _isDisposed = true; _stdin.Dispose(); _stdout.Dispose(); + _controlInput.Dispose(); + _controlOutput.Dispose(); } public async Task ReadAsync(byte[] buffer, int offset, int count) @@ -74,6 +81,18 @@ await Task.Run(() => }); } + public Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _controlInput.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public async Task WriteControlAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + await _controlOutput.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + await _controlOutput.FlushAsync(cancellationToken); + } + public void SetSize(int columns, int rows) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs index 648b729f4..ca234f14e 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs @@ -6,6 +6,9 @@ namespace OneWare.Terminal.Provider.Unix; public class UnixPseudoTerminalProvider : IPseudoTerminalProvider { + private const int CompletionFileDescriptor = 198; + private const int AcknowledgementFileDescriptor = 199; + public IPseudoTerminal? Create(int columns, int rows, string initialDirectory, string command, string? environment, string? arguments) { @@ -17,6 +20,17 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider ws_ypixel = 0 }; + var completionPipe = new int[2]; + var acknowledgementPipe = new int[2]; + if (Native.pipe(completionPipe) != 0) + return null; + if (Native.pipe(acknowledgementPipe) != 0) + { + Native.close(completionPipe[0]); + Native.close(completionPipe[1]); + return null; + } + //Collect ENV Vars before fork to avoid EntryPointNotFoundException var envMap = new Dictionary(StringComparer.Ordinal); var env = Environment.GetEnvironmentVariables(); @@ -38,8 +52,12 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider var value = entry.Substring(separatorIndex + 1); envMap[key] = value; } + } + envMap["OW_COMPLETION_FD"] = CompletionFileDescriptor.ToString(); + envMap["OW_ACK_FD"] = AcknowledgementFileDescriptor.ToString(); + var envVars = new List(envMap.Count + 2); foreach (var pair in envMap) envVars.Add($"{pair.Key}={pair.Value}"); @@ -64,15 +82,33 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider //pid will be 0 on the forked process if (pid == 0) { + Native.close(completionPipe[0]); + Native.close(acknowledgementPipe[1]); + Native.dup2(completionPipe[1], CompletionFileDescriptor); + Native.dup2(acknowledgementPipe[0], AcknowledgementFileDescriptor); + Native.close(completionPipe[1]); + Native.close(acknowledgementPipe[0]); Native.chdir(initialDirectory); Native.execve(argvArray[0], argvArray, envArray); Native._exit(1); } + Native.close(completionPipe[1]); + Native.close(acknowledgementPipe[0]); + + if (pid < 0) + { + Native.close(completionPipe[0]); + Native.close(acknowledgementPipe[1]); + return null; + } + var stdin = Native.dup(masterFd); var process = Process.GetProcessById(pid); return new UnixPseudoTerminal(process, masterFd, new FileStream(new SafeFileHandle(new IntPtr(stdin), true), - FileAccess.Write), new FileStream(new SafeFileHandle(new IntPtr(masterFd), true), FileAccess.Read)); + FileAccess.Write), new FileStream(new SafeFileHandle(new IntPtr(masterFd), true), FileAccess.Read), + new FileStream(new SafeFileHandle(new IntPtr(completionPipe[0]), true), FileAccess.Read), + new FileStream(new SafeFileHandle(new IntPtr(acknowledgementPipe[1]), true), FileAccess.Write)); } } diff --git a/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs index 65fecd932..c3abdcebe 100644 --- a/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs +++ b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs @@ -7,6 +7,7 @@ namespace OneWare.Terminal.Provider.Win32; internal static class ConPtyNative { public const int PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016; + public const int PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002; public const int EXTENDED_STARTUPINFO_PRESENT = 0x00080000; public const int CREATE_UNICODE_ENVIRONMENT = 0x00000400; @@ -92,6 +93,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..9efae0fbc 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs @@ -8,15 +8,19 @@ public class Win32ConPtyPseudoTerminal : IPseudoTerminal private readonly IntPtr _pseudoConsole; private readonly FileStream _stdin; private readonly FileStream _stdout; + private readonly FileStream _controlInput; + private readonly FileStream _controlOutput; private bool _isDisposed; public Win32ConPtyPseudoTerminal(Process process, IntPtr pseudoConsole, SafeFileHandle inputWrite, - SafeFileHandle outputRead) + SafeFileHandle outputRead, SafeFileHandle controlRead, SafeFileHandle acknowledgementWrite) { Process = process; _pseudoConsole = pseudoConsole; _stdin = new FileStream(inputWrite, FileAccess.Write, 4096, false); _stdout = new FileStream(outputRead, FileAccess.Read, 4096, false); + _controlInput = new FileStream(controlRead, FileAccess.Read, 4096, true); + _controlOutput = new FileStream(acknowledgementWrite, FileAccess.Write, 4096, true); } public Process Process { get; } @@ -41,6 +45,18 @@ public async Task ReadAsync(byte[] buffer, int offset, int count) return await _stdout.ReadAsync(buffer, offset, count).ConfigureAwait(false); } + public Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _controlInput.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public async Task WriteControlAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + await _controlOutput.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + await _controlOutput.FlushAsync(cancellationToken); + } + public void Dispose() { if (_isDisposed) @@ -49,6 +65,8 @@ public void Dispose() _isDisposed = true; _stdin.Dispose(); _stdout.Dispose(); + _controlInput.Dispose(); + _controlOutput.Dispose(); if (_pseudoConsole != IntPtr.Zero) ConPtyNative.ClosePseudoConsole(_pseudoConsole); diff --git a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs index 009512212..7d0ecd2e3 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.Globalization; using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32.SafeHandles; @@ -22,8 +24,13 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider SafeFileHandle? inputWrite = null; SafeFileHandle? outputRead = null; SafeFileHandle? outputWrite = null; + SafeFileHandle? completionRead = null; + SafeFileHandle? completionWrite = null; + SafeFileHandle? acknowledgementRead = null; + SafeFileHandle? acknowledgementWrite = null; var pseudoConsole = IntPtr.Zero; var attributeList = IntPtr.Zero; + var inheritedHandleList = IntPtr.Zero; var environmentBlock = IntPtr.Zero; var terminalCreated = false; @@ -31,6 +38,8 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider { CreatePipePair(out inputRead, out inputWrite); CreatePipePair(out outputRead, out outputWrite); + CreatePipePair(out completionRead, out completionWrite, inheritWrite: true); + CreatePipePair(out acknowledgementRead, out acknowledgementWrite, inheritRead: true); var result = ConPtyNative.CreatePseudoConsole( new ConPtyNative.Coord((short)columns, (short)rows), @@ -45,7 +54,8 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider inputRead.Dispose(); outputWrite.Dispose(); - InitializeAttributeList(pseudoConsole, out attributeList); + InitializeAttributeList(pseudoConsole, completionWrite, acknowledgementRead, out attributeList, + out inheritedHandleList); var startupInfo = new ConPtyNative.StartupInfoEx { @@ -56,14 +66,16 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider lpAttributeList = attributeList }; - if (!string.IsNullOrWhiteSpace(environment)) environmentBlock = Marshal.StringToHGlobalUni(environment); + environment = AddControlEnvironment(environment, completionWrite.DangerousGetHandle(), + acknowledgementRead.DangerousGetHandle()); + environmentBlock = Marshal.StringToHGlobalUni(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, + if (!ConPtyNative.CreateProcessW(null, commandLineBuilder, IntPtr.Zero, IntPtr.Zero, true, creationFlags, environmentBlock, initialDirectory, ref startupInfo, out var processInformation)) { ConPtyNative.ClosePseudoConsole(pseudoConsole); @@ -75,7 +87,11 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider var process = Process.GetProcessById(processInformation.dwProcessId); - var terminal = new Win32ConPtyPseudoTerminal(process, pseudoConsole, inputWrite, outputRead); + completionWrite.Dispose(); + acknowledgementRead.Dispose(); + + var terminal = new Win32ConPtyPseudoTerminal(process, pseudoConsole, inputWrite, outputRead, + completionRead, acknowledgementWrite); terminalCreated = true; return terminal; } @@ -91,6 +107,9 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider Marshal.FreeHGlobal(attributeList); } + if (inheritedHandleList != IntPtr.Zero) + Marshal.FreeHGlobal(inheritedHandleList); + if (environmentBlock != IntPtr.Zero) Marshal.FreeHGlobal(environmentBlock); @@ -99,10 +118,14 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider if (inputRead is { IsInvalid: false }) inputRead.Dispose(); if (outputWrite is { IsInvalid: false }) outputWrite.Dispose(); + if (completionWrite is { IsInvalid: false }) completionWrite.Dispose(); + if (acknowledgementRead is { IsInvalid: false }) acknowledgementRead.Dispose(); if (!terminalCreated) { if (inputWrite is { IsInvalid: false }) inputWrite.Dispose(); if (outputRead is { IsInvalid: false }) outputRead.Dispose(); + if (completionRead is { IsInvalid: false }) completionRead.Dispose(); + if (acknowledgementWrite is { IsInvalid: false }) acknowledgementWrite.Dispose(); } } } @@ -115,24 +138,76 @@ 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 void InitializeAttributeList(IntPtr pseudoConsole, out IntPtr attributeList) + private static string AddControlEnvironment(string? environment, IntPtr completionHandle, + IntPtr acknowledgementHandle) + { + var values = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(environment)) + { + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is string key && entry.Value is string value) + values[key] = value; + } + } + else + { + 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)..]; + } + } + + values["OW_COMPLETION_HANDLE"] = completionHandle.ToInt64().ToString(CultureInfo.InvariantCulture); + values["OW_ACK_HANDLE"] = acknowledgementHandle.ToInt64().ToString(CultureInfo.InvariantCulture); + + 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, SafeFileHandle completionWrite, + SafeFileHandle acknowledgementRead, out IntPtr attributeList, out IntPtr inheritedHandleList) { attributeList = IntPtr.Zero; + inheritedHandleList = IntPtr.Zero; var size = IntPtr.Zero; - if (!ConPtyNative.InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size)) + if (!ConPtyNative.InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref size)) { var error = Marshal.GetLastWin32Error(); if (error != 122) @@ -141,12 +216,21 @@ private static void InitializeAttributeList(IntPtr pseudoConsole, out IntPtr att attributeList = Marshal.AllocHGlobal(size); - if (!ConPtyNative.InitializeProcThreadAttributeList(attributeList, 1, 0, ref size)) + if (!ConPtyNative.InitializeProcThreadAttributeList(attributeList, 2, 0, ref size)) throw new Win32Exception(Marshal.GetLastWin32Error()); if (!ConPtyNative.UpdateProcThreadAttribute(attributeList, 0, ConPtyNative.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, pseudoConsole, IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error()); + + inheritedHandleList = Marshal.AllocHGlobal(IntPtr.Size * 2); + Marshal.WriteIntPtr(inheritedHandleList, 0, completionWrite.DangerousGetHandle()); + Marshal.WriteIntPtr(inheritedHandleList, IntPtr.Size, acknowledgementRead.DangerousGetHandle()); + + if (!ConPtyNative.UpdateProcThreadAttribute(attributeList, 0, + ConPtyNative.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inheritedHandleList, IntPtr.Size * 2, + IntPtr.Zero, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error()); } } \ No newline at end of file diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 1e569259c..8fa4cce54 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; @@ -96,7 +93,6 @@ 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)) @@ -105,7 +101,7 @@ public void CreateConnection() startArguments = "-i"; } - var terminal = SProvider.Create(80, 32, WorkingDir, shellExecutable, environment, startArguments); + var terminal = SProvider.Create(80, 32, WorkingDir, shellExecutable, null, startArguments); if (terminal == null) { @@ -159,6 +155,23 @@ public void SuppressEcho(byte[] data) } } + public string BuildCompletionCommand(string executionId) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "$__ow_success=$?; " + + "$__ow_exit=if ($__ow_success) { 0 } elseif ($global:LASTEXITCODE -ne 0) " + + "{ [int]$global:LASTEXITCODE } else { 1 }; " + + "$__ow_esc=[char]27; Write-Host ($__ow_esc + '[1A' + \"`r\" + $__ow_esc + '[2K') -NoNewline; " + + $"$global:__ow_completion.WriteLine('{executionId}:' + $__ow_exit); " + + "[void]$global:__ow_ack.ReadLine()"; + } + + return + $"__ow_exit=$?; printf '\\033[1A\\r\\033[2K'; printf '{executionId}:%s\\n' \"$__ow_exit\" >&198; " + + "IFS= read -r __ow_ack <&199"; + } + public void CloseConnection() { if (Connection != null) @@ -179,145 +192,20 @@ private static string BuildWindowsStartArguments(string workingDir) // 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)) + ' ' " + - "} " + - "}; " + + "$__ow_completion_handle=[Microsoft.Win32.SafeHandles.SafeFileHandle]::new(" + + "[IntPtr][long]$env:OW_COMPLETION_HANDLE,$false); " + + "$__ow_completion_stream=[System.IO.FileStream]::new(" + + "$__ow_completion_handle,[System.IO.FileAccess]::Write,4096,$false); " + + "$global:__ow_completion=[System.IO.StreamWriter]::new($__ow_completion_stream); " + + "$global:__ow_completion.AutoFlush=$true; " + + "$__ow_ack_handle=[Microsoft.Win32.SafeHandles.SafeFileHandle]::new(" + + "[IntPtr][long]$env:OW_ACK_HANDLE,$false); " + + "$__ow_ack_stream=[System.IO.FileStream]::new(" + + "$__ow_ack_handle,[System.IO.FileAccess]::Read,4096,$false); " + + "$global:__ow_ack=[System.IO.StreamReader]::new($__ow_ack_stream); " + $"Set-Location '{escapedDir}'"; return $"powershell.exe -NoProfile -NoExit -Command \"{bootstrapCmd}\""; } - - 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) - { - 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(); - } } diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index abf3b66f7..60d7bc8ba 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -18,7 +18,6 @@ namespace OneWare.TerminalManager.ViewModels; public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService { public const string IconKey = "Material.Console"; - private const string PromptMarkerPrefix = "\u001b]9;OW_DONE:"; private const string CompletionMarkerControlPrefix = "\u001b[1A\r\u001b[2K"; // Automation terminals are pooled per id so that concurrent commands (e.g. an AI agent @@ -137,7 +136,7 @@ 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); @@ -146,27 +145,12 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var output = new StringBuilder(); var outputLock = new object(); var resultTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var commandSent = false; - var markerPrefix = PromptMarkerPrefix; - var commandToSend = command; - string? markerCommand = null; - OutputSequenceSuppressor? chatOutputSuppressor = null; - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - 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}"; - chatOutputSuppressor = new OutputSequenceSuppressor(); - chatOutputSuppressor.SuppressOutput(Encoding.UTF8.GetBytes(markerCommand)); - } + var executionId = Guid.NewGuid().ToString("N"); + var completionCommand = terminal.BuildCompletionCommand(executionId); + var commandToSend = $"{command}\n{completionCommand}"; + var chatOutputSuppressor = new OutputSequenceSuppressor(); + chatOutputSuppressor.SuppressOutput(Encoding.UTF8.GetBytes(completionCommand)); + var interruptRecoveryScheduled = 0; void OnConnectionClosed(object? sender, EventArgs args) { @@ -179,58 +163,63 @@ void OnConnectionClosed(object? sender, EventArgs args) void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) { - var data = chatOutputSuppressor?.FilterOutput(args.Data) ?? args.Data; + var data = chatOutputSuppressor.FilterOutput(args.Data); if (data.Length == 0) return; var text = Encoding.UTF8.GetString(data); string current; - int? completedExitCode = null; lock (outputLock) { output.Append(text); current = output.ToString(); + } - while ((chatOutputSuppressor != null - ? TryExtractCompletionMarker(current, markerPrefix, out var exitCode, out var cleaned) - : TryExtractOscMarker(current, markerPrefix, out exitCode, out cleaned))) - { - while (TryExtractOscMarker(cleaned, PromptMarkerPrefix, out _, out var promptCleaned)) - cleaned = promptCleaned; - - current = cleaned; - output.Clear(); - output.Append(cleaned); - - if (!commandSent) continue; + if (!resultTcs.Task.IsCompleted) + outputProgress?.Report(current); + } - completedExitCode = exitCode; - break; - } - } + void OnCommandCompleted(object? sender, TerminalCommandCompletedEventArgs args) + { + if (!string.Equals(args.ExecutionId, executionId, StringComparison.Ordinal)) return; - if (completedExitCode is { } exitCodeResult) + string cleaned; + lock (outputLock) { - outputProgress?.Report(current); - resultTcs.TrySetResult(new TerminalExecutionResult(current, exitCodeResult, false)); - return; + cleaned = TrimCompletedOutput(output.ToString()); + output.Clear(); + output.Append(cleaned); } - if (commandSent && !resultTcs.Task.IsCompleted) - outputProgress?.Report(current); + outputProgress?.Report(cleaned); + resultTcs.TrySetResult(new TerminalExecutionResult(cleaned, args.ExitCode, false)); + } + + void OnUserInterrupted(object? sender, EventArgs args) + { + if (Interlocked.Exchange(ref interruptRecoveryScheduled, 1) != 0) return; + _ = RecoverCompletionAfterUserInterruptAsync(); } - terminal.Connection.DataReceived += OnDataReceived; - terminal.Connection.Closed += OnConnectionClosed; - if (markerCommand != null) + async Task RecoverCompletionAfterUserInterruptAsync() { - // 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)); + await Task.Yield(); + if (resultTcs.Task.IsCompleted) return; + + var completionBytes = Encoding.UTF8.GetBytes(completionCommand); + connection.ResetOutputSuppression(); + chatOutputSuppressor.Reset(); + connection.SuppressOutput(completionBytes); + chatOutputSuppressor.SuppressOutput(completionBytes); + terminal.Send(completionCommand); + Interlocked.Exchange(ref interruptRecoveryScheduled, 0); } - commandSent = true; + connection.DataReceived += OnDataReceived; + connection.Closed += OnConnectionClosed; + connection.CommandCompleted += OnCommandCompleted; + connection.UserInterrupted += OnUserInterrupted; + terminal.SuppressEcho(Encoding.UTF8.GetBytes(completionCommand)); terminal.Send(commandToSend); TerminalExecutionResult result; @@ -262,8 +251,10 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg } finally { - terminal.Connection.DataReceived -= OnDataReceived; - terminal.Connection.Closed -= OnConnectionClosed; + connection.DataReceived -= OnDataReceived; + connection.Closed -= OnConnectionClosed; + connection.CommandCompleted -= OnCommandCompleted; + connection.UserInterrupted -= OnUserInterrupted; if (closeWhenDone) terminal.Close(); } @@ -343,87 +334,19 @@ 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; - } - - internal static bool TryExtractCompletionMarker(string current, string markerPrefix, out int exitCode, - out string cleanedOutput) + internal static string TrimCompletedOutput(string current) { - 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; - - if (endIndex < 0 || (stIndex >= 0 && stIndex < endIndex)) - endIndex = stIndex; + var clearSequenceIndex = current.LastIndexOf(CompletionMarkerControlPrefix, StringComparison.Ordinal); + if (clearSequenceIndex < 0) return current; - if (endIndex < 0) return false; - - var markerContent = current.Substring(markerIndex + markerPrefix.Length, - endIndex - (markerIndex + markerPrefix.Length)); - int.TryParse(markerContent, out exitCode); - - var promptMarkerIndex = markerIndex > 0 - ? current.LastIndexOf(PromptMarkerPrefix, markerIndex - 1, StringComparison.Ordinal) + var promptLineEnd = clearSequenceIndex > 0 + ? current.LastIndexOf('\n', clearSequenceIndex - 1) + : -1; + var previousLineEnd = promptLineEnd > 0 + ? current.LastIndexOf('\n', promptLineEnd - 1) : -1; - if (promptMarkerIndex >= 0) - { - cleanedOutput = current[..promptMarkerIndex]; - return true; - } - - var clearSequenceIndex = markerIndex - CompletionMarkerControlPrefix.Length; - if (clearSequenceIndex >= 0 && - current.AsSpan(clearSequenceIndex, CompletionMarkerControlPrefix.Length) - .SequenceEqual(CompletionMarkerControlPrefix)) - { - var promptLineEnd = clearSequenceIndex > 0 - ? current.LastIndexOf('\n', clearSequenceIndex - 1) - : -1; - var previousLineEnd = promptLineEnd > 0 - ? current.LastIndexOf('\n', promptLineEnd - 1) - : -1; - cleanedOutput = previousLineEnd >= 0 ? current[..(previousLineEnd + 1)] : string.Empty; - return true; - } - cleanedOutput = current[..markerIndex]; - return true; + return previousLineEnd >= 0 ? current[..(previousLineEnd + 1)] : string.Empty; } public async Task ExecuteInTerminalAsync(string command, string id, diff --git a/src/VtNetCore.Avalonia b/src/VtNetCore.Avalonia index ac3077126..bddd1881c 160000 --- a/src/VtNetCore.Avalonia +++ b/src/VtNetCore.Avalonia @@ -1 +1 @@ -Subproject commit ac3077126317e460562cc335ce3c2655f559a89d +Subproject commit bddd1881c8d7f959cfaa341dce7d22f7084f5e98 diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index 4b9dc7cf2..b698deb92 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -1,5 +1,9 @@ +using System; +using System.IO; using System.Text; +using System.Threading.Tasks; using OneWare.Terminal.Provider; +using OneWare.Terminal.Provider.Unix; using Xunit; namespace OneWare.Studio.Desktop.UnitTests; @@ -7,7 +11,8 @@ namespace OneWare.Studio.Desktop.UnitTests; public class PseudoTerminalConnectionTests { private const string MarkerCommand = - "__ow_exit=$?; printf '\\033[1A\\r\\033[2K\\033]9;OW_DONE:0123456789abcdef:%s\\007' \"$__ow_exit\""; + "__ow_exit=$?; printf '\\033[1A\\r\\033[2K'; printf '0123456789abcdef:%s\\n' \"$__ow_exit\" >&198; " + + "IFS= read -r __ow_ack <&199"; [Fact] public void FilterOutput_SuppressesSequenceAcrossChunks() @@ -66,4 +71,56 @@ public void FilterOutput_UsesIndependentStateForSeparateConsumers() Assert.Equal("beforeafter", Encoding.ASCII.GetString(terminalOutput)); Assert.Equal("beforeafter", Encoding.ASCII.GetString(chatOutput)); } + + [Fact] + public async Task ControlChannel_CompletesUnixCommandOutOfBand() + { + if (OperatingSystem.IsWindows()) return; + + var provider = new UnixPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), "/bin/bash", null, + "--noprofile --norc"); + Assert.NotNull(terminal); + + using var connection = new PseudoTerminalConnection(terminal); + var output = new StringBuilder(); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + connection.DataReceived += (_, args) => output.Append(Encoding.UTF8.GetString(args.Data)); + connection.CommandCompleted += (_, args) => completion.TrySetResult(args); + connection.Connect(); + + const string executionId = "integration"; + var command = "printf 'control-channel-output\\n'\n" + + "__ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + + $"printf '{executionId}:%s\\n' \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199\r"; + connection.SendData(Encoding.ASCII.GetBytes(command)); + + var result = await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(executionId, result.ExecutionId); + Assert.Equal(0, result.ExitCode); + Assert.Contains("control-channel-output", output.ToString()); + Assert.DoesNotContain("OW_DONE", output.ToString()); + } + + [Fact] + public async Task SendData_ReportsUserInterrupt() + { + if (OperatingSystem.IsWindows()) return; + + var provider = new UnixPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), "/bin/bash", null, + "--noprofile --norc"); + Assert.NotNull(terminal); + + using var connection = new PseudoTerminalConnection(terminal); + var interrupted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + connection.UserInterrupted += (_, _) => interrupted.TrySetResult(); + connection.Connect(); + connection.SendData([0x03]); + + await interrupted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + } } diff --git a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs index eff390403..43005355a 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs @@ -5,39 +5,24 @@ namespace OneWare.Studio.Desktop.UnitTests; public class TerminalManagerViewModelTests { - private const string ExecutionMarkerPrefix = "\u001b]9;OW_DONE:execution:"; - [Fact] - public void TryExtractCompletionMarker_RemovesPromptMarkedShellOutput() + public void TrimCompletedOutput_RemovesPromptAndControlSequence() { var output = "20/20 seconds\r\nFinished after 20.0 seconds.\r\n"; var current = output + - "\u001b]9;OW_DONE:0\u0007hmenn@aurora:/project$ \r\n" + - "\u001b[1A\r\u001b[2K" + ExecutionMarkerPrefix + "0\u0007" + - "\u001b]9;OW_DONE:0\u0007hmenn@aurora:/project$ "; + "hmenn@aurora:/project$ \r\n" + + "\u001b[1A\r\u001b[2K"; - var found = TerminalManagerViewModel.TryExtractCompletionMarker( - current, ExecutionMarkerPrefix, out var exitCode, out var cleaned); + var cleaned = TerminalManagerViewModel.TrimCompletedOutput(current); - Assert.True(found); - Assert.Equal(0, exitCode); Assert.Equal(output, cleaned); } [Fact] - public void TryExtractCompletionMarker_RemovesUnmarkedShellPrompts() + public void TrimCompletedOutput_PreservesOutputWithoutControlSequence() { - var output = "20/20 seconds\r\nFinished after 20.0 seconds.\r\n"; - var current = output + - "hmenn@aurora:/project$ \r\n" + - "\u001b[1A\r\u001b[2K" + ExecutionMarkerPrefix + "0\u0007" + - "hmenn@aurora:/project$ "; - - var found = TerminalManagerViewModel.TryExtractCompletionMarker( - current, ExecutionMarkerPrefix, out var exitCode, out var cleaned); + const string output = "Finished without framing"; - Assert.True(found); - Assert.Equal(0, exitCode); - Assert.Equal(output, cleaned); + Assert.Equal(output, TerminalManagerViewModel.TrimCompletedOutput(output)); } } From fed86c243381cbcbe64084834af16d7724b4b360 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 23:28:42 +0200 Subject: [PATCH 3/8] . --- src/VtNetCore.Avalonia | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VtNetCore.Avalonia b/src/VtNetCore.Avalonia index bddd1881c..82078aa6b 160000 --- a/src/VtNetCore.Avalonia +++ b/src/VtNetCore.Avalonia @@ -1 +1 @@ -Subproject commit bddd1881c8d7f959cfaa341dce7d22f7084f5e98 +Subproject commit 82078aa6b5082117970bf170a82fa14aad045825 From 3cc92b7f0a2e4d6daf3f87cdf2b4a4718140ce16 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 23 Jul 2026 23:45:14 +0200 Subject: [PATCH 4/8] Prevent terminal completion helper wrapping Initialize a shell completion function before showing Unix terminals and use short per-terminal sequence IDs so hidden completion calls stay on one terminal row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ViewModels/TerminalViewModel.cs | 30 +++++++++++++++---- .../ViewModels/TerminalManagerViewModel.cs | 2 +- .../PseudoTerminalConnectionTests.cs | 9 ++++-- .../TerminalManagerViewModelTests.cs | 13 ++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 8fa4cce54..58bdd4e7c 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -20,6 +20,7 @@ public class TerminalViewModel : ObservableObject : new UnixPseudoTerminalProvider(); private readonly Lock _createLock = new(); + private long _executionSequence; public TerminalViewModel(string workingDir, string? startArguments = null) { @@ -115,10 +116,19 @@ public void CreateConnection() _ = Dispatcher.UIThread.InvokeAsync(async () => { - TerminalVisible = true; Connection.Connect(); - await Task.Delay(500); + await Task.Delay(300); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var setupCommand = BuildUnixControlFunction(); + SuppressEcho(Encoding.UTF8.GetBytes(setupCommand)); + Send(setupCommand); + await Task.Delay(200); + } + + TerminalVisible = true; TerminalLoading = false; @@ -167,9 +177,12 @@ public string BuildCompletionCommand(string executionId) "[void]$global:__ow_ack.ReadLine()"; } - return - $"__ow_exit=$?; printf '\\033[1A\\r\\033[2K'; printf '{executionId}:%s\\n' \"$__ow_exit\" >&198; " + - "IFS= read -r __ow_ack <&199"; + return $"__owc {executionId}"; + } + + public string NextExecutionId() + { + return Interlocked.Increment(ref _executionSequence).ToString("x"); } public void CloseConnection() @@ -208,4 +221,11 @@ private static string BuildWindowsStartArguments(string workingDir) return $"powershell.exe -NoProfile -NoExit -Command \"{bootstrapCmd}\""; } + + private static string BuildUnixControlFunction() + { + return "__owc(){ __ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + + "printf '%s:%s\\n' \"$1\" \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199; }; " + + "printf '\\033[2J\\033[3J\\033[H'"; + } } diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 60d7bc8ba..2cb9ed153 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -145,7 +145,7 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var output = new StringBuilder(); var outputLock = new object(); var resultTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var executionId = Guid.NewGuid().ToString("N"); + var executionId = terminal.NextExecutionId(); var completionCommand = terminal.BuildCompletionCommand(executionId); var commandToSend = $"{command}\n{completionCommand}"; var chatOutputSuppressor = new OutputSequenceSuppressor(); diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index b698deb92..c71200a6c 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -91,9 +91,12 @@ public async Task ControlChannel_CompletesUnixCommandOutOfBand() connection.Connect(); const string executionId = "integration"; - var command = "printf 'control-channel-output\\n'\n" + - "__ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + - $"printf '{executionId}:%s\\n' \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199\r"; + var setup = "__owc(){ __ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + + "printf '%s:%s\\n' \"$1\" \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199; }\r"; + connection.SendData(Encoding.ASCII.GetBytes(setup)); + await Task.Delay(100); + + var command = $"printf 'control-channel-output\\n'\n__owc {executionId}\r"; connection.SendData(Encoding.ASCII.GetBytes(command)); var result = await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); diff --git a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs index 43005355a..dfe3159e6 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs @@ -1,4 +1,7 @@ +using System; +using System.IO; using OneWare.TerminalManager.ViewModels; +using OneWare.Terminal.ViewModels; using Xunit; namespace OneWare.Studio.Desktop.UnitTests; @@ -25,4 +28,14 @@ public void TrimCompletedOutput_PreservesOutputWithoutControlSequence() Assert.Equal(output, TerminalManagerViewModel.TrimCompletedOutput(output)); } + + [Fact] + public void BuildCompletionCommand_UsesShortUnixFunctionCall() + { + if (OperatingSystem.IsWindows()) return; + + var terminal = new TerminalViewModel(Path.GetTempPath()); + + Assert.Equal("__owc 1", terminal.BuildCompletionCommand(terminal.NextExecutionId())); + } } From a12f8fc1dea4824a53eec7c9333cd79c9dc12454 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 24 Jul 2026 00:58:21 +0200 Subject: [PATCH 5/8] progress --- .../Services/AiBuiltInFunctions.cs | 2 + .../Services/AiFunctionProvider.cs | 38 ++++++-- src/OneWare.Chat/ViewModels/ChatViewModel.cs | 97 ++++++++++++++++++- src/OneWare.Chat/Views/ChatView.axaml | 19 ++++ src/OneWare.Copilot/CopilotModule.cs | 14 +-- .../Services/CopilotChatService.cs | 15 +++ .../Services/IAiFunctionProvider.cs | 5 + .../Services/IChatService.cs | 11 +++ .../Provider/Win32/ConPtyNative.cs | 1 - .../Win32/Win32ConPtyPseudoTerminal.cs | 75 +++++++++++--- .../Win32ConPtyPseudoTerminalProvider.cs | 59 ++++------- .../ViewModels/TerminalViewModel.cs | 28 +++--- .../ViewModels/TerminalManagerViewModel.cs | 30 +++--- .../PseudoTerminalConnectionTests.cs | 48 +++++++++ 14 files changed, 343 insertions(+), 99 deletions(-) diff --git a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs index 382374376..f0faa5c51 100644 --- a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs +++ b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs @@ -279,6 +279,8 @@ private static async Task RunTerminalCommandAsync( progress, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var truncatedOutput = TruncateTerminalOutput(terminalResult.Output, out var outputTruncated); var result = outputTruncated ? terminalResult with { Output = truncatedOutput } diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index 80d3d3f2d..4d36832df 100644 --- a/src/OneWare.Chat/Services/AiFunctionProvider.cs +++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Avalonia.Threading; using Microsoft.Extensions.AI; using OneWare.Essentials.Models; @@ -16,6 +17,7 @@ public class AiFunctionProvider( private readonly Lock _registrationLock = new(); private readonly List _registeredFunctions = []; private readonly List _promptAdditions = []; + private readonly ConcurrentDictionary _activeFunctions = new(); private bool _builtInsRegistered; public event EventHandler? FunctionStarted; @@ -90,6 +92,21 @@ public ICollection GetTools() return tools; } + public void CancelActiveFunctions() + { + foreach (var cancellationSource in _activeFunctions.Values) + { + try + { + cancellationSource.Cancel(); + } + catch (ObjectDisposedException) + { + // The function completed while cancellation was being requested. + } + } + } + private void EnsureBuiltInsRegistered() { lock (_registrationLock) @@ -108,10 +125,8 @@ private void EnsureBuiltInsRegistered() aiFileEditService); } - private async Task NotifyFunctionStartedAsync(string functionName, string? detail = null) + private async Task NotifyFunctionStartedAsync(string id, string functionName, string? detail = null) { - var id = Guid.NewGuid().ToString(); - await Dispatcher.UIThread.InvokeAsync(() => FunctionStarted?.Invoke(this, new AiFunctionStartedEvent { @@ -119,8 +134,6 @@ await Dispatcher.UIThread.InvokeAsync(() => FunctionName = functionName, Detail = detail })); - - return id; } private async Task NotifyFunctionCompletedAsync(string id, Exception? exception = null) @@ -130,7 +143,7 @@ await Dispatcher.UIThread.InvokeAsync(() => { Id = id, Result = exception == null, - ToolOutput = exception?.ToString() + ToolOutput = exception is OperationCanceledException ? "Cancelled." : exception?.ToString() })); } @@ -157,19 +170,25 @@ private sealed class RegisteredOneWareAiFunction( : definition.FriendlyName; var detail = definition.DetailExtractor?.Invoke(arguments); - var id = await provider.NotifyFunctionStartedAsync(friendlyName!, detail); + var id = Guid.NewGuid().ToString(); + using var functionCancellationSource = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + provider._activeFunctions[id] = functionCancellationSource; + var context = new AiFunctionInvocationContext(id, output => provider.RaiseFunctionProgress(id, output)); Exception? exception = null; try { + await provider.NotifyFunctionStartedAsync(id, friendlyName!, detail); + if (definition.RunOnUiThread) { return await Dispatcher.UIThread.InvokeAsync(async () => - await InvokeDefinitionAsync(context, arguments, cancellationToken)); + await InvokeDefinitionAsync(context, arguments, functionCancellationSource.Token)); } - return await InvokeDefinitionAsync(context, arguments, cancellationToken); + return await InvokeDefinitionAsync(context, arguments, functionCancellationSource.Token); } catch (Exception ex) { @@ -178,6 +197,7 @@ private sealed class RegisteredOneWareAiFunction( } finally { + provider._activeFunctions.TryRemove(id, out _); await provider.NotifyFunctionCompletedAsync(id, exception); } } 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.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/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 06cc54d06..69d6798b8 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -998,10 +998,25 @@ public async Task SendAsync(string prompt, ChatSendMode mode) public async Task AbortAsync() { ReleasePendingInputRequests(); + toolProvider.CancelActiveFunctions(); if (_session == null) return; 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 be3164fab..772796708 100644 --- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs +++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs @@ -19,6 +19,11 @@ event EventHandler? FunctionProgress /// Returns available AI tools for this provider. ICollection GetTools(); + /// Cancels all AI functions that are currently running. + void CancelActiveFunctions() + { + } + /// 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/Win32/ConPtyNative.cs b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs index c3abdcebe..1487ad978 100644 --- a/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs +++ b/src/OneWare.Terminal/Provider/Win32/ConPtyNative.cs @@ -7,7 +7,6 @@ namespace OneWare.Terminal.Provider.Win32; internal static class ConPtyNative { public const int PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016; - public const int PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002; public const int EXTENDED_STARTUPINFO_PRESENT = 0x00080000; public const int CREATE_UNICODE_ENVIRONMENT = 0x00000400; diff --git a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs index 9efae0fbc..bba5d7ec9 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.IO.Pipes; using Microsoft.Win32.SafeHandles; namespace OneWare.Terminal.Provider.Win32; @@ -8,19 +9,20 @@ public class Win32ConPtyPseudoTerminal : IPseudoTerminal private readonly IntPtr _pseudoConsole; private readonly FileStream _stdin; private readonly FileStream _stdout; - private readonly FileStream _controlInput; - private readonly FileStream _controlOutput; + private readonly NamedPipeServerStream _controlPipe; + private readonly object _controlPipeLock = new(); + private Task _controlConnected; private bool _isDisposed; public Win32ConPtyPseudoTerminal(Process process, IntPtr pseudoConsole, SafeFileHandle inputWrite, - SafeFileHandle outputRead, SafeFileHandle controlRead, SafeFileHandle acknowledgementWrite) + SafeFileHandle outputRead, NamedPipeServerStream controlPipe, Task controlConnected) { Process = process; _pseudoConsole = pseudoConsole; _stdin = new FileStream(inputWrite, FileAccess.Write, 4096, false); _stdout = new FileStream(outputRead, FileAccess.Read, 4096, false); - _controlInput = new FileStream(controlRead, FileAccess.Read, 4096, true); - _controlOutput = new FileStream(acknowledgementWrite, FileAccess.Write, 4096, true); + _controlPipe = controlPipe; + _controlConnected = controlConnected; } public Process Process { get; } @@ -45,16 +47,43 @@ public async Task ReadAsync(byte[] buffer, int offset, int count) return await _stdout.ReadAsync(buffer, offset, count).ConfigureAwait(false); } - public Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + public async Task ReadControlAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) { - return _controlInput.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + while (true) + { + await _controlConnected.WaitAsync(cancellationToken); + try + { + var bytesRead = await _controlPipe.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + if (bytesRead > 0) return bytesRead; + } + catch (IOException) when (!_controlPipe.IsConnected) + { + // The helper disconnected before sending a complete frame. + } + + PrepareNextControlConnection(); + } } public async Task WriteControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - await _controlOutput.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - await _controlOutput.FlushAsync(cancellationToken); + await _controlConnected.WaitAsync(cancellationToken); + try + { + await _controlPipe.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + await _controlPipe.FlushAsync(cancellationToken); + } + catch (IOException) when (!_controlPipe.IsConnected) + { + // The command stopped waiting before its acknowledgement arrived. + } + finally + { + PrepareNextControlConnection(); + } } public void Dispose() @@ -65,10 +94,34 @@ public void Dispose() _isDisposed = true; _stdin.Dispose(); _stdout.Dispose(); - _controlInput.Dispose(); - _controlOutput.Dispose(); + lock (_controlPipeLock) + { + _controlPipe.Dispose(); + } if (_pseudoConsole != IntPtr.Zero) ConPtyNative.ClosePseudoConsole(_pseudoConsole); } + + private void PrepareNextControlConnection() + { + lock (_controlPipeLock) + { + if (_isDisposed) return; + + if (_controlPipe.IsConnected) + { + try + { + _controlPipe.Disconnect(); + } + catch (IOException) + { + // The client disconnected between the state check and reset. + } + } + + _controlConnected = _controlPipe.WaitForConnectionAsync(); + } + } } \ 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 7d0ecd2e3..c7287d444 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs @@ -1,7 +1,7 @@ using System.Collections; using System.ComponentModel; using System.Diagnostics; -using System.Globalization; +using System.IO.Pipes; using System.Runtime.InteropServices; using System.Text; using Microsoft.Win32.SafeHandles; @@ -24,13 +24,9 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider SafeFileHandle? inputWrite = null; SafeFileHandle? outputRead = null; SafeFileHandle? outputWrite = null; - SafeFileHandle? completionRead = null; - SafeFileHandle? completionWrite = null; - SafeFileHandle? acknowledgementRead = null; - SafeFileHandle? acknowledgementWrite = null; + NamedPipeServerStream? controlPipe = null; var pseudoConsole = IntPtr.Zero; var attributeList = IntPtr.Zero; - var inheritedHandleList = IntPtr.Zero; var environmentBlock = IntPtr.Zero; var terminalCreated = false; @@ -38,8 +34,11 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider { CreatePipePair(out inputRead, out inputWrite); CreatePipePair(out outputRead, out outputWrite); - CreatePipePair(out completionRead, out completionWrite, inheritWrite: true); - CreatePipePair(out acknowledgementRead, out acknowledgementWrite, inheritRead: true); + + var controlPipeName = $"OneWare-{Environment.ProcessId}-{Guid.NewGuid():N}-control"; + controlPipe = new NamedPipeServerStream(controlPipeName, PipeDirection.InOut, 1, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + var controlConnected = controlPipe.WaitForConnectionAsync(); var result = ConPtyNative.CreatePseudoConsole( new ConPtyNative.Coord((short)columns, (short)rows), @@ -54,8 +53,7 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider inputRead.Dispose(); outputWrite.Dispose(); - InitializeAttributeList(pseudoConsole, completionWrite, acknowledgementRead, out attributeList, - out inheritedHandleList); + InitializeAttributeList(pseudoConsole, out attributeList); var startupInfo = new ConPtyNative.StartupInfoEx { @@ -66,8 +64,7 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider lpAttributeList = attributeList }; - environment = AddControlEnvironment(environment, completionWrite.DangerousGetHandle(), - acknowledgementRead.DangerousGetHandle()); + environment = AddControlEnvironment(environment, controlPipeName); environmentBlock = Marshal.StringToHGlobalUni(environment); var creationFlags = ConPtyNative.EXTENDED_STARTUPINFO_PRESENT | @@ -75,10 +72,9 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider var commandLineBuilder = new StringBuilder(commandLine); - if (!ConPtyNative.CreateProcessW(null, commandLineBuilder, IntPtr.Zero, IntPtr.Zero, true, creationFlags, + if (!ConPtyNative.CreateProcessW(null, commandLineBuilder, IntPtr.Zero, IntPtr.Zero, false, creationFlags, environmentBlock, initialDirectory, ref startupInfo, out var processInformation)) { - ConPtyNative.ClosePseudoConsole(pseudoConsole); return null; } @@ -87,11 +83,8 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider var process = Process.GetProcessById(processInformation.dwProcessId); - completionWrite.Dispose(); - acknowledgementRead.Dispose(); - var terminal = new Win32ConPtyPseudoTerminal(process, pseudoConsole, inputWrite, outputRead, - completionRead, acknowledgementWrite); + controlPipe, controlConnected); terminalCreated = true; return terminal; } @@ -107,9 +100,6 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider Marshal.FreeHGlobal(attributeList); } - if (inheritedHandleList != IntPtr.Zero) - Marshal.FreeHGlobal(inheritedHandleList); - if (environmentBlock != IntPtr.Zero) Marshal.FreeHGlobal(environmentBlock); @@ -118,14 +108,11 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider if (inputRead is { IsInvalid: false }) inputRead.Dispose(); if (outputWrite is { IsInvalid: false }) outputWrite.Dispose(); - if (completionWrite is { IsInvalid: false }) completionWrite.Dispose(); - if (acknowledgementRead is { IsInvalid: false }) acknowledgementRead.Dispose(); if (!terminalCreated) { if (inputWrite is { IsInvalid: false }) inputWrite.Dispose(); if (outputRead is { IsInvalid: false }) outputRead.Dispose(); - if (completionRead is { IsInvalid: false }) completionRead.Dispose(); - if (acknowledgementWrite is { IsInvalid: false }) acknowledgementWrite.Dispose(); + controlPipe?.Dispose(); } } } @@ -167,8 +154,7 @@ private static void CreatePipePair(out SafeFileHandle readPipe, out SafeFileHand throw new Win32Exception(Marshal.GetLastWin32Error()); } - private static string AddControlEnvironment(string? environment, IntPtr completionHandle, - IntPtr acknowledgementHandle) + private static string AddControlEnvironment(string? environment, string controlPipeName) { var values = new SortedDictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(environment)) @@ -190,8 +176,7 @@ private static string AddControlEnvironment(string? environment, IntPtr completi } } - values["OW_COMPLETION_HANDLE"] = completionHandle.ToInt64().ToString(CultureInfo.InvariantCulture); - values["OW_ACK_HANDLE"] = acknowledgementHandle.ToInt64().ToString(CultureInfo.InvariantCulture); + values["OW_CONTROL_PIPE"] = controlPipeName; var builder = new StringBuilder(); foreach (var pair in values) @@ -200,14 +185,12 @@ private static string AddControlEnvironment(string? environment, IntPtr completi return builder.ToString(); } - private static void InitializeAttributeList(IntPtr pseudoConsole, SafeFileHandle completionWrite, - SafeFileHandle acknowledgementRead, out IntPtr attributeList, out IntPtr inheritedHandleList) + private static void InitializeAttributeList(IntPtr pseudoConsole, out IntPtr attributeList) { attributeList = IntPtr.Zero; - inheritedHandleList = IntPtr.Zero; var size = IntPtr.Zero; - if (!ConPtyNative.InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref size)) + if (!ConPtyNative.InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size)) { var error = Marshal.GetLastWin32Error(); if (error != 122) @@ -216,7 +199,7 @@ private static void InitializeAttributeList(IntPtr pseudoConsole, SafeFileHandle attributeList = Marshal.AllocHGlobal(size); - if (!ConPtyNative.InitializeProcThreadAttributeList(attributeList, 2, 0, ref size)) + if (!ConPtyNative.InitializeProcThreadAttributeList(attributeList, 1, 0, ref size)) throw new Win32Exception(Marshal.GetLastWin32Error()); if (!ConPtyNative.UpdateProcThreadAttribute(attributeList, 0, @@ -224,13 +207,5 @@ private static void InitializeAttributeList(IntPtr pseudoConsole, SafeFileHandle IntPtr.Zero, IntPtr.Zero)) throw new Win32Exception(Marshal.GetLastWin32Error()); - inheritedHandleList = Marshal.AllocHGlobal(IntPtr.Size * 2); - Marshal.WriteIntPtr(inheritedHandleList, 0, completionWrite.DangerousGetHandle()); - Marshal.WriteIntPtr(inheritedHandleList, IntPtr.Size, acknowledgementRead.DangerousGetHandle()); - - if (!ConPtyNative.UpdateProcThreadAttribute(attributeList, 0, - ConPtyNative.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inheritedHandleList, IntPtr.Size * 2, - IntPtr.Zero, IntPtr.Zero)) - throw new Win32Exception(Marshal.GetLastWin32Error()); } } \ No newline at end of file diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 58bdd4e7c..4a70fe22b 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -169,12 +169,7 @@ public string BuildCompletionCommand(string executionId) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return "$__ow_success=$?; " + - "$__ow_exit=if ($__ow_success) { 0 } elseif ($global:LASTEXITCODE -ne 0) " + - "{ [int]$global:LASTEXITCODE } else { 1 }; " + - "$__ow_esc=[char]27; Write-Host ($__ow_esc + '[1A' + \"`r\" + $__ow_esc + '[2K') -NoNewline; " + - $"$global:__ow_completion.WriteLine('{executionId}:' + $__ow_exit); " + - "[void]$global:__ow_ack.ReadLine()"; + return $"$__ow_success=$?; __owc '{executionId}' $__ow_success $global:LASTEXITCODE"; } return $"__owc {executionId}"; @@ -206,17 +201,16 @@ private static string BuildWindowsStartArguments(string workingDir) var escapedDir = workingDir.Replace("'", "''"); var bootstrapCmd = - "$__ow_completion_handle=[Microsoft.Win32.SafeHandles.SafeFileHandle]::new(" + - "[IntPtr][long]$env:OW_COMPLETION_HANDLE,$false); " + - "$__ow_completion_stream=[System.IO.FileStream]::new(" + - "$__ow_completion_handle,[System.IO.FileAccess]::Write,4096,$false); " + - "$global:__ow_completion=[System.IO.StreamWriter]::new($__ow_completion_stream); " + - "$global:__ow_completion.AutoFlush=$true; " + - "$__ow_ack_handle=[Microsoft.Win32.SafeHandles.SafeFileHandle]::new(" + - "[IntPtr][long]$env:OW_ACK_HANDLE,$false); " + - "$__ow_ack_stream=[System.IO.FileStream]::new(" + - "$__ow_ack_handle,[System.IO.FileAccess]::Read,4096,$false); " + - "$global:__ow_ack=[System.IO.StreamReader]::new($__ow_ack_stream); " + + "function global:__owc { param([string]$id,[bool]$success,$lastExitCode); " + + "$exitCode=if ($success) { 0 } elseif ($lastExitCode -ne 0) { [int]$lastExitCode } else { 1 }; " + + "$esc=[char]27; Write-Host ($esc + '[1A' + [char]13 + $esc + '[2K') -NoNewline; " + + "$pipe=[System.IO.Pipes.NamedPipeClientStream]::new(" + + "'.',$env:OW_CONTROL_PIPE,[System.IO.Pipes.PipeDirection]::InOut); " + + "try { $pipe.Connect(); $encoding=[System.Text.UTF8Encoding]::new($false); " + + "$writer=[System.IO.StreamWriter]::new($pipe,$encoding,1024,$true); " + + "$reader=[System.IO.StreamReader]::new($pipe,$encoding,$false,1024,$true); " + + "$writer.AutoFlush=$true; $writer.WriteLine($id + ':' + $exitCode); " + + "[void]$reader.ReadLine(); $writer.Dispose(); $reader.Dispose() } finally { $pipe.Dispose() } }; " + $"Set-Location '{escapedDir}'"; return $"powershell.exe -NoProfile -NoExit -Command \"{bootstrapCmd}\""; diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index 2cb9ed153..f2fef3000 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -148,6 +148,7 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo var executionId = terminal.NextExecutionId(); var completionCommand = terminal.BuildCompletionCommand(executionId); var commandToSend = $"{command}\n{completionCommand}"; + var commandSent = false; var chatOutputSuppressor = new OutputSequenceSuppressor(); chatOutputSuppressor.SuppressOutput(Encoding.UTF8.GetBytes(completionCommand)); var interruptRecoveryScheduled = 0; @@ -220,7 +221,11 @@ async Task RecoverCompletionAfterUserInterruptAsync() connection.CommandCompleted += OnCommandCompleted; connection.UserInterrupted += OnUserInterrupted; terminal.SuppressEcho(Encoding.UTF8.GetBytes(completionCommand)); - terminal.Send(commandToSend); + if (!resultTcs.Task.IsCompleted && !cancellationToken.IsCancellationRequested) + { + commandSent = true; + terminal.Send(commandToSend); + } TerminalExecutionResult result; @@ -234,17 +239,20 @@ async Task RecoverCompletionAfterUserInterruptAsync() 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); diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index c71200a6c..7c8422703 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using OneWare.Terminal.Provider; using OneWare.Terminal.Provider.Unix; +using OneWare.Terminal.Provider.Win32; +using OneWare.Terminal.ViewModels; using Xunit; namespace OneWare.Studio.Desktop.UnitTests; @@ -107,6 +109,52 @@ public async Task ControlChannel_CompletesUnixCommandOutOfBand() Assert.DoesNotContain("OW_DONE", output.ToString()); } + [Fact] + public async Task ControlChannel_CompletesWindowsCommandOutOfBand() + { + if (!OperatingSystem.IsWindows()) return; + + var terminalViewModel = new TerminalViewModel(Path.GetTempPath()); + var startArguments = terminalViewModel.StartArguments!; + startArguments = startArguments.Insert(startArguments.Length - 1, + "; $abandoned=[System.IO.Pipes.NamedPipeClientStream]::new(" + + "'.',$env:OW_CONTROL_PIPE,[System.IO.Pipes.PipeDirection]::InOut); " + + "$abandoned.Connect(); $abandoned.Dispose(); " + + "__owc 'integration-1' $true 0; __owc 'integration-2' $false 7"); + var provider = new Win32ConPtyPseudoTerminalProvider(); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), "powershell.exe", null, + startArguments); + Assert.NotNull(terminal); + + try + { + using var connection = new PseudoTerminalConnection(terminal); + var firstCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var secondCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + connection.CommandCompleted += (_, args) => + { + if (args.ExecutionId == "integration-1") + firstCompletion.TrySetResult(args); + else if (args.ExecutionId == "integration-2") + secondCompletion.TrySetResult(args); + }; + connection.Connect(); + + var firstResult = await firstCompletion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var secondResult = await secondCompletion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, firstResult.ExitCode); + Assert.Equal(7, secondResult.ExitCode); + } + finally + { + if (!terminal.Process.HasExited) + terminal.Process.Kill(true); + } + } + [Fact] public async Task SendData_ReportsUserInterrupt() { From 08946c2d7c3752fef8c2fca24d6bafdcf368f220 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 24 Jul 2026 10:09:43 +0200 Subject: [PATCH 6/8] progress --- .../Services/AiFunctionProvider.cs | 30 ++- .../ChatMessages/ChatMessageToolViewModel.cs | 8 + src/OneWare.Chat/ViewModels/ChatViewModel.cs | 6 + .../ChatMessages/ChatMessageToolView.axaml | 9 +- .../Services/IAiFunctionProvider.cs | 5 + .../Provider/IPseudoTerminal.cs | 4 - .../Provider/OutputSequenceSuppressor.cs | 115 --------- .../Provider/PseudoTerminalConnection.cs | 130 ++-------- .../Provider/ShellIntegrationParser.cs | 184 ++++++++++++++ .../Provider/Unix/UnixPseudoTerminal.cs | 21 +- .../Unix/UnixPseudoTerminalProvider.cs | 41 +-- .../Provider/UserInputEchoFilter.cs | 76 ++++++ .../Win32/Win32ConPtyPseudoTerminal.cs | 75 +----- .../Win32ConPtyPseudoTerminalProvider.cs | 29 +-- src/OneWare.Terminal/ShellIntegration.cs | 191 ++++++++++++++ .../ViewModels/TerminalViewModel.cs | 88 +------ .../ViewModels/TerminalManagerViewModel.cs | 168 +++++++------ .../PseudoTerminalConnectionTests.cs | 237 +++++++++--------- .../ShellIntegrationTests.cs | 61 +++++ .../TerminalManagerViewModelTests.cs | 41 --- .../UserInputEchoFilterTests.cs | 64 +++++ 21 files changed, 897 insertions(+), 686 deletions(-) delete mode 100644 src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs create mode 100644 src/OneWare.Terminal/Provider/ShellIntegrationParser.cs create mode 100644 src/OneWare.Terminal/Provider/UserInputEchoFilter.cs create mode 100644 src/OneWare.Terminal/ShellIntegration.cs create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/ShellIntegrationTests.cs delete mode 100644 tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/UserInputEchoFilterTests.cs diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs index 4d36832df..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. } } @@ -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 88aae83ad..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; @@ -63,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"); @@ -740,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); } @@ -750,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 @@ + + 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.Terminal/Provider/IPseudoTerminal.cs b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs index 8aaf8d54d..c6474ed2c 100644 --- a/src/OneWare.Terminal/Provider/IPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/IPseudoTerminal.cs @@ -10,8 +10,4 @@ public interface IPseudoTerminal : IDisposable Task WriteAsync(byte[] buffer, int offset, int count); Task ReadAsync(byte[] buffer, int offset, int count); - - Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); - - Task WriteControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs b/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs deleted file mode 100644 index b26b475d0..000000000 --- a/src/OneWare.Terminal/Provider/OutputSequenceSuppressor.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Generic; -using VtNetCore.Avalonia; - -namespace OneWare.Terminal.Provider; - -public sealed class OutputSequenceSuppressor : IOutputFilter, IOutputSuppressor -{ - private const int TolerantSuppressionPrefixLength = 4; - private readonly object _lock = new(); - private readonly Queue _queue = new(); - private readonly List _pending = new(); - private byte[]? _active; - private int _matchedLength; - - public void SuppressOutput(byte[] sequence) - { - if (sequence.Length == 0) return; - - lock (_lock) - { - _queue.Enqueue(sequence); - } - } - - public void Reset() - { - lock (_lock) - { - _queue.Clear(); - ResetActiveSuppression(); - } - } - - public byte[] FilterOutput(byte[] data) - { - if (data.Length == 0) return data; - - lock (_lock) - { - if (_active == null && _queue.Count == 0) return data; - - var output = new List(data.Length + 8); - var index = 0; - - while (index < data.Length) - { - if (_active == null && _queue.Count > 0) - { - _active = _queue.Dequeue(); - _pending.Clear(); - _matchedLength = 0; - } - - if (_active == null) - { - output.Add(data[index++]); - continue; - } - - var b = data[index++]; - if (b == _active[_matchedLength]) - { - _pending.Add(b); - _matchedLength++; - if (_matchedLength == _active.Length) - ResetActiveSuppression(); - - continue; - } - - if (_matchedLength >= Math.Min(TolerantSuppressionPrefixLength, _active.Length)) - { - // Interactive shells may insert cursor movement and redraw bytes while - // echoing a command that wraps. Match the command as a subsequence. - _pending.Add(b); - if (b == (byte)'\n') - { - output.AddRange(_pending); - ResetActiveSuppression(); - } - - continue; - } - - if (_pending.Count > 0) - { - output.AddRange(_pending); - _pending.Clear(); - _matchedLength = 0; - } - - if (b == _active[0]) - { - _pending.Add(b); - _matchedLength = 1; - if (_active.Length == 1) - ResetActiveSuppression(); - } - else - { - output.Add(b); - } - } - - return output.Count == data.Length ? data : output.ToArray(); - } - } - - private void ResetActiveSuppression() - { - _active = null; - _pending.Clear(); - _matchedLength = 0; - } -} diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index 3953e23f9..dd887003e 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -1,13 +1,11 @@ -using System.Text; using VtNetCore.Avalonia; namespace OneWare.Terminal.Provider; -public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, IOutputFilter, IOutputSuppressor, IDisposable +public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, IDisposable { + private readonly ShellIntegrationParser _parser = new(); private CancellationTokenSource? _cancellationSource; - private readonly OutputSequenceSuppressor _outputSuppressor = new(); - private long _outputVersion; public bool IsConnected { get; private set; } @@ -15,16 +13,24 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I public event EventHandler? Closed; - public event EventHandler? CommandCompleted; + /// + /// 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; - public event EventHandler? UserInterrupted; + /// + /// 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(); _ = ReadOutputAsync(_cancellationSource.Token); - _ = ReadControlAsync(_cancellationSource.Token); IsConnected = true; @@ -42,9 +48,8 @@ public void Disconnect() public void SendData(byte[] data) { + DataSent?.Invoke(this, new DataReceivedEventArgs { Data = data }); _ = terminal.WriteAsync(data, 0, data.Length); - if (data.Contains((byte)0x03) && _cancellationSource is { } cancellationSource) - _ = NotifyUserInterruptedAsync(cancellationSource.Token); } public void KillProcess() @@ -60,21 +65,6 @@ public void KillProcess() } } - public void SuppressOutput(byte[] sequence) - { - _outputSuppressor.SuppressOutput(sequence); - } - - public byte[] FilterOutput(byte[] data) - { - return _outputSuppressor.FilterOutput(data); - } - - public void ResetOutputSuppression() - { - _outputSuppressor.Reset(); - } - public void SetTerminalWindowSize(int columns, int rows) { terminal.SetSize(columns, rows); @@ -107,34 +97,13 @@ private async Task ReadOutputAsync(CancellationToken cancellationToken) var receivedData = new byte[bytesReceived]; Buffer.BlockCopy(data, 0, receivedData, 0, bytesReceived); - DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = receivedData }); - Interlocked.Increment(ref _outputVersion); - } - } - catch (Exception) when (cancellationToken.IsCancellationRequested) - { - } - } - - private async Task ReadControlAsync(CancellationToken cancellationToken) - { - var data = new byte[256]; - var pending = new StringBuilder(); - try - { - while (!cancellationToken.IsCancellationRequested) - { - var bytesReceived = await terminal.ReadControlAsync(data, 0, data.Length, cancellationToken); - if (bytesReceived <= 0) break; - pending.Append(Encoding.ASCII.GetString(data, 0, bytesReceived)); - while (TryReadControlFrame(pending, out var executionId, out var exitCode)) + foreach (var segment in _parser.Feed(receivedData)) { - await WaitForOutputDrainAsync(cancellationToken); - CommandCompleted?.Invoke(this, new TerminalCommandCompletedEventArgs(executionId, exitCode)); - - var acknowledgement = Encoding.ASCII.GetBytes($"{executionId}\n"); - await terminal.WriteControlAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); + if (segment.Data != null) + DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = segment.Data }); + else if (segment.Event is { } integrationEvent) + IntegrationEvent?.Invoke(this, new ShellIntegrationEventArgs(integrationEvent)); } } } @@ -142,62 +111,15 @@ private async Task ReadControlAsync(CancellationToken cancellationToken) { } } +} - private async Task WaitForOutputDrainAsync(CancellationToken cancellationToken) - { - var previousVersion = Volatile.Read(ref _outputVersion); - var stableSamples = 0; - - while (stableSamples < 3) - { - await Task.Delay(20, cancellationToken); - var currentVersion = Volatile.Read(ref _outputVersion); - if (currentVersion == previousVersion) - { - stableSamples++; - } - else - { - previousVersion = currentVersion; - stableSamples = 0; - } - } - } - - private async Task NotifyUserInterruptedAsync(CancellationToken cancellationToken) - { - try - { - await WaitForOutputDrainAsync(cancellationToken); - UserInterrupted?.Invoke(this, EventArgs.Empty); - } - catch (OperationCanceledException) - { - } - } - - internal static bool TryReadControlFrame(StringBuilder pending, out string executionId, out int exitCode) - { - executionId = string.Empty; - exitCode = 0; - - var newlineIndex = pending.ToString().IndexOf('\n'); - if (newlineIndex < 0) return false; - - var line = pending.ToString(0, newlineIndex).TrimEnd('\r'); - pending.Remove(0, newlineIndex + 1); +public sealed class ShellIntegrationEventArgs(ShellIntegrationEvent integrationEvent) : EventArgs +{ + public ShellIntegrationEvent Event { get; } = integrationEvent; - var separatorIndex = line.LastIndexOf(':'); - if (separatorIndex <= 0 || !int.TryParse(line[(separatorIndex + 1)..], out exitCode)) - return false; + public bool IsCommandStarted => Event.Command == 'C'; - executionId = line[..separatorIndex]; - return true; - } -} + public bool IsCommandCompleted => Event.Command == 'D'; -public sealed class TerminalCommandCompletedEventArgs(string executionId, int exitCode) : EventArgs -{ - public string ExecutionId { get; } = executionId; - public int ExitCode { get; } = exitCode; + 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/UnixPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs index ad5d92299..2ddefc43b 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminal.cs @@ -9,18 +9,13 @@ public class UnixPseudoTerminal : IPseudoTerminal private readonly int _cfg; private readonly Stream _stdin; private readonly Stream _stdout; - private readonly Stream _controlInput; - private readonly Stream _controlOutput; private bool _isDisposed; - public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout, Stream controlInput, - Stream controlOutput) + public UnixPseudoTerminal(Process process, int cfg, Stream stdin, Stream stdout) { Process = process; _stdin = stdin; _stdout = stdout; - _controlInput = controlInput; - _controlOutput = controlOutput; _cfg = cfg; } @@ -32,8 +27,6 @@ public void Dispose() _isDisposed = true; _stdin.Dispose(); _stdout.Dispose(); - _controlInput.Dispose(); - _controlOutput.Dispose(); } public async Task ReadAsync(byte[] buffer, int offset, int count) @@ -81,18 +74,6 @@ await Task.Run(() => }); } - public Task ReadControlAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return _controlInput.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public async Task WriteControlAsync(byte[] buffer, int offset, int count, - CancellationToken cancellationToken) - { - await _controlOutput.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - await _controlOutput.FlushAsync(cancellationToken); - } - public void SetSize(int columns, int rows) { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) diff --git a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs index ca234f14e..5c0c6c89e 100644 --- a/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Unix/UnixPseudoTerminalProvider.cs @@ -6,9 +6,6 @@ namespace OneWare.Terminal.Provider.Unix; public class UnixPseudoTerminalProvider : IPseudoTerminalProvider { - private const int CompletionFileDescriptor = 198; - private const int AcknowledgementFileDescriptor = 199; - public IPseudoTerminal? Create(int columns, int rows, string initialDirectory, string command, string? environment, string? arguments) { @@ -20,17 +17,6 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider ws_ypixel = 0 }; - var completionPipe = new int[2]; - var acknowledgementPipe = new int[2]; - if (Native.pipe(completionPipe) != 0) - return null; - if (Native.pipe(acknowledgementPipe) != 0) - { - Native.close(completionPipe[0]); - Native.close(completionPipe[1]); - return null; - } - //Collect ENV Vars before fork to avoid EntryPointNotFoundException var envMap = new Dictionary(StringComparer.Ordinal); var env = Environment.GetEnvironmentVariables(); @@ -55,9 +41,6 @@ public class UnixPseudoTerminalProvider : IPseudoTerminalProvider } - envMap["OW_COMPLETION_FD"] = CompletionFileDescriptor.ToString(); - envMap["OW_ACK_FD"] = AcknowledgementFileDescriptor.ToString(); - var envVars = new List(envMap.Count + 2); foreach (var pair in envMap) envVars.Add($"{pair.Key}={pair.Value}"); @@ -77,38 +60,30 @@ 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 if (pid == 0) { - Native.close(completionPipe[0]); - Native.close(acknowledgementPipe[1]); - Native.dup2(completionPipe[1], CompletionFileDescriptor); - Native.dup2(acknowledgementPipe[0], AcknowledgementFileDescriptor); - Native.close(completionPipe[1]); - Native.close(acknowledgementPipe[0]); Native.chdir(initialDirectory); Native.execve(argvArray[0], argvArray, envArray); Native._exit(1); } - Native.close(completionPipe[1]); - Native.close(acknowledgementPipe[0]); - if (pid < 0) - { - Native.close(completionPipe[0]); - Native.close(acknowledgementPipe[1]); return null; - } var stdin = Native.dup(masterFd); var process = Process.GetProcessById(pid); return new UnixPseudoTerminal(process, masterFd, new FileStream(new SafeFileHandle(new IntPtr(stdin), true), - FileAccess.Write), new FileStream(new SafeFileHandle(new IntPtr(masterFd), true), FileAccess.Read), - new FileStream(new SafeFileHandle(new IntPtr(completionPipe[0]), true), FileAccess.Read), - new FileStream(new SafeFileHandle(new IntPtr(acknowledgementPipe[1]), true), FileAccess.Write)); + FileAccess.Write), new FileStream(new SafeFileHandle(new IntPtr(masterFd), true), FileAccess.Read)); } } 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/Win32ConPtyPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs index bba5d7ec9..28c6de3cf 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.IO.Pipes; using Microsoft.Win32.SafeHandles; namespace OneWare.Terminal.Provider.Win32; @@ -9,20 +8,15 @@ public class Win32ConPtyPseudoTerminal : IPseudoTerminal private readonly IntPtr _pseudoConsole; private readonly FileStream _stdin; private readonly FileStream _stdout; - private readonly NamedPipeServerStream _controlPipe; - private readonly object _controlPipeLock = new(); - private Task _controlConnected; private bool _isDisposed; public Win32ConPtyPseudoTerminal(Process process, IntPtr pseudoConsole, SafeFileHandle inputWrite, - SafeFileHandle outputRead, NamedPipeServerStream controlPipe, Task controlConnected) + SafeFileHandle outputRead) { Process = process; _pseudoConsole = pseudoConsole; _stdin = new FileStream(inputWrite, FileAccess.Write, 4096, false); _stdout = new FileStream(outputRead, FileAccess.Read, 4096, false); - _controlPipe = controlPipe; - _controlConnected = controlConnected; } public Process Process { get; } @@ -47,45 +41,6 @@ public async Task ReadAsync(byte[] buffer, int offset, int count) return await _stdout.ReadAsync(buffer, offset, count).ConfigureAwait(false); } - public async Task ReadControlAsync(byte[] buffer, int offset, int count, - CancellationToken cancellationToken) - { - while (true) - { - await _controlConnected.WaitAsync(cancellationToken); - try - { - var bytesRead = await _controlPipe.ReadAsync(buffer.AsMemory(offset, count), cancellationToken); - if (bytesRead > 0) return bytesRead; - } - catch (IOException) when (!_controlPipe.IsConnected) - { - // The helper disconnected before sending a complete frame. - } - - PrepareNextControlConnection(); - } - } - - public async Task WriteControlAsync(byte[] buffer, int offset, int count, - CancellationToken cancellationToken) - { - await _controlConnected.WaitAsync(cancellationToken); - try - { - await _controlPipe.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - await _controlPipe.FlushAsync(cancellationToken); - } - catch (IOException) when (!_controlPipe.IsConnected) - { - // The command stopped waiting before its acknowledgement arrived. - } - finally - { - PrepareNextControlConnection(); - } - } - public void Dispose() { if (_isDisposed) @@ -94,34 +49,8 @@ public void Dispose() _isDisposed = true; _stdin.Dispose(); _stdout.Dispose(); - lock (_controlPipeLock) - { - _controlPipe.Dispose(); - } if (_pseudoConsole != IntPtr.Zero) ConPtyNative.ClosePseudoConsole(_pseudoConsole); } - - private void PrepareNextControlConnection() - { - lock (_controlPipeLock) - { - if (_isDisposed) return; - - if (_controlPipe.IsConnected) - { - try - { - _controlPipe.Disconnect(); - } - catch (IOException) - { - // The client disconnected between the state check and reset. - } - } - - _controlConnected = _controlPipe.WaitForConnectionAsync(); - } - } -} \ 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 c7287d444..d52ba10f1 100644 --- a/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs +++ b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminalProvider.cs @@ -24,7 +24,6 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider SafeFileHandle? inputWrite = null; SafeFileHandle? outputRead = null; SafeFileHandle? outputWrite = null; - NamedPipeServerStream? controlPipe = null; var pseudoConsole = IntPtr.Zero; var attributeList = IntPtr.Zero; var environmentBlock = IntPtr.Zero; @@ -35,11 +34,6 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider CreatePipePair(out inputRead, out inputWrite); CreatePipePair(out outputRead, out outputWrite); - var controlPipeName = $"OneWare-{Environment.ProcessId}-{Guid.NewGuid():N}-control"; - controlPipe = new NamedPipeServerStream(controlPipeName, PipeDirection.InOut, 1, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - var controlConnected = controlPipe.WaitForConnectionAsync(); - var result = ConPtyNative.CreatePseudoConsole( new ConPtyNative.Coord((short)columns, (short)rows), inputRead, @@ -64,8 +58,7 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider lpAttributeList = attributeList }; - environment = AddControlEnvironment(environment, controlPipeName); - environmentBlock = Marshal.StringToHGlobalUni(environment); + environmentBlock = Marshal.StringToHGlobalUni(BuildEnvironmentBlock(environment)); var creationFlags = ConPtyNative.EXTENDED_STARTUPINFO_PRESENT | ConPtyNative.CREATE_UNICODE_ENVIRONMENT; @@ -83,8 +76,7 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider var process = Process.GetProcessById(processInformation.dwProcessId); - var terminal = new Win32ConPtyPseudoTerminal(process, pseudoConsole, inputWrite, outputRead, - controlPipe, controlConnected); + var terminal = new Win32ConPtyPseudoTerminal(process, pseudoConsole, inputWrite, outputRead); terminalCreated = true; return terminal; } @@ -112,7 +104,6 @@ public class Win32ConPtyPseudoTerminalProvider : IPseudoTerminalProvider { if (inputWrite is { IsInvalid: false }) inputWrite.Dispose(); if (outputRead is { IsInvalid: false }) outputRead.Dispose(); - controlPipe?.Dispose(); } } } @@ -154,18 +145,16 @@ private static void CreatePipePair(out SafeFileHandle readPipe, out SafeFileHand throw new Win32Exception(Marshal.GetLastWin32Error()); } - private static string AddControlEnvironment(string? environment, string controlPipeName) + private static string BuildEnvironmentBlock(string? environment) { var values = new SortedDictionary(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(environment)) + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) { - foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) - { - if (entry.Key is string key && entry.Value is string value) - values[key] = value; - } + if (entry.Key is string key && entry.Value is string value) + values[key] = value; } - else + + if (!string.IsNullOrWhiteSpace(environment)) { foreach (var entry in environment.Split('\0')) { @@ -176,8 +165,6 @@ private static string AddControlEnvironment(string? environment, string controlP } } - values["OW_CONTROL_PIPE"] = controlPipeName; - var builder = new StringBuilder(); foreach (var pair in values) builder.Append(pair.Key).Append('=').Append(pair.Value).Append('\0'); diff --git a/src/OneWare.Terminal/ShellIntegration.cs b/src/OneWare.Terminal/ShellIntegration.cs new file mode 100644 index 000000000..039f6b697 --- /dev/null +++ b/src/OneWare.Terminal/ShellIntegration.cs @@ -0,0 +1,191 @@ +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() + { + var dir = Path.Combine(Path.GetTempPath(), "oneware-shell-integration"); + Directory.CreateDirectory(dir); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + File.SetUnixFileMode(dir, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + 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 4a70fe22b..de2e4331c 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -20,14 +20,11 @@ public class TerminalViewModel : ObservableObject : new UnixPseudoTerminalProvider(); private readonly Lock _createLock = new(); - private long _executionSequence; public TerminalViewModel(string workingDir, string? startArguments = null) { WorkingDir = workingDir; - StartArguments = startArguments ?? (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? BuildWindowsStartArguments(WorkingDir) - : null); + StartArguments = startArguments; } public string? StartArguments { get; } @@ -82,7 +79,7 @@ public void CreateConnection() lock (_createLock) { CloseConnection(); - + var shellExecutable = PlatformHelper.Platform switch { PlatformId.WinX64 or PlatformId.WinArm64 => PlatformHelper.GetFullPath("powershell.exe"), @@ -94,15 +91,14 @@ public void CreateConnection() if (!string.IsNullOrEmpty(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, null, startArguments); + var terminal = SProvider.Create(80, 32, WorkingDir, shellExecutable, integration.Environment, + startArguments); if (terminal == null) { @@ -114,20 +110,10 @@ public void CreateConnection() Terminal = new VirtualTerminalController(); - _ = Dispatcher.UIThread.InvokeAsync(async () => + Dispatcher.UIThread.Post(() => { Connection.Connect(); - await Task.Delay(300); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var setupCommand = BuildUnixControlFunction(); - SuppressEcho(Encoding.UTF8.GetBytes(setupCommand)); - Send(setupCommand); - await Task.Delay(200); - } - TerminalVisible = true; TerminalLoading = false; @@ -140,7 +126,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() @@ -157,29 +143,6 @@ public void KillProcess() if (Connection is PseudoTerminalConnection ptc) ptc.KillProcess(); } - public void SuppressEcho(byte[] data) - { - if (Connection is IOutputSuppressor suppressor) - { - suppressor.SuppressOutput(data); - } - } - - public string BuildCompletionCommand(string executionId) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return $"$__ow_success=$?; __owc '{executionId}' $__ow_success $global:LASTEXITCODE"; - } - - return $"__owc {executionId}"; - } - - public string NextExecutionId() - { - return Interlocked.Increment(ref _executionSequence).ToString("x"); - } - public void CloseConnection() { if (Connection != null) @@ -193,33 +156,4 @@ public void Close() { CloseConnection(); } - - 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("'", "''"); - - var bootstrapCmd = - "function global:__owc { param([string]$id,[bool]$success,$lastExitCode); " + - "$exitCode=if ($success) { 0 } elseif ($lastExitCode -ne 0) { [int]$lastExitCode } else { 1 }; " + - "$esc=[char]27; Write-Host ($esc + '[1A' + [char]13 + $esc + '[2K') -NoNewline; " + - "$pipe=[System.IO.Pipes.NamedPipeClientStream]::new(" + - "'.',$env:OW_CONTROL_PIPE,[System.IO.Pipes.PipeDirection]::InOut); " + - "try { $pipe.Connect(); $encoding=[System.Text.UTF8Encoding]::new($false); " + - "$writer=[System.IO.StreamWriter]::new($pipe,$encoding,1024,$true); " + - "$reader=[System.IO.StreamReader]::new($pipe,$encoding,$false,1024,$true); " + - "$writer.AutoFlush=$true; $writer.WriteLine($id + ':' + $exitCode); " + - "[void]$reader.ReadLine(); $writer.Dispose(); $reader.Dispose() } finally { $pipe.Dispose() } }; " + - $"Set-Location '{escapedDir}'"; - - return $"powershell.exe -NoProfile -NoExit -Command \"{bootstrapCmd}\""; - } - - private static string BuildUnixControlFunction() - { - return "__owc(){ __ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + - "printf '%s:%s\\n' \"$1\" \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199; }; " + - "printf '\\033[2J\\033[3J\\033[H'"; - } } diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index f2fef3000..db7e25776 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -18,7 +18,12 @@ namespace OneWare.TerminalManager.ViewModels; public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService { public const string IconKey = "Material.Console"; - private const string CompletionMarkerControlPrefix = "\u001b[1A\r\u001b[2K"; + + // 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 @@ -143,36 +148,76 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo } var output = new StringBuilder(); - var outputLock = new object(); - var resultTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var executionId = terminal.NextExecutionId(); - var completionCommand = terminal.BuildCompletionCommand(executionId); - var commandToSend = $"{command}\n{completionCommand}"; + var stateLock = new object(); + var resultTcs = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var commandSent = false; - var chatOutputSuppressor = new OutputSequenceSuppressor(); - chatOutputSuppressor.SuppressOutput(Encoding.UTF8.GetBytes(completionCommand)); - var interruptRecoveryScheduled = 0; + // 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; + } + + resultTcs.TrySetResult(new TerminalExecutionResult(finalOutput, exitCode, false)); + } + + async Task CompleteAfterGraceAsync(CancellationToken graceToken) + { + 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) + lock (stateLock) partialOutput = output.ToString(); resultTcs.TrySetResult(new TerminalExecutionResult(partialOutput, -1, true)); } - void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) + void OnDataSent(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) { - var data = chatOutputSuppressor.FilterOutput(args.Data); - if (data.Length == 0) return; + lock (stateLock) + { + // 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); + } + } - var text = Encoding.UTF8.GetString(data); - string current; + void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) + { + string? current = null; - lock (outputLock) + lock (stateLock) { - output.Append(text); + if (!capturing) return; + var filtered = echoFilter.Filter(args.Data); + if (filtered.Length == 0) return; + output.Append(Encoding.UTF8.GetString(filtered)); current = output.ToString(); } @@ -180,51 +225,39 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg outputProgress?.Report(current); } - void OnCommandCompleted(object? sender, TerminalCommandCompletedEventArgs args) + void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args) { - if (!string.Equals(args.ExecutionId, executionId, StringComparison.Ordinal)) return; - - string cleaned; - lock (outputLock) + lock (stateLock) { - cleaned = TrimCompletedOutput(output.ToString()); - output.Clear(); - output.Append(cleaned); + 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); + } } - - outputProgress?.Report(cleaned); - resultTcs.TrySetResult(new TerminalExecutionResult(cleaned, args.ExitCode, false)); - } - - void OnUserInterrupted(object? sender, EventArgs args) - { - if (Interlocked.Exchange(ref interruptRecoveryScheduled, 1) != 0) return; - _ = RecoverCompletionAfterUserInterruptAsync(); - } - - async Task RecoverCompletionAfterUserInterruptAsync() - { - await Task.Yield(); - if (resultTcs.Task.IsCompleted) return; - - var completionBytes = Encoding.UTF8.GetBytes(completionCommand); - connection.ResetOutputSuppression(); - chatOutputSuppressor.Reset(); - connection.SuppressOutput(completionBytes); - chatOutputSuppressor.SuppressOutput(completionBytes); - terminal.Send(completionCommand); - Interlocked.Exchange(ref interruptRecoveryScheduled, 0); } connection.DataReceived += OnDataReceived; + connection.DataSent += OnDataSent; connection.Closed += OnConnectionClosed; - connection.CommandCompleted += OnCommandCompleted; - connection.UserInterrupted += OnUserInterrupted; - terminal.SuppressEcho(Encoding.UTF8.GetBytes(completionCommand)); + connection.IntegrationEvent += OnIntegrationEvent; + if (!resultTcs.Task.IsCompleted && !cancellationToken.IsCancellationRequested) { commandSent = true; - terminal.Send(commandToSend); + terminal.Send(command); } TerminalExecutionResult result; @@ -236,7 +269,7 @@ async Task RecoverCompletionAfterUserInterruptAsync() catch (OperationCanceledException) { string partialOutput; - lock (outputLock) + lock (stateLock) partialOutput = output.ToString(); if (commandSent) @@ -259,10 +292,16 @@ async Task RecoverCompletionAfterUserInterruptAsync() } finally { + lock (stateLock) + { + graceCts?.Cancel(); + graceCts = null; + } + connection.DataReceived -= OnDataReceived; + connection.DataSent -= OnDataSent; connection.Closed -= OnConnectionClosed; - connection.CommandCompleted -= OnCommandCompleted; - connection.UserInterrupted -= OnUserInterrupted; + connection.IntegrationEvent -= OnIntegrationEvent; if (closeWhenDone) terminal.Close(); } @@ -275,8 +314,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; } @@ -342,21 +381,6 @@ private static async Task WaitForResultAsync( return await resultTask.WaitAsync(linkedCts.Token); } - internal static string TrimCompletedOutput(string current) - { - var clearSequenceIndex = current.LastIndexOf(CompletionMarkerControlPrefix, StringComparison.Ordinal); - if (clearSequenceIndex < 0) return current; - - var promptLineEnd = clearSequenceIndex > 0 - ? current.LastIndexOf('\n', clearSequenceIndex - 1) - : -1; - var previousLineEnd = promptLineEnd > 0 - ? current.LastIndexOf('\n', promptLineEnd - 1) - : -1; - - return previousLineEnd >= 0 ? current[..(previousLineEnd + 1)] : string.Empty; - } - 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/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index 7c8422703..8580b7632 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -1,177 +1,182 @@ 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 OneWare.Terminal.Provider.Win32; -using OneWare.Terminal.ViewModels; 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'; printf '0123456789abcdef:%s\\n' \"$__ow_exit\" >&198; " + - "IFS= read -r __ow_ack <&199"; + 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 FilterOutput_SuppressesSequenceAcrossChunks() + public void Feed_StripsIntegrationSequencesAndRaisesEvents() { - var connection = new PseudoTerminalConnection(null!); - var marker = Encoding.ASCII.GetBytes(MarkerCommand); - connection.SuppressOutput(marker); + var parser = new ShellIntegrationParser(); + var events = new List(); - var first = connection.FilterOutput(Encoding.ASCII.GetBytes($"before{MarkerCommand[..30]}")); - var second = connection.FilterOutput(Encoding.ASCII.GetBytes($"{MarkerCommand[30..]}after")); + var text = FeedText(parser, "before\u001b]633;C\u0007output\u001b]633;D;3\u0007after", events); - Assert.Equal("before", Encoding.ASCII.GetString(first)); - Assert.Equal("after", Encoding.ASCII.GetString(second)); + 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 FilterOutput_SuppressesWrappedReadlineEcho() + public void Feed_HandlesSequencesSplitAcrossChunks() { - var connection = new PseudoTerminalConnection(null!); - connection.SuppressOutput(Encoding.ASCII.GetBytes(MarkerCommand)); + 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); + } - 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..])); + [Fact] + public void Feed_SupportsStringTerminator() + { + var parser = new ShellIntegrationParser(); + var events = new List(); - Assert.Equal("prompt", Encoding.ASCII.GetString(first)); - Assert.Empty(second); + 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 FilterOutput_ReleasesIncompleteSuppressionAtLineEnd() + public void Feed_PassesOtherOscSequencesThrough() { - var connection = new PseudoTerminalConnection(null!); - connection.SuppressOutput(Encoding.ASCII.GetBytes(MarkerCommand)); + var parser = new ShellIntegrationParser(); + var events = new List(); + const string title = "pre\u001b]0;window title\u0007post"; - var output = connection.FilterOutput(Encoding.ASCII.GetBytes("__ow_broken\r\nnext")); + var text = FeedText(parser, title, events); - Assert.Equal("__ow_broken\r\nnext", Encoding.ASCII.GetString(output)); + Assert.Equal(title, text); + Assert.Empty(events); } [Fact] - public void FilterOutput_UsesIndependentStateForSeparateConsumers() + public void Feed_PassesCsiAndPlainEscapesThrough() { - var marker = Encoding.ASCII.GetBytes(MarkerCommand); - var terminalFilter = new OutputSequenceSuppressor(); - var chatFilter = new OutputSequenceSuppressor(); - terminalFilter.SuppressOutput(marker); - chatFilter.SuppressOutput(marker); - var rawOutput = Encoding.ASCII.GetBytes($"before{MarkerCommand}after"); - - var terminalOutput = terminalFilter.FilterOutput(rawOutput); - var chatOutput = chatFilter.FilterOutput(rawOutput); - - Assert.Equal("beforeafter", Encoding.ASCII.GetString(terminalOutput)); - Assert.Equal("beforeafter", Encoding.ASCII.GetString(chatOutput)); + 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 async Task ControlChannel_CompletesUnixCommandOutOfBand() + public async Task ShellIntegration_ReportsCommandLifecycleOnUnix() { 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(), "/bin/bash", null, - "--noprofile --norc"); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), bash, config.Environment, + config.Arguments); Assert.NotNull(terminal); using var connection = new PseudoTerminalConnection(terminal); var output = new StringBuilder(); - var completion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - connection.DataReceived += (_, args) => output.Append(Encoding.UTF8.GetString(args.Data)); - connection.CommandCompleted += (_, args) => completion.TrySetResult(args); + 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(); - const string executionId = "integration"; - var setup = "__owc(){ __ow_exit=$?; printf '\\033[1A\\r\\033[2K'; " + - "printf '%s:%s\\n' \"$1\" \"$__ow_exit\" >&198; IFS= read -r __ow_ack <&199; }\r"; - connection.SendData(Encoding.ASCII.GetBytes(setup)); - await Task.Delay(100); - - var command = $"printf 'control-channel-output\\n'\n__owc {executionId}\r"; - connection.SendData(Encoding.ASCII.GetBytes(command)); - - var result = await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(executionId, result.ExecutionId); - Assert.Equal(0, result.ExitCode); - Assert.Contains("control-channel-output", output.ToString()); - Assert.DoesNotContain("OW_DONE", output.ToString()); - } + connection.SendData(Encoding.UTF8.GetBytes("printf 'integration-output\\n'; exit_test() { return 0; }\r")); - [Fact] - public async Task ControlChannel_CompletesWindowsCommandOutOfBand() - { - if (!OperatingSystem.IsWindows()) return; - - var terminalViewModel = new TerminalViewModel(Path.GetTempPath()); - var startArguments = terminalViewModel.StartArguments!; - startArguments = startArguments.Insert(startArguments.Length - 1, - "; $abandoned=[System.IO.Pipes.NamedPipeClientStream]::new(" + - "'.',$env:OW_CONTROL_PIPE,[System.IO.Pipes.PipeDirection]::InOut); " + - "$abandoned.Connect(); $abandoned.Dispose(); " + - "__owc 'integration-1' $true 0; __owc 'integration-2' $false 7"); - var provider = new Win32ConPtyPseudoTerminalProvider(); - using var terminal = provider.Create(80, 24, Path.GetTempPath(), "powershell.exe", null, - startArguments); - Assert.NotNull(terminal); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var exitCode = await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); - try - { - using var connection = new PseudoTerminalConnection(terminal); - var firstCompletion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - var secondCompletion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - connection.CommandCompleted += (_, args) => - { - if (args.ExecutionId == "integration-1") - firstCompletion.TrySetResult(args); - else if (args.ExecutionId == "integration-2") - secondCompletion.TrySetResult(args); - }; - connection.Connect(); - - var firstResult = await firstCompletion.Task.WaitAsync(TimeSpan.FromSeconds(5)); - var secondResult = await secondCompletion.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(0, firstResult.ExitCode); - Assert.Equal(7, secondResult.ExitCode); - } - finally - { - if (!terminal.Process.HasExited) - terminal.Process.Kill(true); - } + 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 async Task SendData_ReportsUserInterrupt() + public async Task ShellIntegration_ReportsFailingExitCodeOnUnix() { 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(), "/bin/bash", null, - "--noprofile --norc"); + using var terminal = provider.Create(80, 24, Path.GetTempPath(), bash, config.Environment, + config.Arguments); Assert.NotNull(terminal); using var connection = new PseudoTerminalConnection(terminal); - var interrupted = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - connection.UserInterrupted += (_, _) => interrupted.TrySetResult(); + 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([0x03]); - await interrupted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + 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); } } 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/TerminalManagerViewModelTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs deleted file mode 100644 index dfe3159e6..000000000 --- a/tests/OneWare.Studio.Desktop.UnitTests/TerminalManagerViewModelTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.IO; -using OneWare.TerminalManager.ViewModels; -using OneWare.Terminal.ViewModels; -using Xunit; - -namespace OneWare.Studio.Desktop.UnitTests; - -public class TerminalManagerViewModelTests -{ - [Fact] - public void TrimCompletedOutput_RemovesPromptAndControlSequence() - { - var output = "20/20 seconds\r\nFinished after 20.0 seconds.\r\n"; - var current = output + - "hmenn@aurora:/project$ \r\n" + - "\u001b[1A\r\u001b[2K"; - - var cleaned = TerminalManagerViewModel.TrimCompletedOutput(current); - - Assert.Equal(output, cleaned); - } - - [Fact] - public void TrimCompletedOutput_PreservesOutputWithoutControlSequence() - { - const string output = "Finished without framing"; - - Assert.Equal(output, TerminalManagerViewModel.TrimCompletedOutput(output)); - } - - [Fact] - public void BuildCompletionCommand_UsesShortUnixFunctionCall() - { - if (OperatingSystem.IsWindows()) return; - - var terminal = new TerminalViewModel(Path.GetTempPath()); - - Assert.Equal("__owc 1", terminal.BuildCompletionCommand(terminal.NextExecutionId())); - } -} 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")); + } +} From 56799306c1bd50abd79964b4b8abd5eab0c86f46 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 24 Jul 2026 10:20:03 +0200 Subject: [PATCH 7/8] . --- .../Provider/IPseudoTerminal.cs | 7 ++++ .../Provider/PseudoTerminalConnection.cs | 38 +++++++++++++++++-- src/OneWare.Terminal/Provider/Unix/Native.cs | 3 ++ .../Provider/Unix/UnixPseudoTerminal.cs | 37 ++++++++++++++++++ .../Win32/Win32ConPtyPseudoTerminal.cs | 12 ++++++ .../ViewModels/TerminalViewModel.cs | 17 +++++++++ .../ViewModels/TerminalManagerViewModel.cs | 14 ++++++- .../PseudoTerminalConnectionTests.cs | 27 +++++++++++++ 8 files changed, 151 insertions(+), 4 deletions(-) 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 dd887003e..9d2a2817a 100644 --- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs +++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs @@ -6,9 +6,16 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I { private readonly ShellIntegrationParser _parser = new(); private CancellationTokenSource? _cancellationSource; + 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; @@ -34,8 +41,17 @@ public bool Connect() 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; } @@ -80,6 +96,15 @@ public void Dispose() private void Process_Exited(object? sender, EventArgs e) { terminal.Process.Exited -= Process_Exited; + RaiseClosed(); + } + + private void RaiseClosed() + { + if (Interlocked.Exchange(ref _closedRaised, 1) != 0) return; + + ProcessExitCode = terminal.GetExitCode(); + IsConnected = false; _cancellationSource?.Cancel(); Closed?.Invoke(this, EventArgs.Empty); @@ -107,9 +132,16 @@ private async Task ReadOutputAsync(CancellationToken cancellationToken) } } } - catch (Exception) when (cancellationToken.IsCancellationRequested) + 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. } + + // 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(); } } diff --git a/src/OneWare.Terminal/Provider/Unix/Native.cs b/src/OneWare.Terminal/Provider/Unix/Native.cs index 41d58035c..b3837b881 100644 --- a/src/OneWare.Terminal/Provider/Unix/Native.cs +++ b/src/OneWare.Terminal/Provider/Unix/Native.cs @@ -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")] @@ -156,6 +158,7 @@ internal static class Native 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/Win32/Win32ConPtyPseudoTerminal.cs b/src/OneWare.Terminal/Provider/Win32/Win32ConPtyPseudoTerminal.cs index 28c6de3cf..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) diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index de2e4331c..d9b6bdfc7 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -57,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) @@ -107,6 +113,7 @@ public void CreateConnection() } Connection = new PseudoTerminalConnection(terminal); + Connection.Closed += OnConnectionClosed; Terminal = new VirtualTerminalController(); @@ -147,11 +154,21 @@ public void CloseConnection() { if (Connection != null) { + Connection.Closed -= OnConnectionClosed; Connection.Disconnect(); Connection = null; } } + private void OnConnectionClosed(object? sender, EventArgs e) + { + if (sender is IConnection connection) + connection.Closed -= OnConnectionClosed; + if (!ReferenceEquals(sender, Connection)) return; + + ConnectionClosed?.Invoke(this, EventArgs.Empty); + } + public void Close() { CloseConnection(); diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index db7e25776..ed9de92d8 100644 --- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs +++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs @@ -103,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; @@ -191,10 +193,20 @@ async Task CompleteAfterGraceAsync(CancellationToken graceToken) void OnConnectionClosed(object? sender, EventArgs args) { string partialOutput; + 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 OnDataSent(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args) diff --git a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs index 8580b7632..80f5ce3f4 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/PseudoTerminalConnectionTests.cs @@ -179,4 +179,31 @@ public async Task ShellIntegration_ReportsFailingExitCodeOnUnix() Assert.Equal(42, exitCode); } + + [Fact] + public async Task ShellExit_RaisesClosedInsteadOfHanging() + { + 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(); + + // "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")); + + await closed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(connection.IsConnected); + Assert.Equal(3, connection.ProcessExitCode); + } } From eeadd7af746b052595d9bb0f10c5f8d18b594ebb Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 24 Jul 2026 10:31:48 +0200 Subject: [PATCH 8/8] Store shell integration scripts in per-user directory Using a fixed path under the shared /tmp allowed another local user to pre-create the directory and tamper with the scripts our shells source (TOCTOU), and blocked script writes for the second user on multi-user systems. Scripts now live in ~/.oneware/shell-integration (0700). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Terminal/ShellIntegration.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/OneWare.Terminal/ShellIntegration.cs b/src/OneWare.Terminal/ShellIntegration.cs index 039f6b697..16413da4b 100644 --- a/src/OneWare.Terminal/ShellIntegration.cs +++ b/src/OneWare.Terminal/ShellIntegration.cs @@ -91,7 +91,15 @@ private static string WriteScript(string fileName, string content) private static string CreateScriptDirectory() { - var dir = Path.Combine(Path.GetTempPath(), "oneware-shell-integration"); + // 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)) @@ -99,9 +107,7 @@ private static string CreateScriptDirectory() try { File.SetUnixFileMode(dir, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } catch {