From c55907a842dc47ee3583f5cf6f79dc22fbb34c86 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 6 Aug 2026 14:29:17 +0200 Subject: [PATCH 1/4] . --- docs/PluginDevelopment.md | 9 +- .../Services/AiBuiltInFunctions.cs | 21 +- .../Provider/PseudoTerminalConnection.cs | 17 + .../Provider/UserInputEchoFilter.cs | 8 + src/OneWare.Terminal/ShellIntegration.cs | 34 +- .../ViewModels/TerminalViewModel.cs | 20 +- .../ViewModels/TerminalManagerViewModel.cs | 351 +++++++++++++----- 7 files changed, 361 insertions(+), 99 deletions(-) diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index c7da1c91..de7950bc 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 f0faa5c5..e3a7ddbd 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.Terminal/Provider/PseudoTerminalConnection.cs b/src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs index 9d2a2817..5e551cd4 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/UserInputEchoFilter.cs b/src/OneWare.Terminal/Provider/UserInputEchoFilter.cs index ebf1b154..f4cce9d1 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 16413da4..b33379da 100644 --- a/src/OneWare.Terminal/ShellIntegration.cs +++ b/src/OneWare.Terminal/ShellIntegration.cs @@ -175,23 +175,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 d9b6bdfc..76f4c877 100644 --- a/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs +++ b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs @@ -79,7 +79,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,6 +117,10 @@ 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; } @@ -128,6 +140,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)); + } } } diff --git a/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs b/src/OneWare.TerminalManager/ViewModels/TerminalManagerViewModel.cs index ed9de92d..bd80828a 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); } From 1307112801a7234477e03f332f3dfcf648e97c3d Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 6 Aug 2026 15:56:29 +0200 Subject: [PATCH 2/4] progress --- OneWare.slnx | 1 + .../Provider/ReplayBufferedConnection.cs | 154 +++++++++++++++ .../ViewModels/TerminalViewModel.cs | 15 ++ src/OneWare.Terminal/Views/TerminalView.axaml | 4 +- .../OneWare.Terminal.UnitTests.csproj | 20 ++ .../ReplayBufferedConnectionTests.cs | 183 ++++++++++++++++++ 6 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs create mode 100644 tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj create mode 100644 tests/OneWare.Terminal.UnitTests/ReplayBufferedConnectionTests.cs diff --git a/OneWare.slnx b/OneWare.slnx index 0a5ccca6..c6e8e45e 100644 --- a/OneWare.slnx +++ b/OneWare.slnx @@ -133,6 +133,7 @@ + diff --git a/src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs b/src/OneWare.Terminal/Provider/ReplayBufferedConnection.cs new file mode 100644 index 00000000..b01dd988 --- /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/ViewModels/TerminalViewModel.cs b/src/OneWare.Terminal/ViewModels/TerminalViewModel.cs index 76f4c877..a6ad2459 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 { @@ -126,6 +137,7 @@ public void CreateConnection() Connection = new PseudoTerminalConnection(terminal); Connection.Closed += OnConnectionClosed; + ViewConnection = new ReplayBufferedConnection(Connection); Terminal = new VirtualTerminalController(); @@ -176,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 274224e5..d1ba9c62 100644 --- a/src/OneWare.Terminal/Views/TerminalView.axaml +++ b/src/OneWare.Terminal/Views/TerminalView.axaml @@ -20,8 +20,8 @@ - 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 00000000..03c276c6 --- /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 00000000..89054200 --- /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) }); + } + } +} From a2065ec99f6d8cc2923ba66aa7af834eef2b34d3 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 6 Aug 2026 16:34:27 +0200 Subject: [PATCH 3/4] fix shellintegration --- src/OneWare.Terminal/ShellIntegration.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/OneWare.Terminal/ShellIntegration.cs b/src/OneWare.Terminal/ShellIntegration.cs index b33379da..cd105541 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(); From dc0bd85f029976a73e4c9247bead17cb1ca0fee7 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 6 Aug 2026 17:02:21 +0200 Subject: [PATCH 4/4] fix installer sing issue + add copilot image attachment --- src/OneWare.Chat/Views/ChatView.axaml.cs | 84 ++++++++++++++++++- .../Services/CopilotChatService.cs | 10 +++ .../ViewModels/CopilotAttachmentViewModel.cs | 59 ++++++++++++- .../Views/CopilotChatAttachmentsView.axaml | 9 +- .../Services/IChatService.cs | 7 ++ ...re.Studio.Desktop.WindowsInstaller.wixproj | 4 +- .../SignInstaller.ps1 | 2 +- 7 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/OneWare.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs index 8fc6e5af..026e64f9 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 3be1b6e1..4f0228ce 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 f8471018..1acc8366 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 3a596dd3..eb2923e4 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/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj b/studio/OneWare.Studio.Desktop.WindowsInstaller/OneWare.Studio.Desktop.WindowsInstaller.wixproj index 1c4a3f3f..12febf6f 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 1f5fadac..2d007b51 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