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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@ 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=<id>`. 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

- Fix TOCTOU race condition in `DebouncePusher` — timer and key fields now properly synchronized
- 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

Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.10" />
<!-- VS2022 provides StreamJsonRpc2.22 at runtime, VS2026 has 2.24 with binding redirects. See CopilotCliIde.Server.csproj -->
<PackageVersion Include="StreamJsonRpc" Version="[2.22.23]" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
Expand Down
21 changes: 21 additions & 0 deletions src/CopilotCliIde/CopilotCliIde.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
<PackageReference Include="Microsoft.VisualStudio.SDK.VsixSuppression" PrivateAssets="all" />
<PackageReference Include="Microsoft.VSSDK.BuildTools" PrivateAssets="all" />
<PackageReference Include="Microsoft.Bcl.HashCode" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" GeneratePathProperty="true" />
</ItemGroup>

<!-- Reference VS's built-in Terminal.Wpf assembly.
Expand Down Expand Up @@ -96,6 +98,25 @@
</Content>
</ItemGroup>

<!-- Bundle SQLite assets in the VSIX. Must run AFTER RemoveVSSDKAssemblies
(same as BundleServerInVsix below) — otherwise the VSSDK strips them.
Uses VSIXSourceItem (not Content/IncludeInVSIX) which is the pattern
VSIXProjectsCommon understands at this stage. -->
<Target Name="BundleSqliteInVsix" AfterTargets="RemoveVSSDKAssemblies">
<ItemGroup>
<VSIXSourceItem Include="$(OutputPath)Microsoft.Data.Sqlite.dll" Condition="Exists('$(OutputPath)Microsoft.Data.Sqlite.dll')" />
<VSIXSourceItem Include="$(OutputPath)SQLitePCLRaw.batteries_v2.dll" Condition="Exists('$(OutputPath)SQLitePCLRaw.batteries_v2.dll')" />
<VSIXSourceItem Include="$(OutputPath)SQLitePCLRaw.core.dll" Condition="Exists('$(OutputPath)SQLitePCLRaw.core.dll')" />
<VSIXSourceItem Include="$(OutputPath)SQLitePCLRaw.provider.dynamic_cdecl.dll" Condition="Exists('$(OutputPath)SQLitePCLRaw.provider.dynamic_cdecl.dll')" />
<VSIXSourceItem Include="$(OutputPath)runtimes\win-x64\native\e_sqlite3.dll" Condition="Exists('$(OutputPath)runtimes\win-x64\native\e_sqlite3.dll')">
<VSIXSubPath>runtimes\win-x64\native</VSIXSubPath>
</VSIXSourceItem>
<VSIXSourceItem Include="$(OutputPath)runtimes\win-arm64\native\e_sqlite3.dll" Condition="Exists('$(OutputPath)runtimes\win-arm64\native\e_sqlite3.dll')">
<VSIXSubPath>runtimes\win-arm64\native</VSIXSubPath>
</VSIXSourceItem>
</ItemGroup>
</Target>

<!-- Publish the MCP server before building the extension -->
<Target Name="PublishServerBeforeBuild" BeforeTargets="Build">
<Exec Command="dotnet publish ..\CopilotCliIde.Server\CopilotCliIde.Server.csproj -c $(Configuration) -o $(OutputPath)\CopilotCliIde.Server --no-self-contained" />
Expand Down
207 changes: 200 additions & 7 deletions src/CopilotCliIde/CopilotCliIdePackage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand All @@ -258,7 +271,7 @@ private void OnSolutionAfterClosing()
try
{
await JoinableTaskFactory.SwitchToMainThreadAsync();
_terminalSession?.RestartSession(GetWorkspaceFolder());
_terminalSession?.RestartFresh(GetWorkspaceFolder());
StopConnection();
}
catch (Exception ex)
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions src/CopilotCliIde/CopilotCliIdePackage.vsct
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@

<Extern href="stdidcmd.h" />
<Extern href="vsshlids.h" />
<Extern href="KnownImageIds.vsct" />

<Commands package="guidCopilotCliIdePackage">
<Menus>
<!-- Toolbar attached to the Copilot CLI tool window. The window's ToolBar property
must be set to {guidCopilotCliIdeCmdSet}:CopilotCliToolbarId for this to render. -->
<Menu guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarId" priority="0x0000" type="ToolWindowToolbar">
<Parent guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarId" />
<Strings>
<ButtonText>Copilot CLI Toolbar</ButtonText>
<CommandName>Copilot CLI Toolbar</CommandName>
</Strings>
</Menu>
</Menus>

<Groups>
<Group guid="guidCopilotCliIdeCmdSet" id="CopilotCliMenuGroup" priority="0x0600">
<Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS" />
</Group>
<Group guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarGroup" priority="0x0000">
<Parent guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarId" />
</Group>
</Groups>

<Buttons>
Expand All @@ -26,6 +42,36 @@
<ButtonText>Show Copilot CLI (Embedded Terminal)</ButtonText>
</Strings>
</Button>

<!-- Toolbar buttons. IconIsMoniker tells VS the Icon refers to an image catalog
moniker (guid + id) rather than a Bitmap declared below. -->
<Button guid="guidCopilotCliIdeCmdSet" id="ViewSessionHistoryCommandId" priority="0x0100" type="Button">
<Parent guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarGroup" />
<Icon guid="ImageCatalogGuid" id="History" />
<CommandFlag>IconIsMoniker</CommandFlag>
<Strings>
<ButtonText>View Session History</ButtonText>
<ToolTipText>Resume a previous Copilot CLI session for this workspace</ToolTipText>
</Strings>
</Button>
<Button guid="guidCopilotCliIdeCmdSet" id="NewSessionCommandId" priority="0x0200" type="Button">
<Parent guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarGroup" />
<Icon guid="ImageCatalogGuid" id="NewItem" />
<CommandFlag>IconIsMoniker</CommandFlag>
<Strings>
<ButtonText>New Session</ButtonText>
<ToolTipText>Start a fresh Copilot CLI session</ToolTipText>
</Strings>
</Button>
<Button guid="guidCopilotCliIdeCmdSet" id="DeleteCurrentSessionCommandId" priority="0x0300" type="Button">
<Parent guid="guidCopilotCliIdeCmdSet" id="CopilotCliToolbarGroup" />
<Icon guid="ImageCatalogGuid" id="DeleteListItem" />
<CommandFlag>IconIsMoniker</CommandFlag>
<Strings>
<ButtonText>Delete Current Thread</ButtonText>
<ToolTipText>Permanently delete the current Copilot CLI chat thread</ToolTipText>
</Strings>
</Button>
</Buttons>

<Bitmaps>
Expand All @@ -39,10 +85,16 @@
<IDSymbol name="CopilotCliMenuGroup" value="0x1020" />
<IDSymbol name="LaunchCopilotCliCommandId" value="0x0100" />
<IDSymbol name="CopilotCliWindowCommandId" value="0x0200" />
<IDSymbol name="CopilotCliToolbarId" value="0x1030" />
<IDSymbol name="CopilotCliToolbarGroup" value="0x1031" />
<IDSymbol name="ViewSessionHistoryCommandId" value="0x0300" />
<IDSymbol name="NewSessionCommandId" value="0x0301" />
<IDSymbol name="DeleteCurrentSessionCommandId" value="0x0302" />
</GuidSymbol>
<GuidSymbol name="guidImages" value="{063cfd9a-783a-4907-801f-30eccf98c5f0}">
<IDSymbol name="copilotIcon" value="1" />
</GuidSymbol>
</Symbols>

</CommandTable>

Loading