diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs new file mode 100644 index 000000000..7c5796286 --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageUserInputRequestViewModel.cs @@ -0,0 +1,45 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using OneWare.Essentials.Models; + +namespace OneWare.Chat.ViewModels.ChatMessages; + +public class ChatMessageUserInputRequestViewModel : ObservableObject, IChatMessage +{ + public ChatMessageUserInputRequestViewModel(ChatUserInputRequestEvent inputRequestEvent) + { + Event = inputRequestEvent; + } + + public ChatUserInputRequestEvent Event { get; } + + public string FreeformText + { + get; + set => SetProperty(ref field, value); + } = string.Empty; + + public bool IsAnswered + { + get; + private set => SetProperty(ref field, value); + } + + public string? AnswerText + { + get; + private set => SetProperty(ref field, value); + } + + public void SelectChoice(string? choice) => Submit(choice ?? string.Empty); + + public void SubmitFreeform() => Submit(FreeformText.Trim()); + + private void Submit(string answer) + { + if (IsAnswered || string.IsNullOrEmpty(answer)) return; + + IsAnswered = true; + AnswerText = answer; + Event.SubmitCommand.Execute(answer); + } +} diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index acdb1c97c..521524b0d 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -32,8 +32,16 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService private readonly Dictionary> _historyByService = new(StringComparer.Ordinal); private bool _initialized; - // Counts user messages sent locally so ChatUserMessageEvent duplicates are suppressed - private int _pendingLocalUserMessages; + // FIFO of messages sent locally so the echoed ChatUserMessageEvent can be matched + // (suppressed for normal/steered sends, or used to activate a queued message). + private readonly Queue _pendingLocalMessages = new(); + + private const string DefaultWorkingStatus = "Working…"; + + private sealed record PendingLocalMessage( + string Content, + ChatSendMode Mode, + ChatMessageUserViewModel? QueuedView); private static readonly JsonSerializerOptions ChatStateSerializerOptions = new() { @@ -60,7 +68,9 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma AiFileEditService = aiFileEditService; NewChatCommand = new AsyncRelayCommand(NewChatAsync); - SendCommand = new AsyncRelayCommand(SendAsync, CanSend); + SendCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Send), CanSend); + SteerCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Steer), CanSteerOrQueue); + QueueCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Queue), CanSteerOrQueue); AbortCommand = new AsyncRelayCommand(AbortAsync, CanAbort); InitializeCurrentCommand = new AsyncRelayCommand(InitializeCurrentAsync); @@ -78,6 +88,8 @@ public string CurrentMessage if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); } } } = string.Empty; @@ -90,6 +102,8 @@ public bool IsBusy if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged(); } } @@ -109,6 +123,8 @@ public bool IsConnected if (SetProperty(ref field, value)) { SendCommand.NotifyCanExecuteChanged(); + SteerCommand.NotifyCanExecuteChanged(); + QueueCommand.NotifyCanExecuteChanged(); AbortCommand.NotifyCanExecuteChanged(); } } @@ -122,6 +138,22 @@ public string StatusText public ObservableCollection Messages { get; set; } = new(); + /// + /// Messages the user has queued while the agent is busy. Rendered (dimmed) below the + /// conversation until the agent activates them. + /// + public ObservableCollection QueuedMessages { get; } = new(); + + /// + /// Text shown next to the working spinner. Switches to "Steering…" while a steered message + /// is being applied to the current turn. + /// + public string WorkingStatusText + { + get; + set => SetProperty(ref field, value); + } = DefaultWorkingStatus; + public ObservableCollection ChatServices { get; } = []; public ObservableCollection SessionHistory { get; } = []; @@ -172,6 +204,10 @@ public IChatService? SelectedChatService public AsyncRelayCommand SendCommand { get; } + public AsyncRelayCommand SteerCommand { get; } + + public AsyncRelayCommand QueueCommand { get; } + public AsyncRelayCommand AbortCommand { get; } public AsyncRelayCommand InitializeCurrentCommand { get; } @@ -231,11 +267,14 @@ private async Task NewChatAsync() } Messages.Clear(); + QueuedMessages.Clear(); + _pendingLocalMessages.Clear(); + WorkingStatusText = DefaultWorkingStatus; _assistantMessagesById.Clear(); _assistantReasoningById.Clear(); } - private async Task SendAsync() + private async Task SendInternalAsync(ChatSendMode mode) { var prompt = CurrentMessage.Trim(); if (string.IsNullOrWhiteSpace(prompt)) return; @@ -258,14 +297,31 @@ private async Task SendAsync() } var userMessage = new ChatMessageUserViewModel(prompt); - var assistantMessage = new ChatMessageAssistantViewModel("init") + ChatMessageAssistantViewModel? assistantMessage = null; + + switch (mode) { - IsStreaming = true - }; + case ChatSendMode.Queue: + // Park the message (dimmed) below the conversation until the agent activates it. + QueuedMessages.Add(userMessage); + _pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, userMessage)); + break; - AddMessage(userMessage); - AddMessage(assistantMessage); - _pendingLocalUserMessages++; + case ChatSendMode.Steer: + AddMessage(userMessage); + _pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, null)); + WorkingStatusText = "Steering…"; + break; + + default: + AddMessage(userMessage); + _pendingLocalMessages.Enqueue(new PendingLocalMessage(prompt, mode, null)); + // Only the initial (idle) send shows a placeholder; steered messages join the + // turn that is already streaming. + assistantMessage = new ChatMessageAssistantViewModel("init") { IsStreaming = true }; + AddMessage(assistantMessage); + break; + } CurrentMessage = string.Empty; IsBusy = true; @@ -274,24 +330,32 @@ private async Task SendAsync() try { - await SelectedChatService.SendAsync(prompt); + await SelectedChatService.SendAsync(prompt, mode); } catch (Exception ex) { - if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) + if (mode == ChatSendMode.Queue) + { + QueuedMessages.Remove(userMessage); + } + else if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) { Messages.Remove(initMessage); } - else + else if (assistantMessage != null) { Messages.Remove(assistantMessage); } AddErrorMessage(ex.Message); - IsBusy = false; + if (mode == ChatSendMode.Send) IsBusy = false; + if (mode == ChatSendMode.Steer) WorkingStatusText = DefaultWorkingStatus; } } + private bool CanSteerOrQueue() => + IsConnected && IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage); + private async Task AbortAsync() { if (SelectedChatService == null) return; @@ -306,10 +370,22 @@ private async Task AbortAsync() } } - private bool CanSend() => IsConnected && !IsBusy && !string.IsNullOrWhiteSpace(CurrentMessage); + private bool CanSend() => IsConnected && !string.IsNullOrWhiteSpace(CurrentMessage); private bool CanAbort() => IsConnected && IsBusy; + private bool TryDequeuePendingLocal(string content, out PendingLocalMessage pending) + { + if (_pendingLocalMessages.Count > 0 && _pendingLocalMessages.Peek().Content == content) + { + pending = _pendingLocalMessages.Dequeue(); + return true; + } + + pending = default!; + return false; + } + private void AddMessage(IChatMessage message) { if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage) @@ -383,6 +459,8 @@ private void FinishTurn() message.IsStreaming = false; IsBusy = false; + // Safety: never let the steering indicator stick past the end of a turn. + WorkingStatusText = DefaultWorkingStatus; }); } @@ -442,15 +520,30 @@ private void OnEventReceived(object? sender, ChatEvent e) } case ChatUserMessageEvent x: { - // If we sent this message locally it is already visible — skip. - // Otherwise it originates from a remote session user; show it and mark busy. - if (_pendingLocalUserMessages > 0) - { - _pendingLocalUserMessages--; - break; - } Dispatcher.UIThread.Post(() => { + if (TryDequeuePendingLocal(x.Content, out var pending)) + { + switch (pending.Mode) + { + case ChatSendMode.Queue when pending.QueuedView != null: + // The agent activated the queued message — promote it into the flow. + QueuedMessages.Remove(pending.QueuedView); + AddMessage(pending.QueuedView); + IsBusy = true; + ContentAdded?.Invoke(this, EventArgs.Empty); + break; + case ChatSendMode.Steer: + // Steering has been applied to the current turn. + WorkingStatusText = DefaultWorkingStatus; + break; + // Normal send: already visible, nothing to do. + } + + return; + } + + // Originates from a remote session user; show it and mark busy. AddMessage(new ChatMessageUserViewModel(x.Content)); IsBusy = true; ContentAdded?.Invoke(this, EventArgs.Empty); @@ -459,7 +552,11 @@ private void OnEventReceived(object? sender, ChatEvent e) } case ChatButtonEvent x: { - Dispatcher.UIThread.Post(() => { AddMessage(new ChatMessageWithButtonViewModel(x)); }); + Dispatcher.UIThread.Post(() => + { + AddMessage(new ChatMessageWithButtonViewModel(x)); + ContentAdded?.Invoke(this, EventArgs.Empty); + }); break; } case ChatPermissionRequestEvent x: @@ -469,12 +566,26 @@ private void OnEventReceived(object? sender, ChatEvent e) var msg = new ChatMessagePermissionRequestViewModel(x); msg.CloseAction = () => Messages.Remove(msg); AddMessage(msg); + ContentAdded?.Invoke(this, EventArgs.Empty); + }); + break; + } + case ChatUserInputRequestEvent x: + { + Dispatcher.UIThread.Post(() => + { + AddMessage(new ChatMessageUserInputRequestViewModel(x)); + ContentAdded?.Invoke(this, EventArgs.Empty); }); break; } case ChatErrorEvent x: { - Dispatcher.UIThread.Post(() => { AddErrorMessage(x.Message); }); + Dispatcher.UIThread.Post(() => + { + AddErrorMessage(x.Message); + ContentAdded?.Invoke(this, EventArgs.Empty); + }); break; } case ChatIdleEvent: diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml new file mode 100644 index 000000000..0f2c00657 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageUserInputRequestView.axaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + @@ -239,4 +289,4 @@ - + \ No newline at end of file diff --git a/src/OneWare.Chat/Views/ChatView.axaml.cs b/src/OneWare.Chat/Views/ChatView.axaml.cs index 3e2b4bfe5..8fc6e5afa 100644 --- a/src/OneWare.Chat/Views/ChatView.axaml.cs +++ b/src/OneWare.Chat/Views/ChatView.axaml.cs @@ -2,7 +2,9 @@ using System.Reactive.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; using OneWare.Chat.ViewModels; namespace OneWare.Chat.Views; @@ -14,6 +16,17 @@ public partial class ChatView : UserControl public ChatView() { InitializeComponent(); + + // Handle Enter shortcuts on the tunnel so they win over the TextBox's own newline handling. + CommandBox.AddHandler(KeyDownEvent, OnCommandBoxKeyDown, RoutingStrategies.Tunnel); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + // Scroll to the latest message when the view first becomes visible. + ScrollToEndDeferred(); } protected override void OnDataContextChanged(EventArgs e) @@ -24,14 +37,56 @@ protected override void OnDataContextChanged(EventArgs e) { _disposables.Dispose(); _disposables = new CompositeDisposable(); - - ScrollViewer.ScrollToEnd(); + + ScrollToEndDeferred(); Observable.FromEventPattern(chatViewModel, nameof(chatViewModel.ContentAdded)).Subscribe(x => { - ScrollViewer.ScrollToEnd(); + ScrollToEndDeferred(); }) .DisposeWith(_disposables); } } + + private void ScrollToEndDeferred() + { + // Defer so the scroll happens after the new content has been measured/arranged. + Dispatcher.UIThread.Post(() => ScrollViewer.ScrollToEnd(), DispatcherPriority.Background); + } + + private void OnCommandBoxKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key is not (Key.Enter or Key.Return)) return; + if (DataContext is not ChatViewModel vm) return; + + var modifiers = e.KeyModifiers; + + // Shift+Enter inserts a newline — let the TextBox handle it. + if (modifiers.HasFlag(KeyModifiers.Shift)) return; + + // Ctrl+Enter steers, Alt+Enter queues (both only while the agent is busy). + if (modifiers.HasFlag(KeyModifiers.Control)) + { + Execute(vm.SteerCommand, e); + return; + } + + if (modifiers.HasFlag(KeyModifiers.Alt)) + { + Execute(vm.QueueCommand, e); + return; + } + + // Plain Enter: steer while busy, otherwise start a new turn. + Execute(vm.IsBusy ? vm.SteerCommand : vm.SendCommand, e); + } + + private static void Execute(System.Windows.Input.ICommand command, KeyEventArgs e) + { + e.Handled = true; + if (command.CanExecute(null)) + { + command.Execute(null); + } + } } \ No newline at end of file diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index b5c13bed5..ec874e155 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -13,6 +13,7 @@ public class CopilotModule : OneWareModuleBase public const string CopilotCliSettingKey = "AI_Chat_Copilot_CLI"; public const string CopilotSelectedModelSettingKey = "AI_Chat_Copilot_SelectedModel"; + public const string CopilotSelectedReasoningEffortSettingKey = "AI_Chat_Copilot_SelectedReasoningEffort"; public const string CopilotAutopilotSettingKey = "AI_Chat_Copilot_Autopilot"; public static readonly Package CopilotPackage = new() @@ -175,6 +176,8 @@ public override void Initialize(IServiceProvider serviceProvider) serviceProvider.Resolve().Register(CopilotSelectedModelSettingKey, "gpt-5-mini"); + serviceProvider.Resolve().Register(CopilotSelectedReasoningEffortSettingKey, ""); + serviceProvider.Resolve() .RegisterChatService(serviceProvider.Resolve()); } diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj index d0d30c4e4..0f270a8e7 100644 --- a/src/OneWare.Copilot/OneWare.Copilot.csproj +++ b/src/OneWare.Copilot/OneWare.Copilot.csproj @@ -14,9 +14,5 @@ - - - - \ No newline at end of file diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index c96810447..44effc942 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -1,9 +1,11 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; +using Avalonia; using Avalonia.Controls; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -19,6 +21,7 @@ using OneWare.Essentials.Helpers; using OneWare.Essentials.Models; using OneWare.Essentials.Services; +using OneWare.Essentials.ViewModels; namespace OneWare.Copilot.Services; @@ -28,6 +31,7 @@ public sealed class CopilotChatService( IPackageService packageService, IPackageWindowService packageWindowService, IWindowService windowService, + IMainDockService mainDockService, IPaths paths) : ObservableObject, IChatServiceWithSessions { @@ -36,6 +40,8 @@ public sealed class CopilotChatService( private CopilotSession? _session; private IDisposable? _subscription; private string? _requestedSessionId; + private readonly List> _pendingInputRequests = new(); + private readonly HashSet _sessionApprovedTools = new(); // Usage tracking public long LastInputTokens @@ -138,16 +144,97 @@ public ModelInfo? SelectedModel if (SetProperty(ref field, value) && value != null) { settingsService.SetSettingValue(CopilotModule.CopilotSelectedModelSettingKey, value.Id); + RefreshReasoningEfforts(value); if (oldValue != null && oldValue.Id != value.Id) { - // When a model is changed we reset the session and force a new one on next init - SessionReset?.Invoke(this, EventArgs.Empty); - _ = NewChatAsync(); + // Switch the model in place for the next message, preserving conversation history. + ApplyModelToSession(); } } } } + public ObservableCollection ReasoningEfforts { get; } = []; + + public bool ShowReasoningEffort + { + get; + private set => SetProperty(ref field, value); + } + + public string? SelectedReasoningEffort + { + get; + set + { + if (SetProperty(ref field, value) && value != null && !_suppressReasoningEffortApply) + { + settingsService.SetSettingValue(CopilotModule.CopilotSelectedReasoningEffortSettingKey, value); + ApplyModelToSession(); + } + } + } + + private bool _suppressReasoningEffortApply; + + private void RefreshReasoningEfforts(ModelInfo model) + { + var supported = model.Capabilities.Supports.ReasoningEffort + ? model.SupportedReasoningEfforts ?? [] + : []; + + _suppressReasoningEffortApply = true; + try + { + ReasoningEfforts.Clear(); + foreach (var effort in supported) ReasoningEfforts.Add(effort); + + ShowReasoningEffort = ReasoningEfforts.Count > 0; + + if (!ShowReasoningEffort) + { + SelectedReasoningEffort = null; + return; + } + + var persisted = + settingsService.GetSettingValue(CopilotModule.CopilotSelectedReasoningEffortSettingKey); + + SelectedReasoningEffort = + !string.IsNullOrEmpty(persisted) && ReasoningEfforts.Contains(persisted) + ? persisted + : model.DefaultReasoningEffort is { } def && ReasoningEfforts.Contains(def) + ? def + : ReasoningEfforts.FirstOrDefault(); + } + finally + { + _suppressReasoningEffortApply = false; + } + } + + private void ApplyModelToSession() + { + var session = _session; + var model = SelectedModel; + if (session == null || model == null) return; + + var effort = ShowReasoningEffort ? SelectedReasoningEffort : null; + + _ = Task.Run(async () => + { + try + { + await session.SetModelAsync(model.Id, effort); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + .LogError(ex, "Failed to switch Copilot model to {Model}.", model.Id); + } + }); + } + public string Name { get; } = "Copilot"; public string? CurrentSessionId @@ -161,6 +248,163 @@ public string? CurrentSessionId DataContext = this }; + public Control TopUiExtension + { + get + { + EnsureAttachmentTracking(); + return new CopilotChatAttachmentsView { DataContext = this }; + } + } + + #region Attachments + + private bool _attachmentTrackingInitialized; + private bool _activeFileDismissed; + private IEditor? _trackedEditor; + + /// Files the user explicitly attached for the next message. + public ObservableCollection Attachments { get; } = new(); + + /// The implicit, auto-tracked chip for the currently focused editor file. + public CopilotAttachmentViewModel? ActiveFileAttachment + { + get; + private set => SetProperty(ref field, value); + } + + private void EnsureAttachmentTracking() + { + if (_attachmentTrackingInitialized) return; + _attachmentTrackingInitialized = true; + + mainDockService.PropertyChanged += OnDockServicePropertyChanged; + RefreshActiveFileAttachment(focusChanged: true); + } + + private void OnDockServicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(IMainDockService.CurrentDocument)) + { + RefreshActiveFileAttachment(focusChanged: true); + } + } + + private void OnEditorSelectionChanged(object? sender, EventArgs e) => + RefreshActiveFileAttachment(focusChanged: false); + + private void RefreshActiveFileAttachment(bool focusChanged) + { + var editor = mainDockService.CurrentDocument as IEditor; + + if (!ReferenceEquals(editor, _trackedEditor)) + { + if (_trackedEditor != null) + _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + + _trackedEditor = editor; + + if (_trackedEditor != null) + _trackedEditor.Editor.TextArea.SelectionChanged += OnEditorSelectionChanged; + + // Moving focus to a different file revives a previously dismissed active-file chip. + if (focusChanged) _activeFileDismissed = false; + } + + ActiveFileAttachment = _activeFileDismissed ? null : BuildActiveFileAttachment(editor); + } + + private CopilotAttachmentViewModel? BuildActiveFileAttachment(IEditor? editor) + { + if (editor == null || string.IsNullOrEmpty(editor.FullPath)) return null; + + var name = Path.GetFileName(editor.FullPath); + var selection = TryGetSelection(editor, out var selectionText); + + return new CopilotAttachmentViewModel( + editor.FullPath, + name, + isActiveFile: true, + RemoveAttachment, + selection, + selectionText); + } + + private static CopilotAttachmentViewModel.SelectionRange? TryGetSelection(IEditor editor, out string? selectionText) + { + selectionText = null; + + var textArea = editor.Editor.TextArea; + if (textArea.Selection.IsEmpty) return null; + + var start = textArea.Selection.StartPosition; + var end = textArea.Selection.EndPosition; + + // Normalize so Start precedes End regardless of drag direction. + if (end.Line < start.Line || (end.Line == start.Line && end.Column < start.Column)) + (start, end) = (end, start); + + selectionText = editor.Editor.SelectedText; + + return new CopilotAttachmentViewModel.SelectionRange( + start.Line, start.Column, end.Line, end.Column); + } + + private void RemoveAttachment(CopilotAttachmentViewModel attachment) + { + if (attachment.IsActiveFile) + { + _activeFileDismissed = true; + ActiveFileAttachment = null; + } + else + { + Attachments.Remove(attachment); + } + } + + public IAsyncRelayCommand AddAttachmentCommand => field ??= new AsyncRelayCommand(AddAttachmentAsync); + + private async Task AddAttachmentAsync(Visual? source) + { + var owner = source != null ? TopLevel.GetTopLevel(source) : null; + if (owner == null) return; + + var files = await StorageProviderHelper.SelectFilesAsync(owner, "Add Attachment", null); + + foreach (var file in files) + { + if (string.IsNullOrEmpty(file)) continue; + if (Attachments.Any(a => string.Equals(a.FilePath, file, StringComparison.OrdinalIgnoreCase))) continue; + + Attachments.Add(new CopilotAttachmentViewModel( + file, Path.GetFileName(file), isActiveFile: false, RemoveAttachment)); + } + } + + private IList? CollectAttachments() + { + // Rebuild the active-file chip from the live editor so the sent payload reflects the + // current selection even if the chip display lagged. + RefreshActiveFileAttachment(focusChanged: false); + + var result = new List(); + if (ActiveFileAttachment != null) result.Add(ActiveFileAttachment.ToSdkAttachment()); + result.AddRange(Attachments.Select(a => a.ToSdkAttachment())); + + return result.Count > 0 ? result : null; + } + + private void ClearAttachmentsAfterSend() + { + Attachments.Clear(); + _activeFileDismissed = false; + RefreshActiveFileAttachment(focusChanged: false); + } + + #endregion + + public event EventHandler? SessionReset; public event EventHandler? EventReceived; public event EventHandler? StatusChanged; @@ -370,6 +614,7 @@ private async Task InitializeSessionAsync() var sessionConfig = new SessionConfig { Model = SelectedModel.Id, + ReasoningEffort = ShowReasoningEffort ? SelectedReasoningEffort : null, Streaming = true, // Only stream root-agent deltas; the chat UI does not differentiate sub-agents. IncludeSubAgentStreamingEvents = false, @@ -509,7 +754,9 @@ private SystemMessageConfig BuildSystemMessageConfig() }; } - public async Task SendAsync(string prompt) + public Task SendAsync(string prompt) => SendAsync(prompt, ChatSendMode.Send); + + public async Task SendAsync(string prompt, ChatSendMode mode) { if (SelectedModel == null) { @@ -524,17 +771,36 @@ public async Task SendAsync(string prompt) } if (_session == null) return; - await _session.SendAsync(new MessageOptions { Prompt = prompt }).ConfigureAwait(false); + + var options = new MessageOptions { Prompt = prompt }; + var attachments = CollectAttachments(); + if (attachments != null) options.Attachments = attachments; + + var sdkMode = mode switch + { + ChatSendMode.Steer => "immediate", + ChatSendMode.Queue => "enqueue", + _ => null + }; + if (sdkMode != null) options.Mode = sdkMode; + + await _session.SendAsync(options).ConfigureAwait(false); + + Dispatcher.UIThread.Post(ClearAttachmentsAfterSend); } public async Task AbortAsync() { + ReleasePendingInputRequests(); if (_session == null) return; await _session.AbortAsync(); } public async Task NewChatAsync() { + ReleasePendingInputRequests(); + lock (_sessionApprovedTools) + _sessionApprovedTools.Clear(); _requestedSessionId = null; await InitializeSessionAsync(); } @@ -560,6 +826,16 @@ public async Task LoadSessionAsync(string sessionId) public async ValueTask DisposeAsync() { + ReleasePendingInputRequests(); + + if (_attachmentTrackingInitialized) + { + mainDockService.PropertyChanged -= OnDockServicePropertyChanged; + if (_trackedEditor != null) + _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + _trackedEditor = null; + } + try { await DisposeSessionAsync(); @@ -740,6 +1016,13 @@ public async Task DisableRemoteSessionAsync() if (settingsService.GetSettingValue(CopilotModule.CopilotAutopilotSettingKey)) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + // The user approved this tool for the rest of the session. + lock (_sessionApprovedTools) + { + if (_sessionApprovedTools.Contains(input.ToolName)) + return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + } + var check = toolProvider.GetConfirmationCheck(input.ToolName); if (check == null) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); @@ -776,7 +1059,16 @@ private Task OnPermissionRequestAsync( if (request is PermissionRequestCustomTool) return Task.FromResult(PermissionDecision.ApproveOnce()); - + + var approvalKey = GetSessionApprovalKey(request); + + // Already approved for this session — don't prompt again. + lock (_sessionApprovedTools) + { + if (_sessionApprovedTools.Contains(approvalKey)) + return Task.FromResult(PermissionDecision.ApproveOnce()); + } + var message = BuildPermissionMessage(request); var responseSource = new TaskCompletionSource( @@ -788,6 +1080,8 @@ private Task OnPermissionRequestAsync( new RelayCommand(_ => responseSource.TrySetResult(PermissionDecision.Reject("Denied by User"))); var allowForSessionCmd = new RelayCommand(_ => { + lock (_sessionApprovedTools) + _sessionApprovedTools.Add(approvalKey); responseSource.TrySetResult(new PermissionDecisionApproveForSession()); }); @@ -797,6 +1091,18 @@ private Task OnPermissionRequestAsync( return responseSource.Task; } + private static string GetSessionApprovalKey(PermissionRequest request) => request switch + { + PermissionRequestHook hook => hook.ToolName, + PermissionRequestShell => "shell", + PermissionRequestWrite => "write", + PermissionRequestRead => "read", + PermissionRequestUrl => "url", + PermissionRequestMcp mcp => $"mcp:{mcp.ServerName}/{mcp.ToolName}", + PermissionRequestMemory => "memory", + _ => request.Kind ?? "unknown" + }; + private static string BuildPermissionMessage(PermissionRequest request) => request switch { PermissionRequestHook { HookMessage: { Length: > 0 } msg } => msg, @@ -823,14 +1129,51 @@ private Task OnUserInputRequestAsync( UserInputRequest request, UserInputInvocation invocation) { - var message = $"Copilot requested user input: {request.Question}"; - EventReceived?.Invoke(this, new ChatMessageEvent(message)); + var choices = request.Choices?.ToList() ?? new List(); + var allowFreeform = request.AllowFreeform ?? true; + if (choices.Count == 0) allowFreeform = true; + + var responseSource = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + lock (_pendingInputRequests) + _pendingInputRequests.Add(responseSource); - return Task.FromResult(new UserInputResponse + var submitCommand = new RelayCommand(answer => { - Answer = string.Empty, - WasFreeform = true + var text = answer ?? string.Empty; + var response = new UserInputResponse + { + Answer = text, + WasFreeform = !choices.Contains(text) + }; + + if (responseSource.TrySetResult(response)) + lock (_pendingInputRequests) + _pendingInputRequests.Remove(responseSource); }); + + EventReceived?.Invoke(this, new ChatUserInputRequestEvent( + request.Question, choices, allowFreeform, submitCommand)); + + return responseSource.Task; + } + + /// + /// Releases any unanswered user-input requests with an empty answer so the agent callback can + /// complete instead of hanging (e.g. on abort, new chat, or dispose). + /// + private void ReleasePendingInputRequests() + { + List> pending; + lock (_pendingInputRequests) + { + pending = new List>(_pendingInputRequests); + _pendingInputRequests.Clear(); + } + + foreach (var source in pending) + source.TrySetResult(new UserInputResponse { Answer = string.Empty, WasFreeform = true }); } private async Task RunCopilotLoginAsync(string cliPath, CopilotDeviceLoginViewModel viewModel, diff --git a/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs new file mode 100644 index 000000000..f8471018a --- /dev/null +++ b/src/OneWare.Copilot/ViewModels/CopilotAttachmentViewModel.cs @@ -0,0 +1,80 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GitHub.Copilot; + +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. +/// +public sealed class CopilotAttachmentViewModel : ObservableObject +{ + private readonly string? _selectionText; + private readonly SelectionRange? _selection; + + public string FilePath { get; } + + public string DisplayName { get; } + + /// Short line-range hint shown on the chip, e.g. "L10-24". Null for whole-file attachments. + public string? Detail { get; } + + /// True for the implicit, auto-tracked focused-file chip. + public bool IsActiveFile { get; } + + public string IconResourceKey { get; } + + public IRelayCommand RemoveCommand { get; } + + public CopilotAttachmentViewModel( + string filePath, + string displayName, + bool isActiveFile, + Action onRemove, + SelectionRange? selection = null, + string? selectionText = null, + string iconResourceKey = "VsImageLib.File16X") + { + FilePath = filePath; + DisplayName = displayName; + IsActiveFile = isActiveFile; + IconResourceKey = iconResourceKey; + _selection = selection; + _selectionText = selectionText; + + if (selection is { } s) + { + Detail = s.StartLine == s.EndLine ? $"{s.StartLine}" : $"{s.StartLine}-{s.EndLine}"; + } + + RemoveCommand = new RelayCommand(() => onRemove(this)); + } + + public Attachment ToSdkAttachment() + { + if (_selection is { } s && _selectionText is not null) + { + return new AttachmentSelection + { + FilePath = FilePath, + DisplayName = DisplayName, + Text = _selectionText, + Selection = new AttachmentSelectionDetails + { + Start = new AttachmentSelectionDetailsStart { Line = s.StartLine, Character = s.StartColumn }, + End = new AttachmentSelectionDetailsEnd { Line = s.EndLine, Character = s.EndColumn } + } + }; + } + + return new AttachmentFile + { + Path = FilePath, + DisplayName = DisplayName + }; + } + + 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 new file mode 100644 index 000000000..3a596dd3a --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs new file mode 100644 index 000000000..68292cf9c --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotChatAttachmentsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace OneWare.Copilot.Views; + +public partial class CopilotChatAttachmentsView : UserControl +{ + public CopilotChatAttachmentsView() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml index b51f1ceab..33b05194c 100644 --- a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml @@ -61,7 +61,42 @@ - + +