diff --git a/OneWare.slnx b/OneWare.slnx
index 0a5ccca60..c6e8e45e9 100644
--- a/OneWare.slnx
+++ b/OneWare.slnx
@@ -133,6 +133,7 @@
+
diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md
index c7da1c91d..de7950bc2 100644
--- a/docs/PluginDevelopment.md
+++ b/docs/PluginDevelopment.md
@@ -250,7 +250,14 @@ Provides all app path locations: `AppDataDirectory`, `ProjectsDirectory`, `Packa
- `ExecuteInTerminalAsync(command, id, workingDirectory, showInUi, timeout, outputProgress, cancellationToken)`:
run a command in a terminal pane and return the result. Pass an optional `IProgress`
- as `outputProgress` to receive the accumulated output in real time while the command runs.
+ as `outputProgress` to receive the accumulated output in real time while the command runs
+ (rate limited to a few updates per second).
+ Completion is detected through the shell integration markers the shell emits at prompt
+ boundaries. Always pass a `timeout`: shells without working integration fall back to
+ treating a command as finished once its output has been idle for a few seconds, and the
+ `timeout` is the only hard upper bound. `TerminalExecutionResult.TimedOut` is `true` when
+ the command was aborted (timeout or cancellation); in that case the terminal is interrupted
+ with Ctrl+C and, if that fails, its process tree is killed.
#### `IToolService` (src/OneWare.Essentials/Services/IToolService.cs)
diff --git a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs
index f0faa5c51..e3a7ddbd0 100644
--- a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs
+++ b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs
@@ -13,7 +13,7 @@ internal static class AiBuiltInFunctions
private const int MaxTerminalOutputLines = 220;
private const int MaxTerminalOutputChars = 12000;
- private static readonly TimeSpan TerminalCommandTimeout = TimeSpan.FromHours(12);
+ private static readonly TimeSpan TerminalCommandTimeout = TimeSpan.FromMinutes(30);
public static void Register(
IAiFunctionProvider functionProvider,
@@ -154,6 +154,7 @@ This is the ONLY way to know which file is active.
Executes a command in the IDE terminal and returns the output.
Use this to run shell commands; output appears in the IDE terminal panel.
Output is automatically truncated to avoid oversized responses.
+ Commands must not wait for interactive input; they are aborted after 30 minutes.
""",
Handler = ([Description("Shell command to execute")] string command,
[Description("Absolute working directory for execution (optional, defaults to active project).")]
@@ -281,10 +282,11 @@ private static async Task RunTerminalCommandAsync(
cancellationToken.ThrowIfCancellationRequested();
- var truncatedOutput = TruncateTerminalOutput(terminalResult.Output, out var outputTruncated);
- var result = outputTruncated
- ? terminalResult with { Output = truncatedOutput }
- : terminalResult;
+ // Control sequences are meaningless to the model and can make up most of the
+ // payload for commands that draw progress bars, so they are removed here as well.
+ var cleanedOutput = StripAnsiEscapes(terminalResult.Output);
+ var truncatedOutput = TruncateTerminalOutput(cleanedOutput, out var outputTruncated);
+ var result = terminalResult with { Output = truncatedOutput };
// Show the final, cleaned output in the chat tool box.
context?.ReportProgress(FormatTerminalProgress(command, terminalResult.Output));
@@ -293,7 +295,14 @@ private static async Task RunTerminalCommandAsync(
{
result,
outputTruncated,
- originalOutputLength = terminalResult.Output.Length
+ originalOutputLength = terminalResult.Output.Length,
+ note = terminalResult.TimedOut
+ ? $"The command did not finish within {TerminalCommandTimeout.TotalMinutes:0} minutes and was aborted. " +
+ "The output above is partial and the exit code is unknown."
+ : terminalResult.ExitCode < 0
+ ? "The shell did not report an exit code for this command, so success or failure cannot be " +
+ "derived from it. Judge the result from the output instead."
+ : null
};
}
diff --git a/src/OneWare.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs
index 8fc6e5afa..026e64f9d 100644
--- a/src/OneWare.Chat/Views/ChatView.axaml.cs
+++ b/src/OneWare.Chat/Views/ChatView.axaml.cs
@@ -6,6 +6,7 @@
using Avalonia.Interactivity;
using Avalonia.Threading;
using OneWare.Chat.ViewModels;
+using OneWare.Essentials.Services;
namespace OneWare.Chat.Views;
@@ -56,9 +57,24 @@ private void ScrollToEndDeferred()
private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e)
{
- if (e.Key is not (Key.Enter or Key.Return)) return;
+ if (e.Key is not (Key.Enter or Key.Return or Key.V)) return;
if (DataContext is not ChatViewModel vm) return;
+ // --- Ctrl+V: try image paste first ---
+ if (e.Key == Key.V && e.KeyModifiers.HasFlag(KeyModifiers.Control)
+ && !e.KeyModifiers.HasFlag(KeyModifiers.Shift)
+ && !e.KeyModifiers.HasFlag(KeyModifiers.Alt)
+ && vm.SelectedChatService is IChatService chatService)
+ {
+ // Mark as handled immediately (synchronously) to prevent the TextBox from
+ // processing the keystroke before our async clipboard check completes.
+ e.Handled = true;
+ _ = HandleClipboardPasteAsync(chatService);
+ return;
+ }
+
+ if (e.Key is not (Key.Enter or Key.Return)) return;
+
var modifiers = e.KeyModifiers;
// Shift+Enter inserts a newline — let the TextBox handle it.
@@ -81,6 +97,72 @@ private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e)
Execute(vm.IsBusy ? vm.SteerCommand : vm.SendCommand, e);
}
+ private async Task HandleClipboardPasteAsync(IChatService chatService)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+
+ try
+ {
+ var formats = await clipboard.GetFormatsAsync();
+
+ // Check for image data first.
+ var imageFormat = formats.FirstOrDefault(f =>
+ string.Equals(f, "PNG", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(f, "image/png", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(f, "image/jpeg", StringComparison.OrdinalIgnoreCase));
+
+ if (imageFormat != null)
+ {
+ var raw = await clipboard.GetDataAsync(imageFormat);
+ if (raw is byte[] bytes && bytes.Length > 0)
+ {
+ var mimeType = imageFormat.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase)
+ ? "image/jpeg"
+ : "image/png";
+ var ext = mimeType == "image/jpeg" ? ".jpg" : ".png";
+ var name = $"image{ext}";
+
+ if (chatService.TryAddImageAttachment(bytes, mimeType, name))
+ return;
+ }
+ }
+ }
+ catch
+ {
+ // Clipboard access failed — fall through to text paste.
+ }
+
+ // No image (or service doesn't support it): fall back to pasting text.
+ await FallbackTextPasteAsync();
+ }
+
+ private async Task FallbackTextPasteAsync()
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+
+ try
+ {
+ var text = await clipboard.GetTextAsync();
+ if (string.IsNullOrEmpty(text)) return;
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ var start = Math.Min(CommandBox.SelectionStart, CommandBox.SelectionEnd);
+ var end = Math.Max(CommandBox.SelectionStart, CommandBox.SelectionEnd);
+ var current = CommandBox.Text ?? string.Empty;
+ CommandBox.Text = current.Remove(start, end - start).Insert(start, text);
+ CommandBox.CaretIndex = start + text.Length;
+ CommandBox.SelectionStart = CommandBox.SelectionEnd = CommandBox.CaretIndex;
+ });
+ }
+ catch
+ {
+ // Best-effort; ignore clipboard errors.
+ }
+ }
+
private static void Execute(System.Windows.Input.ICommand command, KeyEventArgs e)
{
e.Handled = true;
diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs
index 3be1b6e15..4f0228cec 100644
--- a/src/OneWare.Copilot/Services/CopilotChatService.cs
+++ b/src/OneWare.Copilot/Services/CopilotChatService.cs
@@ -716,6 +716,7 @@ private void RemoveAttachment(CopilotAttachmentViewModel attachment)
{
Attachments.Remove(attachment);
}
+ attachment.Dispose();
}
public IAsyncRelayCommand AddAttachmentCommand => field ??= new AsyncRelayCommand(AddAttachmentAsync);
@@ -752,11 +753,20 @@ private async Task AddAttachmentAsync(Visual? source)
private void ClearAttachmentsAfterSend()
{
+ foreach (var attachment in Attachments)
+ attachment.Dispose();
Attachments.Clear();
_activeFileDismissed = false;
RefreshActiveFileAttachment(focusChanged: false);
}
+ public bool TryAddImageAttachment(byte[] data, string mimeType, string displayName)
+ {
+ EnsureAttachmentTracking();
+ Attachments.Add(new CopilotAttachmentViewModel(data, mimeType, displayName, isActiveFile: false, RemoveAttachment));
+ return true;
+ }
+
#endregion
diff --git a/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs
index f8471018a..1acc83664 100644
--- a/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs
+++ b/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs
@@ -1,3 +1,4 @@
+using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GitHub.Copilot;
@@ -6,13 +7,15 @@ namespace OneWare.Copilot.ViewModels;
///
/// A single attachment chip shown in the Copilot chat attachment strip. Represents either a whole
-/// file or a code selection within a file, and knows how to convert itself into an SDK
-/// when a message is sent.
+/// file, a code selection within a file, or an inline image blob (e.g. pasted from clipboard), and
+/// knows how to convert itself into an SDK when a message is sent.
///
-public sealed class CopilotAttachmentViewModel : ObservableObject
+public sealed class CopilotAttachmentViewModel : ObservableObject, IDisposable
{
private readonly string? _selectionText;
private readonly SelectionRange? _selection;
+ private readonly byte[]? _imageData;
+ private readonly string? _mimeType;
public string FilePath { get; }
@@ -26,6 +29,12 @@ public sealed class CopilotAttachmentViewModel : ObservableObject
public string IconResourceKey { get; }
+ /// True when this attachment wraps inline image data rather than a file path.
+ public bool IsImage { get; }
+
+ /// Small decoded thumbnail for image attachments; null for file/selection attachments.
+ public Bitmap? ThumbnailBitmap { get; }
+
public IRelayCommand RemoveCommand { get; }
public CopilotAttachmentViewModel(
@@ -52,8 +61,47 @@ public CopilotAttachmentViewModel(
RemoveCommand = new RelayCommand(() => onRemove(this));
}
+ /// Creates an image-blob attachment from raw bytes (e.g. a clipboard paste).
+ public CopilotAttachmentViewModel(
+ byte[] imageData,
+ string mimeType,
+ string displayName,
+ bool isActiveFile,
+ Action onRemove)
+ {
+ FilePath = string.Empty;
+ DisplayName = displayName;
+ IsActiveFile = isActiveFile;
+ IsImage = true;
+ IconResourceKey = "VsImageLib.File16X";
+ _imageData = imageData;
+ _mimeType = mimeType;
+
+ try
+ {
+ using var ms = new MemoryStream(imageData);
+ ThumbnailBitmap = Bitmap.DecodeToWidth(ms, 24);
+ }
+ catch
+ {
+ // Best-effort thumbnail; show nothing if decoding fails.
+ }
+
+ RemoveCommand = new RelayCommand(() => onRemove(this));
+ }
+
public Attachment ToSdkAttachment()
{
+ if (IsImage && _imageData != null && _mimeType != null)
+ {
+ return new AttachmentBlob
+ {
+ Data = Convert.ToBase64String(_imageData),
+ MimeType = _mimeType,
+ DisplayName = DisplayName
+ };
+ }
+
if (_selection is { } s && _selectionText is not null)
{
return new AttachmentSelection
@@ -76,5 +124,10 @@ public Attachment ToSdkAttachment()
};
}
+ public void Dispose()
+ {
+ ThumbnailBitmap?.Dispose();
+ }
+
public readonly record struct SelectionRange(int StartLine, int StartColumn, int EndLine, int EndColumn);
}
diff --git a/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml
index 3a596dd3a..eb2923e47 100644
--- a/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml
+++ b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml
@@ -12,8 +12,15 @@
+
+
+
+ VerticalAlignment="Center"
+ IsVisible="{Binding !IsImage}" />
Task ClearQueuedMessagesAsync() => Task.FromResult(false);
+ ///
+ /// Attaches raw image data (e.g. from a clipboard paste) to the next message.
+ /// Returns if the service accepted the image;
+ /// if image attachments are not supported and the caller should fall back to text paste.
+ ///
+ bool TryAddImageAttachment(byte[] data, string mimeType, string displayName) => false;
+
///
/// Starts a new chat session.
///
diff --git a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs
index 9d2a2817a..5e551cd49 100644
--- a/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs
+++ b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs
@@ -16,6 +16,20 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I
///
public int? ProcessExitCode { get; private set; }
+ ///
+ /// True once the shell has emitted at least one OSC 633 sequence, i.e. the integration
+ /// hooks are installed and command lifecycle markers can be relied on. Automation waits
+ /// for this before sending a command so the markers of that command cannot be missed.
+ ///
+ public bool ShellIntegrationDetected { get; private set; }
+
+ ///
+ /// Set by consumers after they waited for in vain,
+ /// so every following command on the same shell skips the probe delay and goes straight
+ /// to the marker-less fallback.
+ ///
+ public bool ShellIntegrationProbeFailed { get; set; }
+
public event EventHandler? DataReceived;
public event EventHandler? Closed;
@@ -128,7 +142,10 @@ private async Task ReadOutputAsync(CancellationToken cancellationToken)
if (segment.Data != null)
DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = segment.Data });
else if (segment.Event is { } integrationEvent)
+ {
+ ShellIntegrationDetected = true;
IntegrationEvent?.Invoke(this, new ShellIntegrationEventArgs(integrationEvent));
+ }
}
}
}
diff --git a/src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs b/src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs
new file mode 100644
index 000000000..b01dd9880
--- /dev/null
+++ b/src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs
@@ -0,0 +1,154 @@
+using Avalonia.Threading;
+using VtNetCore.Avalonia;
+
+namespace OneWare.Terminal.Provider;
+
+///
+/// Wraps a pty connection for the terminal control. The control subscribes to
+/// only while it is attached to the visual tree, and the terminal
+/// pane keeps just the selected tab attached. Without this wrapper every byte a shell writes
+/// while its tab is not selected is dropped, so the terminal shows a hole in its output (or
+/// nothing at all for commands the AI ran in a background tab).
+/// This wrapper keeps that output and replays it once a control subscribes again.
+///
+///
+/// The buffer is only filled while there is no subscriber, so data is never delivered twice.
+///
+public sealed class ReplayBufferedConnection : IConnection, IDisposable
+{
+ private const int MaxBufferedBytes = 1024 * 1024;
+
+ private readonly IConnection _inner;
+ private readonly Lock _lock = new();
+ private readonly Queue _pending = new();
+
+ private EventHandler? _dataReceived;
+ private int _pendingBytes;
+ private bool _flushScheduled;
+ private bool _disposed;
+
+ public ReplayBufferedConnection(IConnection inner)
+ {
+ _inner = inner;
+ _inner.DataReceived += OnInnerDataReceived;
+ }
+
+ public bool IsConnected => _inner.IsConnected;
+
+ public event EventHandler? DataReceived
+ {
+ add
+ {
+ if (value == null) return;
+
+ lock (_lock)
+ {
+ _dataReceived += value;
+ ScheduleFlush();
+ }
+ }
+ remove
+ {
+ if (value == null) return;
+ lock (_lock) _dataReceived -= value;
+ }
+ }
+
+ public event EventHandler Closed
+ {
+ add => _inner.Closed += value;
+ remove => _inner.Closed -= value;
+ }
+
+ public bool Connect()
+ {
+ return _inner.Connect();
+ }
+
+ public void Disconnect()
+ {
+ _inner.Disconnect();
+ }
+
+ public void SendData(byte[] data)
+ {
+ _inner.SendData(data);
+ }
+
+ public void SetTerminalWindowSize(int columns, int rows)
+ {
+ _inner.SetTerminalWindowSize(columns, rows);
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _pending.Clear();
+ _pendingBytes = 0;
+ _dataReceived = null;
+ }
+
+ _inner.DataReceived -= OnInnerDataReceived;
+ }
+
+ private void OnInnerDataReceived(object? sender, DataReceivedEventArgs e)
+ {
+ // Delivery and buffering happen under the same lock so that a control subscribing
+ // concurrently cannot receive replayed and live data out of order. Subscribers only
+ // queue the data for the UI thread, so holding the lock is cheap.
+ lock (_lock)
+ {
+ if (_disposed) return;
+
+ // While a replay is still pending, new data has to queue up behind it.
+ if (_dataReceived != null && _pending.Count == 0)
+ {
+ _dataReceived(this, e);
+ return;
+ }
+
+ _pending.Enqueue(e.Data);
+ _pendingBytes += e.Data.Length;
+
+ // Drop the oldest output when a hidden terminal produces more than the buffer
+ // holds; that part would have scrolled out of the terminal's scrollback anyway.
+ while (_pendingBytes > MaxBufferedBytes && _pending.Count > 1)
+ _pendingBytes -= _pending.Dequeue().Length;
+
+ ScheduleFlush();
+ }
+ }
+
+ ///
+ /// Replays buffered output on the UI thread instead of inline. A subscribing terminal
+ /// control is not ready to consume data yet: it binds its connection before its terminal
+ /// controller, and only the latter creates the parser the data is pushed into.
+ ///
+ private void ScheduleFlush()
+ {
+ if (_flushScheduled || _disposed || _dataReceived == null || _pending.Count == 0) return;
+
+ _flushScheduled = true;
+ Dispatcher.UIThread.Post(Flush);
+ }
+
+ private void Flush()
+ {
+ lock (_lock)
+ {
+ _flushScheduled = false;
+ if (_disposed) return;
+
+ var handler = _dataReceived;
+ if (handler == null) return;
+
+ while (_pending.Count > 0)
+ handler(this, new DataReceivedEventArgs { Data = _pending.Dequeue() });
+
+ _pendingBytes = 0;
+ }
+ }
+}
diff --git a/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs b/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs
index ebf1b154b..f4cce9d1a 100644
--- a/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs
+++ b/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs
@@ -18,6 +18,14 @@ public sealed class UserInputEchoFilter
private DateTimeOffset _lastInput = DateTimeOffset.MinValue;
private bool _dropNextLf;
+ /// Forgets all pending input, e.g. after the captured output has been reset.
+ public void Reset()
+ {
+ _pending.Clear();
+ _dropNextLf = false;
+ _lastInput = DateTimeOffset.MinValue;
+ }
+
/// Records user input written to the pty so its echo can be recognized.
public void OnUserInput(byte[] data)
{
diff --git a/src/OneWare.Terminal/ShellIntegration.cs b/src/OneWare.Terminal/ShellIntegration.cs
index 16413da4b..cd1055412 100644
--- a/src/OneWare.Terminal/ShellIntegration.cs
+++ b/src/OneWare.Terminal/ShellIntegration.cs
@@ -63,9 +63,11 @@ private static SpawnConfig CreatePowerShellConfig(string shellExecutable)
// The arguments string must contain the full command line because the ConPTY
// provider uses it verbatim when it is not empty.
+ // -ExecutionPolicy Bypass is required so the integration script can be dot-sourced
+ // regardless of the system execution policy (matches VS Code behaviour).
var exe = Path.GetFileName(shellExecutable);
return new SpawnConfig(
- $"{exe} -NoLogo -NoProfile -NoExit -Command \". '{escaped}'\"", null);
+ $"{exe} -NoLogo -NoProfile -NoExit -ExecutionPolicy Bypass -Command \". '{escaped}'\"", null);
}
private static readonly Lock WriteLock = new();
@@ -175,23 +177,41 @@ private static string CreateScriptDirectory()
# OneWare shell integration for PowerShell.
$Global:__OneWareOriginalPrompt = $function:Prompt
+ # Reads a line and emits the command-started marker right before it executes.
+ # PSReadLine is used when available; it is frequently NOT loaded yet while this
+ # script is sourced (powershell.exe -Command runs before the interactive REPL
+ # imports it), so the module is resolved per call instead of once at install time.
+ $Global:__OneWareReadLineHook = {
+ $__ow_line = if (Get-Module -Name PSReadLine) {
+ [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext)
+ } else {
+ $Host.UI.ReadLine()
+ }
+ [Console]::Write("$([char]0x1b)]633;C$([char]0x07)")
+ return $__ow_line
+ }
+
+ function Global:__OneWareEnsureReadLineHook {
+ $__ow_current = (Get-Item -LiteralPath function:global:PSConsoleHostReadLine -ErrorAction SilentlyContinue).ScriptBlock
+ if ($null -eq $__ow_current -or $__ow_current -ne $Global:__OneWareReadLineHook) {
+ Set-Item -LiteralPath function:global:PSConsoleHostReadLine -Value $Global:__OneWareReadLineHook
+ }
+ }
+
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 }
+ # PSReadLine overwrites PSConsoleHostReadLine when it is imported, which can
+ # happen after this script ran. Re-installing on every prompt keeps the
+ # command-started marker working regardless of module load order.
+ __OneWareEnsureReadLineHook
$__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
- }
- }
+ __OneWareEnsureReadLineHook
""";
}
diff --git a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs
index d9b6bdfc7..a6ad2459c 100644
--- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs
+++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs
@@ -36,6 +36,17 @@ public IConnection? Connection
set => SetProperty(ref field, value);
}
+ ///
+ /// The connection the terminal control binds to. It buffers output while no control is
+ /// attached (an unselected terminal tab, a collapsed terminal pane) and replays it on the
+ /// next attach, so switching tabs never loses output.
+ ///
+ public IConnection? ViewConnection
+ {
+ get;
+ private set => SetProperty(ref field, value);
+ }
+
public VirtualTerminalController? Terminal
{
@@ -79,7 +90,15 @@ public void StartCreate()
public void CreateConnection()
{
- if (Connection is { IsConnected: true }) return;
+ if (Connection is { IsConnected: true })
+ {
+ // Already usable. Still announce readiness: callers subscribe to TerminalReady
+ // before calling this and would otherwise wait for an event that never comes.
+ TerminalLoading = false;
+ Dispatcher.UIThread.Post(() => TerminalReady?.Invoke(this, EventArgs.Empty));
+ return;
+ }
+
TerminalLoading = true;
lock (_createLock)
@@ -109,11 +128,16 @@ public void CreateConnection()
if (terminal == null)
{
ContainerLocator.Container.Resolve().Error("Error creating terminal!");
+ TerminalLoading = false;
+ // Unblock anyone waiting for readiness; they detect the missing
+ // connection and fail fast instead of hanging.
+ Dispatcher.UIThread.Post(() => TerminalReady?.Invoke(this, EventArgs.Empty));
return;
}
Connection = new PseudoTerminalConnection(terminal);
Connection.Closed += OnConnectionClosed;
+ ViewConnection = new ReplayBufferedConnection(Connection);
Terminal = new VirtualTerminalController();
@@ -128,6 +152,12 @@ public void CreateConnection()
TerminalReady?.Invoke(this, EventArgs.Empty);
});
}
+ else
+ {
+ ContainerLocator.Container.Resolve().Error("No supported shell found for this platform!");
+ TerminalLoading = false;
+ Dispatcher.UIThread.Post(() => TerminalReady?.Invoke(this, EventArgs.Empty));
+ }
}
}
@@ -158,6 +188,9 @@ public void CloseConnection()
Connection.Disconnect();
Connection = null;
}
+
+ if (ViewConnection is IDisposable disposableView) disposableView.Dispose();
+ ViewConnection = null;
}
private void OnConnectionClosed(object? sender, EventArgs e)
diff --git a/src/OneWare.Terminal/Views/TerminalView.axaml b/src/OneWare.Terminal/Views/TerminalView.axaml
index 274224e59..d1ba9c620 100644
--- a/src/OneWare.Terminal/Views/TerminalView.axaml
+++ b/src/OneWare.Terminal/Views/TerminalView.axaml
@@ -20,8 +20,8 @@
-
diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs
index ed9de92d8..bd80828ac 100644
--- a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs
+++ b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs
@@ -25,6 +25,39 @@ public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService
// followed by silence.
private static readonly TimeSpan MultiCommandGracePeriod = TimeSpan.FromMilliseconds(250);
+ // The ready handshake is a purely local operation. It must never inherit the (possibly
+ // very long) command timeout: a shell that fails to spawn would otherwise block the
+ // caller for hours instead of failing fast.
+ private static readonly TimeSpan TerminalStartTimeout = TimeSpan.FromSeconds(30);
+
+ // Time granted to a freshly started shell to emit its first prompt marker. Sending the
+ // command before the integration hooks are installed loses that command's lifecycle
+ // markers, which is the classic cause of an execution that never returns.
+ private static readonly TimeSpan ShellIntegrationProbeTimeout = TimeSpan.FromSeconds(10);
+
+ // Fallback for shells whose integration never works (unknown shell, blocked startup
+ // files, ...): the command is considered finished once its output stays silent this
+ // long. Without markers there is no better completion signal, and waiting forever is
+ // never an acceptable outcome.
+ private static readonly TimeSpan NoIntegrationIdleTimeout = TimeSpan.FromSeconds(5);
+
+ // Per attempt time granted to Ctrl+C before the process tree is killed.
+ private static readonly TimeSpan InterruptRecoveryTimeout = TimeSpan.FromSeconds(2);
+ private const int InterruptAttempts = 2;
+
+ // Progress is pushed to the UI thread, where it re-renders the whole captured text.
+ // Reporting every pty chunk turns a chatty command into an application freeze, so
+ // updates are rate limited.
+ private const long ProgressReportIntervalMs = 250;
+
+ // Hard cap on captured output so a runaway command cannot exhaust memory.
+ private const int MaxCapturedOutputChars = 1_000_000;
+
+ // Applied when the caller passes no timeout. A command whose completion marker never
+ // arrives (a nested shell, an interactive REPL, ...) would otherwise block its caller
+ // forever; automation must always terminate.
+ private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromHours(1);
+
// 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
// interleaving on a single shell. Idle terminals in a pool are reused for sequential
@@ -39,6 +72,10 @@ public class TerminalManagerViewModel : ExtendedTool, ITerminalManagerService
private TerminalTabModel? _selectedTerminalTab;
+ // Set once a shell failed to report any integration marker. The shell is chosen per
+ // platform, so the result applies to every terminal and spares later commands the probe.
+ private volatile bool _shellIntegrationUnavailable;
+
public TerminalManagerViewModel(ISettingsService settingsService, IMainDockService mainDockService,
IProjectExplorerService projectExplorerService, IPaths paths) : base(IconKey)
{
@@ -116,54 +153,66 @@ public async Task ExecuteInTerminalAsync(TerminalViewMo
TimeSpan? timeout = null, bool closeWhenDone = true, IProgress? outputProgress = null,
CancellationToken cancellationToken = default)
{
- var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- void OnReady(object? sender, EventArgs args) => readyTcs.TrySetResult();
-
- if (terminal.Connection?.IsConnected == true && !terminal.TerminalLoading)
- {
- readyTcs.TrySetResult();
- }
- else
- {
- terminal.TerminalReady += OnReady;
- terminal.CreateConnection();
- }
-
+ PseudoTerminalConnection? connection;
try
{
- await WaitForReadyAsync(readyTcs.Task, timeout, cancellationToken);
+ connection = await EnsureConnectedAsync(terminal, cancellationToken);
}
catch (OperationCanceledException)
{
- terminal.TerminalReady -= OnReady;
if (closeWhenDone) terminal.Close();
return new TerminalExecutionResult(string.Empty, -1, true);
}
- terminal.TerminalReady -= OnReady;
-
- if (terminal.Connection is not PseudoTerminalConnection connection)
+ if (connection == null)
{
+ // The shell could not be started (or did not become ready in time). Drop the
+ // terminal so the next command gets a fresh one instead of retrying a dead tab.
if (closeWhenDone) terminal.Close();
- return new TerminalExecutionResult(string.Empty, -1, true);
+ else DiscardAutomationTerminal(terminal);
+ return new TerminalExecutionResult("[terminal could not be started]", -1, true);
}
var output = new StringBuilder();
var stateLock = new object();
var resultTcs =
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var integrationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ // Strips echoed keystrokes (ours and the user's) from the captured output only.
+ var echoFilter = new UserInputEchoFilter();
+ var idleCts = new CancellationTokenSource();
+
var commandSent = false;
- // 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.
+ // True from the moment the command is written to the pty. Capturing deliberately does
+ // NOT wait for the command-start marker: that marker is the least reliable part of
+ // shell integration (it depends on PSReadLine on Windows), and gating capture and
+ // completion on it made a missing marker hang the execution forever.
var capturing = false;
+ var sawCommandStart = false;
+ var integrationSeen = connection.ShellIntegrationDetected;
var lastExitCode = 0;
+ var exitCodeKnown = false;
+ var lastActivityTicks = Environment.TickCount64;
+ var lastProgressTicks = 0L;
+ var idleFallbackUsed = false;
CancellationTokenSource? graceCts = null;
- // Strips echoed user keystrokes from the captured output only (not the visible terminal).
- var echoFilter = new UserInputEchoFilter();
- void CompleteWithResult()
+ void ReplaceGraceSource(CancellationTokenSource? replacement)
+ {
+ var previous = graceCts;
+ graceCts = replacement;
+ previous?.Cancel();
+ previous?.Dispose();
+ }
+
+ void AppendOutput(string text)
+ {
+ output.Append(text);
+ if (output.Length > MaxCapturedOutputChars)
+ output.Remove(0, output.Length - MaxCapturedOutputChars);
+ }
+
+ void CompleteWithResult(bool exitCodeIsKnown)
{
string finalOutput;
int exitCode;
@@ -173,7 +222,14 @@ void CompleteWithResult()
exitCode = lastExitCode;
}
- resultTcs.TrySetResult(new TerminalExecutionResult(finalOutput, exitCode, false));
+ if (!exitCodeIsKnown)
+ finalOutput +=
+ "\n[no shell integration: the command was assumed to have finished after its output " +
+ "went idle, the exit code is unknown]";
+
+ // An unknown exit code must never look like success: -1 is the established
+ // "indeterminate" value of this result type.
+ resultTcs.TrySetResult(new TerminalExecutionResult(finalOutput, exitCodeIsKnown ? exitCode : -1, false));
}
async Task CompleteAfterGraceAsync(CancellationToken graceToken)
@@ -187,7 +243,41 @@ async Task CompleteAfterGraceAsync(CancellationToken graceToken)
return; // Another command line started; keep waiting for its completion.
}
- CompleteWithResult();
+ CompleteWithResult(true);
+ }
+
+ // Only used while the shell reports no lifecycle markers at all. Completing on
+ // silence is a guess, but it is the only way to guarantee that the call returns.
+ async Task WatchIdleAsync(CancellationToken idleToken)
+ {
+ while (!idleToken.IsCancellationRequested)
+ {
+ TimeSpan remaining;
+ lock (stateLock)
+ {
+ if (integrationSeen) return;
+ var idleFor = TimeSpan.FromMilliseconds(Environment.TickCount64 - lastActivityTicks);
+ remaining = NoIntegrationIdleTimeout - idleFor;
+ }
+
+ if (remaining <= TimeSpan.Zero)
+ {
+ // The command may well still be running (it was only guessed to have
+ // finished), so the shell must not be handed to the next command.
+ idleFallbackUsed = true;
+ CompleteWithResult(false);
+ return;
+ }
+
+ try
+ {
+ await Task.Delay(remaining, idleToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
}
void OnConnectionClosed(object? sender, EventArgs args)
@@ -197,7 +287,7 @@ void OnConnectionClosed(object? sender, EventArgs args)
lock (stateLock)
{
partialOutput = output.ToString();
- exitCode = capturing ? -1 : lastExitCode;
+ exitCode = capturing && !exitCodeKnown ? -1 : lastExitCode;
}
// The shell itself exited (e.g. the command was "exit 3"). Prefer the real
@@ -213,8 +303,9 @@ void OnDataSent(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs args)
{
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.
+ // Input written while our command runs (the command line itself and anything
+ // the user types into the terminal) is echoed back by the pty; remember it so
+ // the echo can be removed from the captured output.
if (capturing)
echoFilter.OnUserInput(args.Data);
}
@@ -227,13 +318,22 @@ void OnDataReceived(object? sender, VtNetCore.Avalonia.DataReceivedEventArgs arg
lock (stateLock)
{
if (!capturing) return;
+
+ lastActivityTicks = Environment.TickCount64;
+
var filtered = echoFilter.Filter(args.Data);
if (filtered.Length == 0) return;
- output.Append(Encoding.UTF8.GetString(filtered));
- current = output.ToString();
+ AppendOutput(Encoding.UTF8.GetString(filtered));
+
+ var now = Environment.TickCount64;
+ if (outputProgress != null && now - lastProgressTicks >= ProgressReportIntervalMs)
+ {
+ lastProgressTicks = now;
+ current = output.ToString();
+ }
}
- if (!resultTcs.Task.IsCompleted)
+ if (current != null && !resultTcs.Task.IsCompleted)
outputProgress?.Report(current);
}
@@ -241,22 +341,39 @@ void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args)
{
lock (stateLock)
{
+ integrationSeen = true;
+ integrationTcs.TrySetResult();
+ lastActivityTicks = Environment.TickCount64;
+
+ if (!commandSent) return;
+
if (args.IsCommandStarted)
{
+ // The shell confirmed our command started. Everything captured so far is
+ // prompt redraw and command echo, which the echo filter cannot always
+ // recognize (PSReadLine re-renders the line with colors), so drop it.
+ if (!sawCommandStart)
+ {
+ sawCommandStart = true;
+ output.Clear();
+ echoFilter.Reset();
+ }
+
capturing = true;
- graceCts?.Cancel();
- graceCts = null;
+ ReplaceGraceSource(null);
}
- else if (args.IsCommandCompleted && capturing)
+ else if (args.IsCommandCompleted)
{
- // 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);
+ exitCodeKnown = true;
+ // Stop capturing so the prompt drawn right after the command does not
+ // leak into the output. Only safe when the shell also emits command-start
+ // markers, because those are what resume capture for the next line of a
+ // multi-line command block.
+ if (sawCommandStart) capturing = false;
+ var grace = new CancellationTokenSource();
+ ReplaceGraceSource(grace);
+ _ = CompleteAfterGraceAsync(grace.Token);
}
}
}
@@ -266,29 +383,53 @@ void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args)
connection.Closed += OnConnectionClosed;
connection.IntegrationEvent += OnIntegrationEvent;
- if (!resultTcs.Task.IsCompleted && !cancellationToken.IsCancellationRequested)
- {
- commandSent = true;
- terminal.Send(command);
- }
-
TerminalExecutionResult result;
try
{
+ // Wait until the shell reached its first prompt. That proves the integration
+ // hooks are installed, so the markers of the command we are about to send cannot
+ // be missed. Skipping this step is what made executions hang indefinitely.
+ if (!integrationSeen && !connection.ShellIntegrationProbeFailed && !_shellIntegrationUnavailable)
+ {
+ try
+ {
+ await integrationTcs.Task.WaitAsync(ShellIntegrationProbeTimeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ // This shell has no working integration. Remember it so following commands
+ // do not pay the probe timeout again; the shell is a property of the
+ // platform, so the result applies to every terminal of this manager.
+ connection.ShellIntegrationProbeFailed = true;
+ _shellIntegrationUnavailable = true;
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ bool useIdleFallback;
+ lock (stateLock)
+ {
+ capturing = true;
+ commandSent = true;
+ lastActivityTicks = Environment.TickCount64;
+ useIdleFallback = !integrationSeen;
+ }
+
+ if (useIdleFallback) _ = WatchIdleAsync(idleCts.Token);
+
+ terminal.Send(command);
+
result = await WaitForResultAsync(resultTcs.Task, timeout, cancellationToken);
}
catch (OperationCanceledException)
{
- string partialOutput;
- lock (stateLock)
- partialOutput = output.ToString();
-
if (commandSent)
{
- // 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.
+ // 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)
{
@@ -300,14 +441,31 @@ void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args)
}
}
+ string partialOutput;
+ lock (stateLock)
+ partialOutput = output.ToString();
+
result = new TerminalExecutionResult(partialOutput, -1, true);
}
finally
{
lock (stateLock)
{
- graceCts?.Cancel();
- graceCts = null;
+ ReplaceGraceSource(null);
+ }
+
+ idleCts.Cancel();
+ idleCts.Dispose();
+
+ if (idleFallbackUsed)
+ {
+ // The command was only *assumed* to be finished. It may still be attached to
+ // this shell, so the shell must never serve another command: its output would
+ // be mixed into the next result and the next command line would be fed to the
+ // still running process as stdin.
+ terminal.KillProcess();
+ DiscardAutomationTerminal(terminal);
+ terminal.Close();
}
connection.DataReceived -= OnDataReceived;
@@ -320,21 +478,61 @@ void OnIntegrationEvent(object? sender, ShellIntegrationEventArgs args)
return result;
}
- private static async Task TryRecoverPromptAsync(TerminalViewModel terminal,
- Task resultTask)
+ ///
+ /// Brings the terminal up to a connected state and returns its pty connection, or null
+ /// when the shell could not be started.
+ ///
+ private static async Task EnsureConnectedAsync(TerminalViewModel terminal,
+ CancellationToken cancellationToken)
{
- terminal.SendInterrupt();
+ var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnReady(object? sender, EventArgs args) => readyTcs.TrySetResult();
+
+ terminal.TerminalReady += OnReady;
+
try
{
- // 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;
+ if (terminal.Connection is { IsConnected: true } && !terminal.TerminalLoading)
+ readyTcs.TrySetResult();
+ else
+ terminal.CreateConnection();
+
+ await readyTcs.Task.WaitAsync(TerminalStartTimeout, cancellationToken);
}
catch (TimeoutException)
{
- return false;
+ return null;
+ }
+ finally
+ {
+ terminal.TerminalReady -= OnReady;
+ }
+
+ return terminal.Connection as PseudoTerminalConnection;
+ }
+
+ private static async Task TryRecoverPromptAsync(TerminalViewModel terminal,
+ Task resultTask)
+ {
+ for (var attempt = 0; attempt < InterruptAttempts; attempt++)
+ {
+ terminal.SendInterrupt();
+ try
+ {
+ // The interrupt makes the shell print a fresh prompt, whose integration
+ // marker completes the pending result and keeps the terminal reusable.
+ await resultTask.WaitAsync(InterruptRecoveryTimeout);
+ return true;
+ }
+ catch (TimeoutException)
+ {
+ // Retry once: the first Ctrl+C is sometimes swallowed by a program that
+ // installs its own handler while starting up.
+ }
}
+
+ return false;
}
private void DiscardAutomationTerminal(TerminalViewModel terminal)
@@ -370,25 +568,12 @@ public void ExecScriptInTerminal(string scriptPath, bool elevated, string title)
}
}
- private static async Task WaitForReadyAsync(Task readyTask, TimeSpan? timeout, CancellationToken cancellationToken)
- {
- if (timeout == null)
- {
- await readyTask.WaitAsync(cancellationToken);
- return;
- }
-
- using var timeoutCts = new CancellationTokenSource(timeout.Value);
- using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
- await readyTask.WaitAsync(linkedCts.Token);
- }
-
private static async Task WaitForResultAsync(
Task resultTask, TimeSpan? timeout, CancellationToken cancellationToken)
{
- if (timeout == null) return await resultTask.WaitAsync(cancellationToken);
-
- using var timeoutCts = new CancellationTokenSource(timeout.Value);
+ // Never wait unbounded: a command whose completion marker never arrives would
+ // otherwise block the caller for the lifetime of the application.
+ using var timeoutCts = new CancellationTokenSource(timeout ?? DefaultCommandTimeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
return await resultTask.WaitAsync(linkedCts.Token);
}
diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj b/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj
index 1c4a3f3fd..12febf6fe 100644
--- a/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj
+++ b/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj
@@ -59,8 +59,10 @@
+
diff --git a/studio/OneWare.Studio.Desktop.WindowsInstaller/SignInstaller.ps1 b/studio/OneWare.Studio.Desktop.WindowsInstaller/SignInstaller.ps1
index 1f5fadac4..2d007b516 100644
--- a/studio/OneWare.Studio.Desktop.WindowsInstaller/SignInstaller.ps1
+++ b/studio/OneWare.Studio.Desktop.WindowsInstaller/SignInstaller.ps1
@@ -1 +1 @@
-signtool sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /a bin\Release\en-us\OneWare.Studio.Desktop.WindowsInstaller.msi
\ No newline at end of file
+signtool sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 /d "OneWare Studio" /du "https://www.one-ware.com" /a bin\Release\en-us\OneWare.Studio.Desktop.WindowsInstaller.msi
\ No newline at end of file
diff --git a/tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj b/tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj
new file mode 100644
index 000000000..03c276c63
--- /dev/null
+++ b/tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+ net10.0
+ Library
+ False
+ True
+ False
+ True
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/tests/OneWare.Terminal.UnitTests/ReplayBufferedConnectionTests.cs b/tests/OneWare.Terminal.UnitTests/ReplayBufferedConnectionTests.cs
new file mode 100644
index 000000000..890542003
--- /dev/null
+++ b/tests/OneWare.Terminal.UnitTests/ReplayBufferedConnectionTests.cs
@@ -0,0 +1,183 @@
+using System.Text;
+using Avalonia.Threading;
+using OneWare.Terminal.Provider;
+using VtNetCore.Avalonia;
+using Xunit;
+
+namespace OneWare.Terminal.UnitTests;
+
+public class ReplayBufferedConnectionTests
+{
+ [Fact]
+ public void ReplaysOutputProducedBeforeAControlSubscribed()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+
+ inner.Emit("while-hidden");
+
+ var received = Subscribe(connection, out _);
+ Assert.Empty(received);
+
+ Dispatcher.UIThread.RunJobs();
+ Assert.Equal(["while-hidden"], received);
+ }
+
+ [Fact]
+ public void ForwardsOutputWhileAControlIsSubscribed()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+ var received = Subscribe(connection, out _);
+
+ inner.Emit("live");
+
+ Assert.Equal(["live"], received);
+ }
+
+ [Fact]
+ public void BuffersWhileDetachedAndReplaysInOrderOnReattach()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+
+ var firstControl = Subscribe(connection, out var firstHandler);
+ inner.Emit("visible");
+
+ connection.DataReceived -= firstHandler;
+ inner.Emit("hidden-1");
+ inner.Emit("hidden-2");
+ Assert.Equal(["visible"], firstControl);
+
+ var secondControl = Subscribe(connection, out _);
+ Dispatcher.UIThread.RunJobs();
+ Assert.Equal(["hidden-1", "hidden-2"], secondControl);
+
+ // The detached control must not receive anything it missed.
+ Assert.Equal(["visible"], firstControl);
+ }
+
+ [Fact]
+ public void KeepsLiveOutputBehindAPendingReplay()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+
+ inner.Emit("buffered");
+ var received = Subscribe(connection, out _);
+ inner.Emit("live");
+
+ Dispatcher.UIThread.RunJobs();
+ Assert.Equal(["buffered", "live"], received);
+ }
+
+ [Fact]
+ public void DoesNotReplayToASecondSubscriberOfAlreadyDeliveredOutput()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+ Subscribe(connection, out _);
+ inner.Emit("delivered");
+
+ var second = Subscribe(connection, out _);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Empty(second);
+ }
+
+ [Fact]
+ public void DropsTheOldestOutputWhenTheBufferOverflows()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+
+ inner.Emit("oldest");
+ for (var i = 0; i < 3; i++) inner.Emit(new string('x', 512 * 1024));
+
+ var received = Subscribe(connection, out _);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.DoesNotContain("oldest", received);
+ Assert.NotEmpty(received);
+ }
+
+ [Fact]
+ public void StopsDeliveringAfterDispose()
+ {
+ var inner = new FakeConnection();
+ var connection = new ReplayBufferedConnection(inner);
+ var received = Subscribe(connection, out _);
+
+ connection.Dispose();
+ inner.Emit("after-dispose");
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Empty(received);
+ Assert.Empty(inner.DataReceivedSubscribers);
+ }
+
+ [Fact]
+ public void ForwardsTheConnectionApiToTheInnerConnection()
+ {
+ var inner = new FakeConnection();
+ using var connection = new ReplayBufferedConnection(inner);
+
+ connection.SendData([1, 2, 3]);
+ connection.SetTerminalWindowSize(120, 40);
+ var closed = false;
+ connection.Closed += (_, _) => closed = true;
+ connection.Disconnect();
+
+ Assert.True(connection.IsConnected);
+ Assert.Equal([1, 2, 3], Assert.Single(inner.Sent));
+ Assert.Equal((120, 40), inner.WindowSize);
+ Assert.True(closed);
+ }
+
+ private static List Subscribe(ReplayBufferedConnection connection,
+ out EventHandler handler)
+ {
+ var received = new List();
+ handler = (_, e) => received.Add(Encoding.UTF8.GetString(e.Data));
+ connection.DataReceived += handler;
+ return received;
+ }
+
+ private sealed class FakeConnection : IConnection
+ {
+ public List Sent { get; } = [];
+ public (int Columns, int Rows) WindowSize { get; private set; }
+ public bool IsConnected => true;
+
+ public List DataReceivedSubscribers =>
+ DataReceived?.GetInvocationList().ToList() ?? [];
+
+ public event EventHandler? DataReceived;
+ public event EventHandler? Closed;
+
+ public bool Connect()
+ {
+ return true;
+ }
+
+ public void Disconnect()
+ {
+ Closed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void SendData(byte[] data)
+ {
+ Sent.Add(data);
+ }
+
+ public void SetTerminalWindowSize(int columns, int rows)
+ {
+ WindowSize = (columns, rows);
+ }
+
+ public void Emit(string text)
+ {
+ DataReceived?.Invoke(this, new DataReceivedEventArgs { Data = Encoding.UTF8.GetBytes(text) });
+ }
+ }
+}