diff --git a/CHANGELOG.md b/CHANGELOG.md index d8245c5..0647673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,19 @@ 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 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`). + - **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 - 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 +26,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..7add269 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.cs +++ b/src/CopilotCliIde/CopilotCliIdePackage.cs @@ -114,14 +114,26 @@ 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); + + var deleteSessionCmd = new OleMenuCommand(OnDeleteCurrentSession, new CommandID(cmdSetGuid, 0x0302)); + deleteSessionCmd.BeforeQueryStatus += OnQueryWorkspaceCommandStatus; + commandService.AddCommand(deleteSessionCmd); } // Create terminal session service (survives tool window hide/show) @@ -242,7 +254,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 +271,7 @@ private void OnSolutionAfterClosing() try { await JoinableTaskFactory.SwitchToMainThreadAsync(); - _terminalSession?.RestartSession(GetWorkspaceFolder()); + _terminalSession?.RestartFresh(GetWorkspaceFolder()); StopConnection(); } catch (Exception ex) @@ -327,6 +340,186 @@ 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 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(); + 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..b582ebd 100644 --- a/src/CopilotCliIde/CopilotCliIdePackage.vsct +++ b/src/CopilotCliIde/CopilotCliIdePackage.vsct @@ -3,12 +3,28 @@ + + + + + + + Copilot CLI Toolbar + Copilot CLI Toolbar + + + + + + + @@ -26,6 +42,36 @@ Show Copilot CLI (Embedded Terminal) + + + + + @@ -39,6 +85,11 @@ + + + + + @@ -46,3 +97,4 @@ + diff --git a/src/CopilotCliIde/SessionId.cs b/src/CopilotCliIde/SessionId.cs new file mode 100644 index 0000000..f2719a7 --- /dev/null +++ b/src/CopilotCliIde/SessionId.cs @@ -0,0 +1,10 @@ +namespace CopilotCliIde; + +// 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 +{ + 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 new file mode 100644 index 0000000..c622cc2 --- /dev/null +++ b/src/CopilotCliIde/SessionPickerDialog.cs @@ -0,0 +1,223 @@ +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 Row(string id, string summaryDisplay, string updatedDisplay, int 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) + { + 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..18d50df --- /dev/null +++ b/src/CopilotCliIde/SessionStore.cs @@ -0,0 +1,316 @@ +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 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 +{ + Ok, + NoDatabase, + Unavailable, +} + +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); +} + +// 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; + } + + // 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/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..d444926 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; @@ -20,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) @@ -27,14 +35,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 +77,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 +109,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 +118,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(); } }