Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OneWare.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
<Project Path="tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj" />
<Project Path="tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj" />
<Project Path="tests/OneWare.Studio.Desktop.UnitTests/OneWare.Studio.Desktop.UnitTests.csproj" />
<Project Path="tests/OneWare.Terminal.UnitTests/OneWare.Terminal.UnitTests.csproj" />
<Project Path="tests/OneWare.TestPlugin/OneWare.TestPlugin.csproj" />
<Project Path="tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj" />
<Project Path="tests/OneWare.UniversalFpgaProjectSystem.Tests/OneWare.UniversalFpgaProjectSystem.Tests.csproj" />
Expand Down
9 changes: 8 additions & 1 deletion docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>`
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)

Expand Down
21 changes: 15 additions & 6 deletions src/OneWare.Chat/Services/AiBuiltInFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).")]
Expand Down Expand Up @@ -281,10 +282,11 @@ private static async Task<object> 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));
Expand All @@ -293,7 +295,14 @@ private static async Task<object> 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
};
}

Expand Down
84 changes: 83 additions & 1 deletion src/OneWare.Chat/Views/ChatView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Avalonia.Interactivity;
using Avalonia.Threading;
using OneWare.Chat.ViewModels;
using OneWare.Essentials.Services;

namespace OneWare.Chat.Views;

Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/OneWare.Copilot/Services/CopilotChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ private void RemoveAttachment(CopilotAttachmentViewModel attachment)
{
Attachments.Remove(attachment);
}
attachment.Dispose();
}

public IAsyncRelayCommand<Visual?> AddAttachmentCommand => field ??= new AsyncRelayCommand<Visual?>(AddAttachmentAsync);
Expand Down Expand Up @@ -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


Expand Down
59 changes: 56 additions & 3 deletions src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GitHub.Copilot;
Expand All @@ -6,13 +7,15 @@ namespace OneWare.Copilot.ViewModels;

/// <summary>
/// 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
/// <see cref="Attachment"/> 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 <see cref="Attachment"/> when a message is sent.
/// </summary>
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; }

Expand All @@ -26,6 +29,12 @@ public sealed class CopilotAttachmentViewModel : ObservableObject

public string IconResourceKey { get; }

/// <summary>True when this attachment wraps inline image data rather than a file path.</summary>
public bool IsImage { get; }

/// <summary>Small decoded thumbnail for image attachments; null for file/selection attachments.</summary>
public Bitmap? ThumbnailBitmap { get; }

public IRelayCommand RemoveCommand { get; }

public CopilotAttachmentViewModel(
Expand All @@ -52,8 +61,47 @@ public CopilotAttachmentViewModel(
RemoveCommand = new RelayCommand(() => onRemove(this));
}

/// <summary>Creates an image-blob attachment from raw bytes (e.g. a clipboard paste).</summary>
public CopilotAttachmentViewModel(
byte[] imageData,
string mimeType,
string displayName,
bool isActiveFile,
Action<CopilotAttachmentViewModel> 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
Expand All @@ -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);
}
9 changes: 8 additions & 1 deletion src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@
<Border Background="{DynamicResource ThemeControlLowBrush}" CornerRadius="3"
Padding="4 1" Margin="0 0 4 0" ToolTip.Tip="{Binding FilePath}">
<StackPanel Orientation="Horizontal" Spacing="3" VerticalAlignment="Center">
<!-- Thumbnail for image attachments -->
<Image Source="{Binding ThumbnailBitmap}" Width="16" Height="16"
VerticalAlignment="Center"
IsVisible="{Binding IsImage}"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<!-- File icon for non-image attachments -->
<Image Source="{DynamicResource VsImageLib.File16X}" Width="12" Height="12"
VerticalAlignment="Center" />
VerticalAlignment="Center"
IsVisible="{Binding !IsImage}" />
<TextBlock Text="{Binding DisplayName}" FontSize="11" VerticalAlignment="Center" />
<TextBlock Text="{Binding Detail}" FontSize="11" FontStyle="Italic"
VerticalAlignment="Center"
Expand Down
7 changes: 7 additions & 0 deletions src/OneWare.Essentials/Services/IChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ public interface IChatService : INotifyPropertyChanged, IAsyncDisposable
/// </summary>
Task<bool> ClearQueuedMessagesAsync() => Task.FromResult(false);

/// <summary>
/// Attaches raw image data (e.g. from a clipboard paste) to the next message.
/// Returns <see langword="true"/> if the service accepted the image; <see langword="false"/>
/// if image attachments are not supported and the caller should fall back to text paste.
/// </summary>
bool TryAddImageAttachment(byte[] data, string mimeType, string displayName) => false;

/// <summary>
/// Starts a new chat session.
/// </summary>
Expand Down
17 changes: 17 additions & 0 deletions src/OneWare.Terminal/Provider/PseudoTerminalConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ public class PseudoTerminalConnection(IPseudoTerminal terminal) : IConnection, I
/// </summary>
public int? ProcessExitCode { get; private set; }

/// <summary>
/// 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.
/// </summary>
public bool ShellIntegrationDetected { get; private set; }

/// <summary>
/// Set by consumers after they waited for <see cref="ShellIntegrationDetected"/> in vain,
/// so every following command on the same shell skips the probe delay and goes straight
/// to the marker-less fallback.
/// </summary>
public bool ShellIntegrationProbeFailed { get; set; }

public event EventHandler<DataReceivedEventArgs>? DataReceived;

public event EventHandler<EventArgs>? Closed;
Expand Down Expand Up @@ -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));
}
}
}
}
Expand Down
Loading
Loading