From 1e9d13a4590fa154994df8534fa36ad5116f11e6 Mon Sep 17 00:00:00 2001 From: Joe Farrell Date: Sat, 18 Apr 2026 14:28:46 -0400 Subject: [PATCH 1/3] Add tool window toolbar with session history (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a toolbar to the embedded Copilot CLI tool window with two buttons: - View Session History — VS-themed picker filtered to the current workspace that resumes a previous Copilot CLI session via `copilot --resume=`. Reads ~/.copilot/session-store.db in read-only mode (Microsoft.Data.Sqlite). - New Session — restarts the terminal with a fresh `copilot`. Buttons use KnownMonikers.History and KnownMonikers.NewItem via `IconIsMoniker` so they pick up the active VS theme. Both commands are disabled when no solution is open. Hardening: - Session ids passed to `--resume=` are validated against a strict UUID regex before being interpolated into the cmd.exe command line. - Workspace path matching is separator-aware in both SQL (`LIKE workspace + sep + '%'`) and C# to avoid sibling overmatch (`C:\repo` matching `C:\repo-old`). - `TerminalProcess.IsRunning` now returns false after the hosted CLI exits — fixes the Enter-to-restart UX. - `TerminalSessionService` exposes explicit `RestartFresh` / `RestartResuming` / `RestartPreservingMode` methods replacing the previous tri-state `RestartSession` parameters. Native `e_sqlite3.dll` (win-x64 + win-arm64) is bundled in the VSIX via a `BundleSqliteInVsix` MSBuild target running after the VSSDK strip step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 + Directory.Packages.props | 2 + src/CopilotCliIde/CopilotCliIde.csproj | 21 ++ src/CopilotCliIde/CopilotCliIdePackage.cs | 131 ++++++++++- src/CopilotCliIde/CopilotCliIdePackage.vsct | 42 ++++ src/CopilotCliIde/Polyfills.cs | 23 ++ src/CopilotCliIde/SessionId.cs | 16 ++ src/CopilotCliIde/SessionPickerDialog.cs | 217 ++++++++++++++++++ src/CopilotCliIde/SessionStore.cs | 158 +++++++++++++ src/CopilotCliIde/TerminalProcess.cs | 19 +- src/CopilotCliIde/TerminalSessionService.cs | 36 ++- src/CopilotCliIde/TerminalToolWindow.cs | 2 + .../TerminalToolWindowControl.cs | 2 +- 13 files changed, 663 insertions(+), 16 deletions(-) create mode 100644 src/CopilotCliIde/Polyfills.cs create mode 100644 src/CopilotCliIde/SessionId.cs create mode 100644 src/CopilotCliIde/SessionPickerDialog.cs create mode 100644 src/CopilotCliIde/SessionStore.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8245c5..7d7870e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Toolbar on the embedded Copilot CLI tool window** with two buttons (issue #9): + - **View Session History** — VS-themed picker (filtered to current workspace) that resumes a previous Copilot CLI session via `copilot --resume=`. Reads `~/.copilot/session-store.db` in read-only mode. + - **New Session** — restarts the terminal with a fresh `copilot` (no `--resume`). + - Buttons use `KnownMonikers.History` and `KnownMonikers.NewItem` (theme-aware via `IconIsMoniker`). + - Both commands are disabled when no solution is open. + ### Changed - Replace `Task.Delay(200)` server startup wait with stdout `READY` handshake — deterministic server readiness detection with 10s timeout +- `TerminalSessionService` exposes explicit `RestartFresh` / `RestartResuming` / `RestartPreservingMode` methods in place of the previous tri-state `RestartSession` parameters ### Fixed @@ -16,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Clean up orphaned diff views on solution switch — `CleanupAllDiffs()` runs before RPC teardown in `StopConnection()` - Trim SSE event historyto last-per-notification-type — prevents unbounded growth from rapid selection/diagnostics changes while preserving initial state for new SSE clients - Remove no-op assertions and duplicate tests in server test suite +- `TerminalProcess.IsRunning` now returns `false` after the hosted CLI exits (previously stayed `true` until the process was disposed, breaking Enter-to-restart UX) ## [1.0.18] - 2026-04-13 diff --git a/Directory.Packages.props b/Directory.Packages.props index 03c0b58..9ac0e07 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/src/CopilotCliIde/CopilotCliIde.csproj b/src/CopilotCliIde/CopilotCliIde.csproj index 6ac8497..ac99f2e 100644 --- a/src/CopilotCliIde/CopilotCliIde.csproj +++ b/src/CopilotCliIde/CopilotCliIde.csproj @@ -16,7 +16,9 @@ + + + + + + + + + + runtimes\win-x64\native + + + runtimes\win-arm64\native + + + + diff --git a/src/CopilotCliIde/CopilotCliIdePackage.cs b/src/CopilotCliIde/CopilotCliIdePackage.cs index b969a6a..2a8ec06 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.cs +++ b/src/CopilotCliIde/CopilotCliIdePackage.cs @@ -114,14 +114,22 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke _monitorSelection.AdviseSelectionEvents(new SelectionTracker.SelectionEventSink(_selectionTracker), out _selectionMonitorCookie); _selectionTracker.TrackActiveView(); - // Register Copilot CLI commands in the Tools menu + // Register Copilot CLI commands in the Tools menu and tool window toolbar if (await GetServiceAsync(typeof(IMenuCommandService)) is OleMenuCommandService commandService) { - var cmdId = new CommandID(new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"), 0x0100); - commandService.AddCommand(new MenuCommand(OnLaunchCopilotCli, cmdId)); + var cmdSetGuid = new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"); - var windowCmdId = new CommandID(new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"), 0x0200); - commandService.AddCommand(new MenuCommand(OnShowCopilotCliWindow, windowCmdId)); + commandService.AddCommand(new MenuCommand(OnLaunchCopilotCli, new CommandID(cmdSetGuid, 0x0100))); + commandService.AddCommand(new MenuCommand(OnShowCopilotCliWindow, new CommandID(cmdSetGuid, 0x0200))); + + // Toolbar buttons on the embedded terminal tool window + var viewHistoryCmd = new OleMenuCommand(OnViewSessionHistory, new CommandID(cmdSetGuid, 0x0300)); + viewHistoryCmd.BeforeQueryStatus += OnQueryWorkspaceCommandStatus; + commandService.AddCommand(viewHistoryCmd); + + var newSessionCmd = new OleMenuCommand(OnNewSession, new CommandID(cmdSetGuid, 0x0301)); + newSessionCmd.BeforeQueryStatus += OnQueryWorkspaceCommandStatus; + commandService.AddCommand(newSessionCmd); } // Create terminal session service (survives tool window hide/show) @@ -242,7 +250,8 @@ private void OnSolutionOpened() { await JoinableTaskFactory.SwitchToMainThreadAsync(); await StartConnectionAsync(); - _terminalSession?.RestartSession(GetWorkspaceFolder()); + // New solution context — drop any prior resume id so we start fresh. + _terminalSession?.RestartFresh(GetWorkspaceFolder()); } catch (Exception ex) { @@ -258,7 +267,7 @@ private void OnSolutionAfterClosing() try { await JoinableTaskFactory.SwitchToMainThreadAsync(); - _terminalSession?.RestartSession(GetWorkspaceFolder()); + _terminalSession?.RestartFresh(GetWorkspaceFolder()); StopConnection(); } catch (Exception ex) @@ -327,6 +336,114 @@ private void OnShowCopilotCliWindow(object sender, EventArgs e) private void OnDocumentSaved(EnvDTE.Document document) => _diagnosticTracker?.SchedulePush(); + private void OnQueryWorkspaceCommandStatus(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (sender is not OleMenuCommand cmd) + return; + // Both toolbar commands require an active terminal session service AND a real + // open solution. Without a solution, GetWorkspaceFolder() falls back to the + // process's CWD which is rarely meaningful for session matching. + cmd.Enabled = _terminalSession != null && IsSolutionOpen(); + } + + private static bool IsSolutionOpen() + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var dte = (EnvDTE80.DTE2)GetGlobalService(typeof(EnvDTE.DTE)); + return dte?.Solution != null && !string.IsNullOrEmpty(dte.Solution.FullName); + } + catch + { + return false; + } + } + + private void OnNewSession(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + if (_terminalSession == null) + return; + if (!ConfirmReplaceRunningSession("Start a new Copilot CLI session?")) + return; + + _terminalSession.RestartFresh(GetWorkspaceFolder()); + } + catch (Exception ex) + { + _logger?.Log($"OnNewSession failed: {ex.Message}"); + } + } + + private void OnViewSessionHistory(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + if (_terminalSession == null) + return; + + var workspace = GetWorkspaceFolder(); + var dialog = new SessionPickerDialog(new SessionStore(_logger), workspace); + var ok = dialog.ShowModal() == true; + if (!ok || dialog.SelectedSessionId == null) + return; + + if (!SessionId.IsValid(dialog.SelectedSessionId)) + { + _logger?.Log("OnViewSessionHistory: rejected invalid session id from picker"); + return; + } + + if (!ConfirmReplaceRunningSession("Resume the selected Copilot CLI session?")) + return; + + _terminalSession.RestartResuming(workspace, dialog.SelectedSessionId); + ShowToolWindowFireAndForget(); + } + catch (Exception ex) + { + _logger?.Log($"OnViewSessionHistory failed: {ex.Message}"); + } + } + + private bool ConfirmReplaceRunningSession(string question) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_terminalSession?.IsRunning != true) + return true; + + var result = VsShellUtilities.ShowMessageBox( + this, + $"The Copilot CLI is currently running. {question} Any unsent input will be discarded.", + "Copilot CLI", + Microsoft.VisualStudio.Shell.Interop.OLEMSGICON.OLEMSGICON_QUERY, + Microsoft.VisualStudio.Shell.Interop.OLEMSGBUTTON.OLEMSGBUTTON_OKCANCEL, + Microsoft.VisualStudio.Shell.Interop.OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + return result == 1; // IDOK + } + + private void ShowToolWindowFireAndForget() + { + _ = JoinableTaskFactory.RunAsync(async () => + { + try + { + await JoinableTaskFactory.SwitchToMainThreadAsync(); + var window = await ShowToolWindowAsync(typeof(TerminalToolWindow), 0, true, DisposalToken); + (window?.Frame as IVsWindowFrame)?.Show(); + } + catch (Exception ex) + { + _logger?.Log($"ShowToolWindowFireAndForget failed: {ex.Message}"); + } + }); + } + protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/CopilotCliIde/CopilotCliIdePackage.vsct b/src/CopilotCliIde/CopilotCliIdePackage.vsct index 77c9cb5..a95d197 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.vsct +++ b/src/CopilotCliIde/CopilotCliIdePackage.vsct @@ -3,12 +3,28 @@ + + + + + + + Copilot CLI Toolbar + Copilot CLI Toolbar + + + + + + + @@ -26,6 +42,27 @@ Show Copilot CLI (Embedded Terminal) + + + + @@ -39,6 +76,10 @@ + + + + @@ -46,3 +87,4 @@ + diff --git a/src/CopilotCliIde/Polyfills.cs b/src/CopilotCliIde/Polyfills.cs new file mode 100644 index 0000000..0a2017f --- /dev/null +++ b/src/CopilotCliIde/Polyfills.cs @@ -0,0 +1,23 @@ +// Polyfills for C# language features that target newer BCLs but only require +// internal sentinel types. .NET Framework 4.7.2 doesn't ship these — they're +// declared here so we can use `record`, `init`, and `required` in this assembly. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + internal sealed class RequiredMemberAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + internal sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute + { + public string FeatureName { get; } = featureName; + public bool IsOptional { get; init; } + } +} + +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] + internal sealed class SetsRequiredMembersAttribute : Attribute { } +} diff --git a/src/CopilotCliIde/SessionId.cs b/src/CopilotCliIde/SessionId.cs new file mode 100644 index 0000000..dc2391a --- /dev/null +++ b/src/CopilotCliIde/SessionId.cs @@ -0,0 +1,16 @@ +using System.Text.RegularExpressions; + +namespace CopilotCliIde; + +// Strict validation for Copilot CLI session IDs. The CLI uses lowercase UUIDs, +// but we accept either case. Any value that fails this check must NOT be passed +// to the shell — the only path it currently flows through is cmd.exe interpolation +// in TerminalProcess.Start. +internal static class SessionId +{ + private static readonly Regex Pattern = new( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static bool IsValid(string? id) => !string.IsNullOrEmpty(id) && Pattern.IsMatch(id); +} diff --git a/src/CopilotCliIde/SessionPickerDialog.cs b/src/CopilotCliIde/SessionPickerDialog.cs new file mode 100644 index 0000000..58de3fe --- /dev/null +++ b/src/CopilotCliIde/SessionPickerDialog.cs @@ -0,0 +1,217 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using Microsoft.VisualStudio.PlatformUI; +using Microsoft.VisualStudio.Shell; + +namespace CopilotCliIde; + +// VS-themed picker for resuming a previous Copilot CLI session. +// Filters to sessions whose cwd is at or below the supplied workspace path. +internal sealed class SessionPickerDialog : DialogWindow +{ + private readonly SessionStore _store; + private readonly string _workspacePath; + private readonly CancellationTokenSource _cts = new(); + private readonly TextBlock _statusText; + private readonly ListView _list; + private readonly Button _resumeButton; + + public string? SelectedSessionId { get; private set; } + + public SessionPickerDialog(SessionStore store, string workspacePath) + { + _store = store; + _workspacePath = workspacePath; + + Title = "Resume Copilot CLI Session"; + Width = 720; + Height = 480; + MinWidth = 520; + MinHeight = 320; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ShowInTaskbar = false; + + var root = new DockPanel { Margin = new Thickness(12) }; + + var header = new TextBlock + { + Text = $"Sessions for: {workspacePath}", + Margin = new Thickness(0, 0, 0, 8), + TextTrimming = TextTrimming.CharacterEllipsis, + }; + DockPanel.SetDock(header, Dock.Top); + root.Children.Add(header); + + _statusText = new TextBlock + { + Text = "Loading sessions...", + Margin = new Thickness(0, 0, 0, 8), + Visibility = Visibility.Visible, + }; + DockPanel.SetDock(_statusText, Dock.Top); + root.Children.Add(_statusText); + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 8, 0, 0), + }; + _resumeButton = new Button + { + Content = "_Resume", + IsDefault = true, + IsEnabled = false, + MinWidth = 80, + Margin = new Thickness(0, 0, 8, 0), + Padding = new Thickness(12, 4, 12, 4), + }; + _resumeButton.Click += OnResumeClick; + var cancelButton = new Button + { + Content = "_Cancel", + IsCancel = true, + MinWidth = 80, + Padding = new Thickness(12, 4, 12, 4), + }; + buttonPanel.Children.Add(_resumeButton); + buttonPanel.Children.Add(cancelButton); + DockPanel.SetDock(buttonPanel, Dock.Bottom); + root.Children.Add(buttonPanel); + + _list = new ListView { SelectionMode = SelectionMode.Single }; + var grid = new GridView(); + grid.Columns.Add(new GridViewColumn { Header = "Summary", Width = 360, DisplayMemberBinding = new System.Windows.Data.Binding(nameof(Row.SummaryDisplay)) }); + grid.Columns.Add(new GridViewColumn { Header = "Last Updated", Width = 140, DisplayMemberBinding = new System.Windows.Data.Binding(nameof(Row.UpdatedDisplay)) }); + grid.Columns.Add(new GridViewColumn { Header = "Turns", Width = 60, DisplayMemberBinding = new System.Windows.Data.Binding(nameof(Row.TurnCount)) }); + _list.View = grid; + _list.SelectionChanged += (_, _) => _resumeButton.IsEnabled = _list.SelectedItem is Row; + _list.MouseDoubleClick += OnListDoubleClick; + _list.KeyDown += OnListKeyDown; + root.Children.Add(_list); + + Content = root; + + Loaded += OnLoaded; + Closed += (_, _) => _cts.Cancel(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + // Fire-and-forget load — exceptions are caught inside LoadAsync. +#pragma warning disable VSSDK007 // Dialog lifetime is bounded by the modal Show; not worth tracking the JoinableTask. + _ = ThreadHelper.JoinableTaskFactory.RunAsync(LoadAsync); +#pragma warning restore VSSDK007 + } + + private async System.Threading.Tasks.Task LoadAsync() + { + var ct = _cts.Token; + SessionQueryResult result; + try + { + result = await _store.GetSessionsForWorkspaceAsync(_workspacePath, ct); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + result = SessionQueryResult.Empty(SessionStoreStatus.Unavailable, ex.Message); + } + + if (ct.IsCancellationRequested) + return; + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(ct); + ApplyResult(result); + } + + private void ApplyResult(SessionQueryResult result) + { + switch (result.Status) + { + case SessionStoreStatus.NoDatabase: + _statusText.Text = "No Copilot CLI session history found. Sessions are saved automatically once you start using the CLI."; + return; + case SessionStoreStatus.Unavailable: + _statusText.Text = "Session history is unavailable (could not read session-store.db). See the Copilot CLI IDE output pane for details."; + return; + } + + if (result.Sessions.Count == 0) + { + _statusText.Text = "No previous Copilot CLI sessions for this workspace."; + return; + } + + _statusText.Visibility = Visibility.Collapsed; + var rows = new Row[result.Sessions.Count]; + for (var i = 0; i < result.Sessions.Count; i++) + rows[i] = Row.From(result.Sessions[i]); + _list.ItemsSource = rows; + _list.SelectedIndex = 0; + _list.Focus(); + } + + private void OnResumeClick(object sender, RoutedEventArgs e) => Accept(); + + private void OnListDoubleClick(object sender, MouseButtonEventArgs e) + { + if (_list.SelectedItem is Row) + Accept(); + } + + private void OnListKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && _list.SelectedItem is Row) + { + Accept(); + e.Handled = true; + } + } + + private void Accept() + { + if (_list.SelectedItem is Row row) + { + SelectedSessionId = row.Id; + DialogResult = true; + Close(); + } + } + + private sealed class Row + { + public required string Id { get; init; } + public required string SummaryDisplay { get; init; } + public required string UpdatedDisplay { get; init; } + public required int TurnCount { get; init; } + + public static Row From(SessionInfo s) => new() + { + Id = s.Id, + SummaryDisplay = string.IsNullOrWhiteSpace(s.Summary) ? "(no summary)" : s.Summary!.Trim(), + UpdatedDisplay = FormatRelative(s.UpdatedAtUtc), + TurnCount = s.TurnCount, + }; + + private static string FormatRelative(DateTime utc) + { + if (utc == DateTime.MinValue) + return "—"; + var delta = DateTime.UtcNow - utc; + if (delta.TotalSeconds < 0) + return utc.ToLocalTime().ToString("g"); + if (delta.TotalMinutes < 1) return "just now"; + if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; + if (delta.TotalHours < 24) return $"{(int)delta.TotalHours}h ago"; + if (delta.TotalDays < 7) return $"{(int)delta.TotalDays}d ago"; + return utc.ToLocalTime().ToString("yyyy-MM-dd"); + } + } +} diff --git a/src/CopilotCliIde/SessionStore.cs b/src/CopilotCliIde/SessionStore.cs new file mode 100644 index 0000000..ca44b04 --- /dev/null +++ b/src/CopilotCliIde/SessionStore.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; + +namespace CopilotCliIde; + +internal readonly record struct SessionInfo(string Id, string? Summary, string? Cwd, DateTime UpdatedAtUtc, int TurnCount); + +internal enum SessionStoreStatus +{ + Ok, + NoDatabase, + Unavailable, +} + +internal readonly record struct SessionQueryResult(SessionStoreStatus Status, IReadOnlyList Sessions, string? ErrorMessage = null) +{ + public static SessionQueryResult Empty(SessionStoreStatus status, string? error = null) => + new(status, Array.Empty(), error); +} + +// Reads the Copilot CLI's session-store.db (SQLite) to surface previous sessions +// for the current workspace. Opened read-only so it does not contend with the live +// CLI's WAL writer. Schema is internal CLI state — all errors degrade gracefully. +internal sealed class SessionStore(OutputLogger? logger) +{ + private const int MaxResults = 200; + + public static string DefaultDatabasePath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "session-store.db"); + + public Task GetSessionsForWorkspaceAsync(string workspacePath, CancellationToken ct) => + Task.Run(() => GetSessionsForWorkspace(workspacePath, ct), ct); + + private SessionQueryResult GetSessionsForWorkspace(string workspacePath, CancellationToken ct) + { + var dbPath = DefaultDatabasePath; + if (!File.Exists(dbPath)) + return SessionQueryResult.Empty(SessionStoreStatus.NoDatabase); + + var normalizedWorkspace = NormalizePath(workspacePath); + if (normalizedWorkspace == null) + return SessionQueryResult.Empty(SessionStoreStatus.Ok); + + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Private, + }.ToString(); + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + + ct.ThrowIfCancellationRequested(); + + // Build separator-aware LIKE prefix so we don't overmatch siblings + // (e.g. "C:\repo" should NOT match "C:\repo-old") and so the SQL LIMIT + // can't drop valid descendants in favor of unrelated ones. + // SQLite LIKE treats % and _ as wildcards — escape them. + var separator = Path.DirectorySeparatorChar.ToString(); + var likePrefix = EscapeLike(normalizedWorkspace + separator) + "%"; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT s.id, s.summary, s.cwd, s.updated_at, + (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id) AS turn_count + FROM sessions s + WHERE s.cwd = $exact OR s.cwd LIKE $prefix ESCAPE '\' + ORDER BY s.updated_at DESC + LIMIT $limit"; + cmd.Parameters.AddWithValue("$exact", normalizedWorkspace); + cmd.Parameters.AddWithValue("$prefix", likePrefix); + cmd.Parameters.AddWithValue("$limit", MaxResults); + + var results = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + ct.ThrowIfCancellationRequested(); + + var id = reader.GetString(0); + if (!SessionId.IsValid(id)) + continue; // defense-in-depth: never return malformed IDs to callers + + var summary = reader.IsDBNull(1) ? null : reader.GetString(1); + var cwd = reader.IsDBNull(2) ? null : reader.GetString(2); + var updatedAtRaw = reader.IsDBNull(3) ? null : reader.GetString(3); + var turnCount = reader.IsDBNull(4) ? 0 : reader.GetInt32(4); + + if (!IsCwdMatch(cwd, normalizedWorkspace)) + continue; + + var updatedAt = ParseDateTime(updatedAtRaw); + results.Add(new SessionInfo(id, summary, cwd, updatedAt, turnCount)); + } + + return new SessionQueryResult(SessionStoreStatus.Ok, results); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger?.Log($"SessionStore: read failed: {ex.GetType().Name}: {ex.Message}"); + return SessionQueryResult.Empty(SessionStoreStatus.Unavailable, ex.Message); + } + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + try + { + var full = Path.GetFullPath(path); + return full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + catch + { + return null; + } + } + + private static bool IsCwdMatch(string? cwd, string normalizedWorkspace) + { + var normCwd = NormalizePath(cwd); + if (normCwd == null) + return false; + if (string.Equals(normCwd, normalizedWorkspace, StringComparison.OrdinalIgnoreCase)) + return true; + var prefixWithSep = normalizedWorkspace + Path.DirectorySeparatorChar; + return normCwd.StartsWith(prefixWithSep, StringComparison.OrdinalIgnoreCase); + } + + // SQLite LIKE treats % and _ as wildcards. Escape them with backslash (matches ESCAPE '\'). + private static string EscapeLike(string value) => + value.Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_"); + + private static DateTime ParseDateTime(string? raw) + { + if (string.IsNullOrEmpty(raw)) + return DateTime.MinValue; + // SQLite's datetime('now') format: "YYYY-MM-DD HH:MM:SS" UTC. + if (DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var dt)) + return dt; + return DateTime.MinValue; + } +} diff --git a/src/CopilotCliIde/TerminalProcess.cs b/src/CopilotCliIde/TerminalProcess.cs index 3405c97..9f0dbbc 100644 --- a/src/CopilotCliIde/TerminalProcess.cs +++ b/src/CopilotCliIde/TerminalProcess.cs @@ -11,6 +11,7 @@ internal sealed class TerminalProcess : IDisposable private CancellationTokenSource? _cts; private readonly object _lock = new(); private bool _disposed; + private bool _exited; // Output batching: accumulate reads, flush on timer (~16ms / 60fps) private readonly StringBuilder _outputBuffer = new(); @@ -32,12 +33,12 @@ public bool IsRunning { lock (_lock) { - return _session != null && !_disposed; + return _session != null && !_disposed && !_exited; } } } - public void Start(string workingDirectory, short cols = 120, short rows = 40) + public void Start(string workingDirectory, short cols = 120, short rows = 40, string? resumeSessionId = null) { lock (_lock) { @@ -48,7 +49,15 @@ public void Start(string workingDirectory, short cols = 120, short rows = 40) throw new InvalidOperationException("Process is already running."); _cts = new CancellationTokenSource(); - _session = ConPty.Create("cmd.exe /c copilot", workingDirectory, cols, rows); + + // resumeSessionId is validated by callers (SessionId.IsValid). We re-check here + // as a defense-in-depth measure since this string is interpolated into a cmd.exe + // command line. Anything not matching the strict UUID format is dropped. + var commandLine = SessionId.IsValid(resumeSessionId) + ? $"cmd.exe /c copilot --resume={resumeSessionId}" + : "cmd.exe /c copilot"; + + _session = ConPty.Create(commandLine, workingDirectory, cols, rows); _utf8Decoder = Encoding.UTF8.GetDecoder(); _flushTimer = new Timer(FlushOutput, null, Timeout.Infinite, Timeout.Infinite); @@ -130,6 +139,10 @@ private void ReadLoop() // Flush any remaining buffered output FlushOutput(null); + lock (_lock) + { + _exited = true; + } ProcessExited?.Invoke(); } diff --git a/src/CopilotCliIde/TerminalSessionService.cs b/src/CopilotCliIde/TerminalSessionService.cs index 73a550a..fb1e117 100644 --- a/src/CopilotCliIde/TerminalSessionService.cs +++ b/src/CopilotCliIde/TerminalSessionService.cs @@ -8,6 +8,9 @@ internal sealed class TerminalSessionService(OutputLogger? logger) : IDisposable private readonly object _processLock = new(); private TerminalProcess? _process; private string? _workingDirectory; + // Last launch parameters — preserved across implicit restarts (e.g., Enter-to-restart + // after the CLI exits) so a resumed session stays resumed instead of becoming a fresh one. + private string? _lastResumeSessionId; // Fired when the terminal produces output (UTF-8 string). public event Action? OutputReceived; @@ -27,14 +30,16 @@ public void StartSession(string workingDirectory, short cols = 120, short rows = StopSessionCore(); _workingDirectory = workingDirectory; - logger?.Log($"Terminal: starting session in {workingDirectory} ({cols}x{rows})"); + // Initial start uses whatever resume id was previously selected (typically null). + // Toolbar actions go through RestartFresh / RestartResuming and update this state. + logger?.Log($"Terminal: starting session in {workingDirectory} ({cols}x{rows}){(_lastResumeSessionId != null ? " resume=" + _lastResumeSessionId : "")}"); try { _process = new TerminalProcess(); _process.OutputReceived += OnOutputReceived; _process.ProcessExited += OnProcessExited; - _process.Start(workingDirectory, cols, rows); + _process.Start(workingDirectory, cols, rows, _lastResumeSessionId); } catch (Exception ex) { @@ -67,7 +72,28 @@ private void StopSessionCore() _workingDirectory = null; } - public void RestartSession(string? workingDirectory = null) + // Restarts preserving the last launch mode (resume id is preserved). + // Used by Enter-to-restart after the CLI exits. + public void RestartPreservingMode() => + RestartCore(workingDirectory: null, _lastResumeSessionId); + + // Restarts in a brand-new session (no --resume). Updates persistent state. + public void RestartFresh(string workingDirectory) + { + _lastResumeSessionId = null; + RestartCore(workingDirectory, resumeSessionId: null); + } + + // Restarts resuming the specified session. Updates persistent state. + public void RestartResuming(string workingDirectory, string sessionId) + { + if (!SessionId.IsValid(sessionId)) + throw new ArgumentException("Invalid session id format.", nameof(sessionId)); + _lastResumeSessionId = sessionId; + RestartCore(workingDirectory, resumeSessionId: sessionId); + } + + private void RestartCore(string? workingDirectory, string? resumeSessionId) { var restarted = false; lock (_processLock) @@ -78,7 +104,7 @@ public void RestartSession(string? workingDirectory = null) StopSessionCore(); _workingDirectory = dir; - logger?.Log($"Terminal: restarting session in {dir}"); + logger?.Log($"Terminal: restarting session in {dir}{(resumeSessionId != null ? " resume=" + resumeSessionId : "")}"); try { @@ -87,7 +113,7 @@ public void RestartSession(string? workingDirectory = null) _process.ProcessExited += OnProcessExited; // Use defaults — the new TerminalControl's first Resize will // set the real dimensions, forcing the process to redraw. - _process.Start(dir); + _process.Start(dir, resumeSessionId: resumeSessionId); restarted = true; } catch (Exception ex) diff --git a/src/CopilotCliIde/TerminalToolWindow.cs b/src/CopilotCliIde/TerminalToolWindow.cs index c70c620..e070299 100644 --- a/src/CopilotCliIde/TerminalToolWindow.cs +++ b/src/CopilotCliIde/TerminalToolWindow.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.Design; using System.Runtime.InteropServices; using Microsoft.VisualStudio.Shell; @@ -11,6 +12,7 @@ public TerminalToolWindow() : base(null) { Caption = "Copilot CLI"; Content = new TerminalToolWindowControl(); + ToolBar = new CommandID(new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"), 0x1030); } // Escape key sequence matching VS's TerminalWindowBase.EscKeyCode (Kitty keyboard protocol). diff --git a/src/CopilotCliIde/TerminalToolWindowControl.cs b/src/CopilotCliIde/TerminalToolWindowControl.cs index eb5c550..6dd6eda 100644 --- a/src/CopilotCliIde/TerminalToolWindowControl.cs +++ b/src/CopilotCliIde/TerminalToolWindowControl.cs @@ -54,7 +54,7 @@ internal void SendInput(string data) } else if (data is "\r" or "\n") { - _sessionService?.RestartSession(); + _sessionService?.RestartPreservingMode(); } } From 74bd4f2534942402aac636324e196b42bbd23ec2 Mon Sep 17 00:00:00 2001 From: Joe Farrell Date: Sat, 18 Apr 2026 15:02:14 -0400 Subject: [PATCH 2/3] Add Delete Current Thread button and right-align toolbar (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third toolbar button to the embedded Copilot CLI tool window: - Delete Current Thread (KnownMonikers.DeleteListItem) — stops the running CLI, transactionally deletes the current session and all dependent rows (turns, session_files, session_refs, checkpoints, search_index FTS entries) from session-store.db, then restarts with a fresh session. Confirms before deleting and shows the first 8 chars of the session id so the user can sanity-check. The current session id is resolved from TerminalSessionService.LastResumeSessionId when known (we used --resume=), otherwise from the most-recently-updated session for the workspace in session-store.db. Switches the toolbar from VS's native command toolbar to a custom WPF toolbar hosted inside TerminalToolWindowControl. VS tool window toolbars are left-aligned only; the WPF toolbar lets the buttons sit top-right, matching VS Code's terminal placement. WPF buttons invoke handler methods on the package via Action delegates exposed through VsServices.Instance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 +- src/CopilotCliIde/CopilotCliIdePackage.cs | 82 +++++++++++ src/CopilotCliIde/CopilotCliIdePackage.vsct | 40 ------ src/CopilotCliIde/SessionStore.cs | 131 ++++++++++++++++++ src/CopilotCliIde/TerminalSessionService.cs | 5 + src/CopilotCliIde/TerminalToolWindow.cs | 1 - .../TerminalToolWindowControl.cs | 92 +++++++++++- src/CopilotCliIde/VsServices.cs | 8 ++ 8 files changed, 320 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7870e..0647673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Toolbar on the embedded Copilot CLI tool window** with two buttons (issue #9): +- **Toolbar on the embedded Copilot CLI tool window** with three buttons (issue #9), right-aligned at the top of the tool window: - **View Session History** — VS-themed picker (filtered to current workspace) that resumes a previous Copilot CLI session via `copilot --resume=`. Reads `~/.copilot/session-store.db` in read-only mode. - **New Session** — restarts the terminal with a fresh `copilot` (no `--resume`). - - Buttons use `KnownMonikers.History` and `KnownMonikers.NewItem` (theme-aware via `IconIsMoniker`). - - Both commands are disabled when no solution is open. + - **Delete Current Thread** — confirms, then permanently deletes the current chat thread (transactional across sessions, turns, files, refs, checkpoints, FTS index) and restarts fresh. + - Buttons use `KnownMonikers.History`, `KnownMonikers.NewItem`, and `KnownMonikers.DeleteListItem` (theme-aware via `CrispImage`). + - Buttons are hosted in a custom WPF toolbar inside the tool window content (not the native VS command toolbar) so they can sit on the right side, matching VS Code's terminal placement. ### Changed diff --git a/src/CopilotCliIde/CopilotCliIdePackage.cs b/src/CopilotCliIde/CopilotCliIdePackage.cs index 2a8ec06..73358f3 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.cs +++ b/src/CopilotCliIde/CopilotCliIdePackage.cs @@ -130,11 +130,21 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke var newSessionCmd = new OleMenuCommand(OnNewSession, new CommandID(cmdSetGuid, 0x0301)); newSessionCmd.BeforeQueryStatus += OnQueryWorkspaceCommandStatus; commandService.AddCommand(newSessionCmd); + + var deleteSessionCmd = new OleMenuCommand(OnDeleteCurrentSession, new CommandID(cmdSetGuid, 0x0302)); + deleteSessionCmd.BeforeQueryStatus += OnQueryWorkspaceCommandStatus; + commandService.AddCommand(deleteSessionCmd); } // Create terminal session service (survives tool window hide/show) _terminalSession = new TerminalSessionService(_logger); VsServices.Instance.TerminalSession = _terminalSession; + + // Wire toolbar handlers used by the WPF buttons in TerminalToolWindowControl. + // They reuse the same handler methods as the registered commands. + VsServices.Instance.OnViewSessionHistory = () => OnViewSessionHistory(this, EventArgs.Empty); + VsServices.Instance.OnNewSession = () => OnNewSession(this, EventArgs.Empty); + VsServices.Instance.OnDeleteCurrentSession = () => OnDeleteCurrentSession(this, EventArgs.Empty); } catch (Exception ex) { @@ -379,6 +389,78 @@ private void OnNewSession(object sender, EventArgs e) } } + private void OnDeleteCurrentSession(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + if (_terminalSession == null) + return; + + var workspace = GetWorkspaceFolder(); + var store = new SessionStore(_logger); + + // Prefer the explicit resume id we tracked. Fall back to the most-recently + // updated session for this workspace (covers fresh sessions where copilot + // chose its own id and we never saw it). + var sessionId = _terminalSession.LastResumeSessionId + ?? store.GetMostRecentSessionIdForWorkspace(workspace); + + if (sessionId == null) + { + VsShellUtilities.ShowMessageBox( + this, + "No Copilot CLI chat thread was found for this workspace to delete.", + "Copilot CLI", + Microsoft.VisualStudio.Shell.Interop.OLEMSGICON.OLEMSGICON_INFO, + Microsoft.VisualStudio.Shell.Interop.OLEMSGBUTTON.OLEMSGBUTTON_OK, + Microsoft.VisualStudio.Shell.Interop.OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + return; + } + + if (!SessionId.IsValid(sessionId)) + { + _logger?.Log("OnDeleteCurrentSession: rejected invalid session id"); + return; + } + + var confirm = VsShellUtilities.ShowMessageBox( + this, + $"Permanently delete the current Copilot CLI chat thread ({sessionId.Substring(0, 8)}…)? This cannot be undone. The terminal will restart with a fresh session.", + "Copilot CLI", + Microsoft.VisualStudio.Shell.Interop.OLEMSGICON.OLEMSGICON_WARNING, + Microsoft.VisualStudio.Shell.Interop.OLEMSGBUTTON.OLEMSGBUTTON_OKCANCEL, + Microsoft.VisualStudio.Shell.Interop.OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_SECOND); + if (confirm != 1) + return; + + // Stop the live CLI BEFORE deleting so it can't re-write the row mid-delete + // or immediately recreate it on its next persistence tick. + _terminalSession.StopSession(); + + var deleted = store.DeleteSession(sessionId); + if (!deleted) + { + VsShellUtilities.ShowMessageBox( + this, + "Failed to delete the chat thread. The session-store database may be locked or unavailable. Check the Copilot CLI IDE output pane for details.", + "Copilot CLI", + Microsoft.VisualStudio.Shell.Interop.OLEMSGICON.OLEMSGICON_WARNING, + Microsoft.VisualStudio.Shell.Interop.OLEMSGBUTTON.OLEMSGBUTTON_OK, + Microsoft.VisualStudio.Shell.Interop.OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST); + } + + // Always restart fresh — even on failure the user expects a clean terminal + // because we already stopped the previous CLI process. + _terminalSession.RestartFresh(workspace); + ShowToolWindowFireAndForget(); + } + catch (Exception ex) + { + _logger?.Log($"OnDeleteCurrentSession failed: {ex.Message}"); + } + } + private void OnViewSessionHistory(object sender, EventArgs e) { ThreadHelper.ThrowIfNotOnUIThread(); diff --git a/src/CopilotCliIde/CopilotCliIdePackage.vsct b/src/CopilotCliIde/CopilotCliIdePackage.vsct index a95d197..b70a18b 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.vsct +++ b/src/CopilotCliIde/CopilotCliIdePackage.vsct @@ -6,25 +6,10 @@ - - - - - - Copilot CLI Toolbar - Copilot CLI Toolbar - - - - - - - @@ -42,27 +27,6 @@ Show Copilot CLI (Embedded Terminal) - - - - @@ -76,10 +40,6 @@ - - - - diff --git a/src/CopilotCliIde/SessionStore.cs b/src/CopilotCliIde/SessionStore.cs index ca44b04..d67e5e0 100644 --- a/src/CopilotCliIde/SessionStore.cs +++ b/src/CopilotCliIde/SessionStore.cs @@ -155,4 +155,135 @@ private static DateTime ParseDateTime(string? raw) return dt; return DateTime.MinValue; } + + // Returns the id of the most recently updated session that belongs to the given + // workspace, or null if the DB is missing / no session matches / lookup fails. + // Used to resolve "delete the current chat thread" when the caller didn't explicitly + // resume a known session id (i.e. copilot was started fresh and chose its own id). + public string? GetMostRecentSessionIdForWorkspace(string workspacePath) + { + var dbPath = DefaultDatabasePath; + if (!File.Exists(dbPath)) + return null; + + var normalizedWorkspace = NormalizePath(workspacePath); + if (normalizedWorkspace == null) + return null; + + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Private, + }.ToString(); + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + + var separator = Path.DirectorySeparatorChar.ToString(); + var likePrefix = EscapeLike(normalizedWorkspace + separator) + "%"; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" + SELECT id, cwd FROM sessions + WHERE cwd = $exact OR cwd LIKE $prefix ESCAPE '\' + ORDER BY updated_at DESC + LIMIT 50"; + cmd.Parameters.AddWithValue("$exact", normalizedWorkspace); + cmd.Parameters.AddWithValue("$prefix", likePrefix); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetString(0); + if (!SessionId.IsValid(id)) + continue; + var cwd = reader.IsDBNull(1) ? null : reader.GetString(1); + if (!IsCwdMatch(cwd, normalizedWorkspace)) + continue; + return id; + } + } + catch (Exception ex) + { + logger?.Log($"SessionStore: most-recent lookup failed: {ex.GetType().Name}: {ex.Message}"); + } + return null; + } + + // Deletes a single session and all its dependent rows (turns, files, refs, checkpoints, + // FTS index entries) in a single transaction. Returns true if the session row was removed. + // Caller should ensure the live copilot CLI process is stopped first to avoid contention + // and to prevent it from re-writing the row immediately after deletion. + public bool DeleteSession(string sessionId) + { + if (!SessionId.IsValid(sessionId)) + { + logger?.Log("SessionStore: refusing to delete with invalid id"); + return false; + } + var dbPath = DefaultDatabasePath; + if (!File.Exists(dbPath)) + return false; + + try + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWrite, + Cache = SqliteCacheMode.Private, + }.ToString(); + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + + // In case another writer (e.g. a still-running CLI) is mid-checkpoint. + using (var pragma = conn.CreateCommand()) + { + pragma.CommandText = "PRAGMA busy_timeout = 3000"; + pragma.ExecuteNonQuery(); + } + + using var tx = conn.BeginTransaction(); + var tablesInOrder = new[] + { + "DELETE FROM search_index WHERE session_id = $id", + "DELETE FROM turns WHERE session_id = $id", + "DELETE FROM session_files WHERE session_id = $id", + "DELETE FROM session_refs WHERE session_id = $id", + "DELETE FROM checkpoints WHERE session_id = $id", + "DELETE FROM sessions WHERE id = $id", + }; + var sessionsRemoved = 0; + foreach (var sql in tablesInOrder) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("$id", sessionId); + try + { + var affected = cmd.ExecuteNonQuery(); + if (sql.StartsWith("DELETE FROM sessions ", StringComparison.Ordinal)) + sessionsRemoved = affected; + } + catch (SqliteException ex) when (ex.Message.IndexOf("no such table", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Schema variant that doesn't ship one of the auxiliary tables — keep going. + logger?.Log($"SessionStore: skip missing table during delete: {ex.Message}"); + } + } + tx.Commit(); + logger?.Log($"SessionStore: deleted session {sessionId} (rows from sessions: {sessionsRemoved})"); + return sessionsRemoved > 0; + } + catch (Exception ex) + { + logger?.Log($"SessionStore: delete failed for {sessionId}: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } } diff --git a/src/CopilotCliIde/TerminalSessionService.cs b/src/CopilotCliIde/TerminalSessionService.cs index fb1e117..d444926 100644 --- a/src/CopilotCliIde/TerminalSessionService.cs +++ b/src/CopilotCliIde/TerminalSessionService.cs @@ -23,6 +23,11 @@ internal sealed class TerminalSessionService(OutputLogger? logger) : IDisposable public bool IsRunning => _process?.IsRunning ?? false; + // The session id that the currently running CLI was launched with via --resume, + // or null if the CLI is running without --resume (a fresh session whose id was + // chosen by copilot internally). Updated by RestartResuming / RestartFresh. + public string? LastResumeSessionId => _lastResumeSessionId; + public void StartSession(string workingDirectory, short cols = 120, short rows = 40) { lock (_processLock) diff --git a/src/CopilotCliIde/TerminalToolWindow.cs b/src/CopilotCliIde/TerminalToolWindow.cs index e070299..a4197a6 100644 --- a/src/CopilotCliIde/TerminalToolWindow.cs +++ b/src/CopilotCliIde/TerminalToolWindow.cs @@ -12,7 +12,6 @@ public TerminalToolWindow() : base(null) { Caption = "Copilot CLI"; Content = new TerminalToolWindowControl(); - ToolBar = new CommandID(new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"), 0x1030); } // Escape key sequence matching VS's TerminalWindowBase.EscKeyCode (Kitty keyboard protocol). diff --git a/src/CopilotCliIde/TerminalToolWindowControl.cs b/src/CopilotCliIde/TerminalToolWindowControl.cs index 6dd6eda..2bf124a 100644 --- a/src/CopilotCliIde/TerminalToolWindowControl.cs +++ b/src/CopilotCliIde/TerminalToolWindowControl.cs @@ -1,6 +1,10 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; using Microsoft.Terminal.Wpf; +using Microsoft.VisualStudio.Imaging; +using Microsoft.VisualStudio.Imaging.Interop; using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Shell; @@ -23,9 +27,23 @@ public TerminalToolWindowControl() _logger = VsServices.Instance.Logger; _termControl = new TerminalControl { Focusable = true, Connection = this, AutoResize = true }; - Content = _termControl; - // Attach to session early — Resize may fire before OnLoaded + // Two-row layout: a thin right-aligned toolbar above the terminal control. + // VS's ToolWindow toolbars are left-aligned only — we host our own here so + // the buttons sit at the top right where the user expects them. + var grid = new Grid(); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var toolbar = BuildToolbar(); + Grid.SetRow(toolbar, 0); + grid.Children.Add(toolbar); + + Grid.SetRow(_termControl, 1); + grid.Children.Add(_termControl); + + Content = grid; + AttachToSession(); Loaded += OnLoaded; @@ -36,6 +54,76 @@ public TerminalToolWindowControl() VSColorTheme.ThemeChanged += OnThemeChanged; } + private FrameworkElement BuildToolbar() + { + // Themed bar background — matches the rest of the tool window chrome. + var bar = new Border + { + Background = new DynamicResourceExtension(EnvironmentColors.ToolWindowBackgroundBrushKey).ProvideValue(null) as Brush, + BorderBrush = new DynamicResourceExtension(EnvironmentColors.ToolWindowBorderBrushKey).ProvideValue(null) as Brush, + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(2, 1, 2, 1), + }; + + var stack = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + }; + stack.Children.Add(MakeToolbarButton(KnownMonikers.History, "View Session History", + "Resume a previous Copilot CLI session for this workspace", OnViewSessionHistoryClick)); + stack.Children.Add(MakeToolbarButton(KnownMonikers.NewItem, "New Session", + "Start a fresh Copilot CLI session", OnNewSessionClick)); + stack.Children.Add(MakeToolbarButton(KnownMonikers.DeleteListItem, "Delete Current Thread", + "Permanently delete the current Copilot CLI chat thread", OnDeleteCurrentSessionClick)); + + bar.Child = stack; + return bar; + } + + private static Button MakeToolbarButton(ImageMoniker moniker, string accessibleName, string tooltip, RoutedEventHandler onClick) + { + var img = new CrispImage + { + Moniker = moniker, + Width = 16, + Height = 16, + Margin = new Thickness(2), + }; + var btn = new Button + { + Content = img, + ToolTip = tooltip, + Padding = new Thickness(4, 2, 4, 2), + Margin = new Thickness(1, 0, 1, 0), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Focusable = false, + Cursor = Cursors.Hand, + }; + System.Windows.Automation.AutomationProperties.SetName(btn, accessibleName); + btn.Click += onClick; + return btn; + } + + private void OnViewSessionHistoryClick(object sender, RoutedEventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + VsServices.Instance.OnViewSessionHistory?.Invoke(); + } + + private void OnNewSessionClick(object sender, RoutedEventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + VsServices.Instance.OnNewSession?.Invoke(); + } + + private void OnDeleteCurrentSessionClick(object sender, RoutedEventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + VsServices.Instance.OnDeleteCurrentSession?.Invoke(); + } + void ITerminalConnection.Start() { } diff --git a/src/CopilotCliIde/VsServices.cs b/src/CopilotCliIde/VsServices.cs index 228f199..9e8b974 100644 --- a/src/CopilotCliIde/VsServices.cs +++ b/src/CopilotCliIde/VsServices.cs @@ -1,5 +1,7 @@ namespace CopilotCliIde; +using System; + // Single point of contact between the VS package and VsServiceRpc (which is // instantiated by StreamJsonRpc and can't use constructor injection). internal sealed class VsServices @@ -10,4 +12,10 @@ internal sealed class VsServices public Action? OnResetNotificationState { get; set; } public DiagnosticTracker? DiagnosticTracker { get; set; } public TerminalSessionService? TerminalSession { get; set; } + + // Toolbar handlers — set by the package, invoked by the WPF toolbar buttons in + // the embedded terminal tool window. Null if the package isn't initialized yet. + public Action? OnViewSessionHistory { get; set; } + public Action? OnNewSession { get; set; } + public Action? OnDeleteCurrentSession { get; set; } } From 6b3e1ee3923b58a86518672b0a28317da4a306be Mon Sep 17 00:00:00 2001 From: Joe Farrell Date: Wed, 22 Apr 2026 19:33:31 -0400 Subject: [PATCH 3/3] Address PR review feedback (#10) - Drop Polyfills.cs; convert SessionInfo / SessionQueryResult to plain readonly structs and Row to a regular class (no records, no required/init). - Replace SessionId regex with Guid.TryParseExact ("D" format). - Revert custom WPF toolbar; restore the native VSCT ToolWindowToolbar with the three buttons. The WPF Grid wrapper above the native TerminalControl HWND host caused resize-sync issues for the maintainer; the native toolbar avoids that and matches every other VS tool window's left-aligned convention (Solution Explorer, Output, Error List, etc.). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/CopilotCliIde/CopilotCliIdePackage.cs | 6 -- src/CopilotCliIde/CopilotCliIdePackage.vsct | 50 ++++++++++ src/CopilotCliIde/Polyfills.cs | 23 ----- src/CopilotCliIde/SessionId.cs | 16 +--- src/CopilotCliIde/SessionPickerDialog.cs | 28 +++--- src/CopilotCliIde/SessionStore.cs | 31 ++++++- src/CopilotCliIde/TerminalToolWindow.cs | 1 + .../TerminalToolWindowControl.cs | 92 +------------------ src/CopilotCliIde/VsServices.cs | 8 -- 9 files changed, 104 insertions(+), 151 deletions(-) delete mode 100644 src/CopilotCliIde/Polyfills.cs diff --git a/src/CopilotCliIde/CopilotCliIdePackage.cs b/src/CopilotCliIde/CopilotCliIdePackage.cs index 73358f3..7add269 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.cs +++ b/src/CopilotCliIde/CopilotCliIdePackage.cs @@ -139,12 +139,6 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke // Create terminal session service (survives tool window hide/show) _terminalSession = new TerminalSessionService(_logger); VsServices.Instance.TerminalSession = _terminalSession; - - // Wire toolbar handlers used by the WPF buttons in TerminalToolWindowControl. - // They reuse the same handler methods as the registered commands. - VsServices.Instance.OnViewSessionHistory = () => OnViewSessionHistory(this, EventArgs.Empty); - VsServices.Instance.OnNewSession = () => OnNewSession(this, EventArgs.Empty); - VsServices.Instance.OnDeleteCurrentSession = () => OnDeleteCurrentSession(this, EventArgs.Empty); } catch (Exception ex) { diff --git a/src/CopilotCliIde/CopilotCliIdePackage.vsct b/src/CopilotCliIde/CopilotCliIdePackage.vsct index b70a18b..b582ebd 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.vsct +++ b/src/CopilotCliIde/CopilotCliIdePackage.vsct @@ -6,10 +6,25 @@ + + + + + + Copilot CLI Toolbar + Copilot CLI Toolbar + + + + + + + @@ -27,6 +42,36 @@ Show Copilot CLI (Embedded Terminal) + + + + + @@ -40,6 +85,11 @@ + + + + + diff --git a/src/CopilotCliIde/Polyfills.cs b/src/CopilotCliIde/Polyfills.cs deleted file mode 100644 index 0a2017f..0000000 --- a/src/CopilotCliIde/Polyfills.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Polyfills for C# language features that target newer BCLs but only require -// internal sentinel types. .NET Framework 4.7.2 doesn't ship these — they're -// declared here so we can use `record`, `init`, and `required` in this assembly. -namespace System.Runtime.CompilerServices -{ - internal static class IsExternalInit { } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] - internal sealed class RequiredMemberAttribute : Attribute { } - - [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] - internal sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute - { - public string FeatureName { get; } = featureName; - public bool IsOptional { get; init; } - } -} - -namespace System.Diagnostics.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] - internal sealed class SetsRequiredMembersAttribute : Attribute { } -} diff --git a/src/CopilotCliIde/SessionId.cs b/src/CopilotCliIde/SessionId.cs index dc2391a..f2719a7 100644 --- a/src/CopilotCliIde/SessionId.cs +++ b/src/CopilotCliIde/SessionId.cs @@ -1,16 +1,10 @@ -using System.Text.RegularExpressions; - namespace CopilotCliIde; -// Strict validation for Copilot CLI session IDs. The CLI uses lowercase UUIDs, -// but we accept either case. Any value that fails this check must NOT be passed -// to the shell — the only path it currently flows through is cmd.exe interpolation -// in TerminalProcess.Start. +// Strict validation for Copilot CLI session IDs. The CLI uses lowercase UUIDs. +// Any value that fails this check must NOT be passed to the shell — the only +// path it currently flows through is cmd.exe interpolation in TerminalProcess.Start. internal static class SessionId { - private static readonly Regex Pattern = new( - "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - public static bool IsValid(string? id) => !string.IsNullOrEmpty(id) && Pattern.IsMatch(id); + public static bool IsValid(string? id) => + !string.IsNullOrEmpty(id) && Guid.TryParseExact(id, "D", out _); } diff --git a/src/CopilotCliIde/SessionPickerDialog.cs b/src/CopilotCliIde/SessionPickerDialog.cs index 58de3fe..c622cc2 100644 --- a/src/CopilotCliIde/SessionPickerDialog.cs +++ b/src/CopilotCliIde/SessionPickerDialog.cs @@ -187,18 +187,24 @@ private void Accept() private sealed class Row { - public required string Id { get; init; } - public required string SummaryDisplay { get; init; } - public required string UpdatedDisplay { get; init; } - public required int TurnCount { get; init; } - - public static Row From(SessionInfo s) => new() + public Row(string id, string summaryDisplay, string updatedDisplay, int turnCount) { - Id = s.Id, - SummaryDisplay = string.IsNullOrWhiteSpace(s.Summary) ? "(no summary)" : s.Summary!.Trim(), - UpdatedDisplay = FormatRelative(s.UpdatedAtUtc), - TurnCount = s.TurnCount, - }; + Id = id; + SummaryDisplay = summaryDisplay; + UpdatedDisplay = updatedDisplay; + TurnCount = turnCount; + } + + public string Id { get; } + public string SummaryDisplay { get; } + public string UpdatedDisplay { get; } + public int TurnCount { get; } + + public static Row From(SessionInfo s) => new( + s.Id, + string.IsNullOrWhiteSpace(s.Summary) ? "(no summary)" : s.Summary!.Trim(), + FormatRelative(s.UpdatedAtUtc), + s.TurnCount); private static string FormatRelative(DateTime utc) { diff --git a/src/CopilotCliIde/SessionStore.cs b/src/CopilotCliIde/SessionStore.cs index d67e5e0..18d50df 100644 --- a/src/CopilotCliIde/SessionStore.cs +++ b/src/CopilotCliIde/SessionStore.cs @@ -8,7 +8,23 @@ namespace CopilotCliIde; -internal readonly record struct SessionInfo(string Id, string? Summary, string? Cwd, DateTime UpdatedAtUtc, int TurnCount); +internal readonly struct SessionInfo +{ + public SessionInfo(string id, string? summary, string? cwd, DateTime updatedAtUtc, int turnCount) + { + Id = id; + Summary = summary; + Cwd = cwd; + UpdatedAtUtc = updatedAtUtc; + TurnCount = turnCount; + } + + public string Id { get; } + public string? Summary { get; } + public string? Cwd { get; } + public DateTime UpdatedAtUtc { get; } + public int TurnCount { get; } +} internal enum SessionStoreStatus { @@ -17,8 +33,19 @@ internal enum SessionStoreStatus Unavailable, } -internal readonly record struct SessionQueryResult(SessionStoreStatus Status, IReadOnlyList Sessions, string? ErrorMessage = null) +internal readonly struct SessionQueryResult { + public SessionQueryResult(SessionStoreStatus status, IReadOnlyList sessions, string? errorMessage = null) + { + Status = status; + Sessions = sessions; + ErrorMessage = errorMessage; + } + + public SessionStoreStatus Status { get; } + public IReadOnlyList Sessions { get; } + public string? ErrorMessage { get; } + public static SessionQueryResult Empty(SessionStoreStatus status, string? error = null) => new(status, Array.Empty(), error); } diff --git a/src/CopilotCliIde/TerminalToolWindow.cs b/src/CopilotCliIde/TerminalToolWindow.cs index a4197a6..e070299 100644 --- a/src/CopilotCliIde/TerminalToolWindow.cs +++ b/src/CopilotCliIde/TerminalToolWindow.cs @@ -12,6 +12,7 @@ public TerminalToolWindow() : base(null) { Caption = "Copilot CLI"; Content = new TerminalToolWindowControl(); + ToolBar = new CommandID(new Guid("e7a8b9c0-d1e2-4f3a-8b5c-6d7e8f9a0b1c"), 0x1030); } // Escape key sequence matching VS's TerminalWindowBase.EscKeyCode (Kitty keyboard protocol). diff --git a/src/CopilotCliIde/TerminalToolWindowControl.cs b/src/CopilotCliIde/TerminalToolWindowControl.cs index 2bf124a..6dd6eda 100644 --- a/src/CopilotCliIde/TerminalToolWindowControl.cs +++ b/src/CopilotCliIde/TerminalToolWindowControl.cs @@ -1,10 +1,6 @@ using System.Windows; using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; using Microsoft.Terminal.Wpf; -using Microsoft.VisualStudio.Imaging; -using Microsoft.VisualStudio.Imaging.Interop; using Microsoft.VisualStudio.PlatformUI; using Microsoft.VisualStudio.Shell; @@ -27,23 +23,9 @@ public TerminalToolWindowControl() _logger = VsServices.Instance.Logger; _termControl = new TerminalControl { Focusable = true, Connection = this, AutoResize = true }; + Content = _termControl; - // Two-row layout: a thin right-aligned toolbar above the terminal control. - // VS's ToolWindow toolbars are left-aligned only — we host our own here so - // the buttons sit at the top right where the user expects them. - var grid = new Grid(); - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - var toolbar = BuildToolbar(); - Grid.SetRow(toolbar, 0); - grid.Children.Add(toolbar); - - Grid.SetRow(_termControl, 1); - grid.Children.Add(_termControl); - - Content = grid; - + // Attach to session early — Resize may fire before OnLoaded AttachToSession(); Loaded += OnLoaded; @@ -54,76 +36,6 @@ public TerminalToolWindowControl() VSColorTheme.ThemeChanged += OnThemeChanged; } - private FrameworkElement BuildToolbar() - { - // Themed bar background — matches the rest of the tool window chrome. - var bar = new Border - { - Background = new DynamicResourceExtension(EnvironmentColors.ToolWindowBackgroundBrushKey).ProvideValue(null) as Brush, - BorderBrush = new DynamicResourceExtension(EnvironmentColors.ToolWindowBorderBrushKey).ProvideValue(null) as Brush, - BorderThickness = new Thickness(0, 0, 0, 1), - Padding = new Thickness(2, 1, 2, 1), - }; - - var stack = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - }; - stack.Children.Add(MakeToolbarButton(KnownMonikers.History, "View Session History", - "Resume a previous Copilot CLI session for this workspace", OnViewSessionHistoryClick)); - stack.Children.Add(MakeToolbarButton(KnownMonikers.NewItem, "New Session", - "Start a fresh Copilot CLI session", OnNewSessionClick)); - stack.Children.Add(MakeToolbarButton(KnownMonikers.DeleteListItem, "Delete Current Thread", - "Permanently delete the current Copilot CLI chat thread", OnDeleteCurrentSessionClick)); - - bar.Child = stack; - return bar; - } - - private static Button MakeToolbarButton(ImageMoniker moniker, string accessibleName, string tooltip, RoutedEventHandler onClick) - { - var img = new CrispImage - { - Moniker = moniker, - Width = 16, - Height = 16, - Margin = new Thickness(2), - }; - var btn = new Button - { - Content = img, - ToolTip = tooltip, - Padding = new Thickness(4, 2, 4, 2), - Margin = new Thickness(1, 0, 1, 0), - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Focusable = false, - Cursor = Cursors.Hand, - }; - System.Windows.Automation.AutomationProperties.SetName(btn, accessibleName); - btn.Click += onClick; - return btn; - } - - private void OnViewSessionHistoryClick(object sender, RoutedEventArgs e) - { - ThreadHelper.ThrowIfNotOnUIThread(); - VsServices.Instance.OnViewSessionHistory?.Invoke(); - } - - private void OnNewSessionClick(object sender, RoutedEventArgs e) - { - ThreadHelper.ThrowIfNotOnUIThread(); - VsServices.Instance.OnNewSession?.Invoke(); - } - - private void OnDeleteCurrentSessionClick(object sender, RoutedEventArgs e) - { - ThreadHelper.ThrowIfNotOnUIThread(); - VsServices.Instance.OnDeleteCurrentSession?.Invoke(); - } - void ITerminalConnection.Start() { } diff --git a/src/CopilotCliIde/VsServices.cs b/src/CopilotCliIde/VsServices.cs index 9e8b974..228f199 100644 --- a/src/CopilotCliIde/VsServices.cs +++ b/src/CopilotCliIde/VsServices.cs @@ -1,7 +1,5 @@ namespace CopilotCliIde; -using System; - // Single point of contact between the VS package and VsServiceRpc (which is // instantiated by StreamJsonRpc and can't use constructor injection). internal sealed class VsServices @@ -12,10 +10,4 @@ internal sealed class VsServices public Action? OnResetNotificationState { get; set; } public DiagnosticTracker? DiagnosticTracker { get; set; } public TerminalSessionService? TerminalSession { get; set; } - - // Toolbar handlers — set by the package, invoked by the WPF toolbar buttons in - // the embedded terminal tool window. Null if the package isn't initialized yet. - public Action? OnViewSessionHistory { get; set; } - public Action? OnNewSession { get; set; } - public Action? OnDeleteCurrentSession { get; set; } }