Skip to content

Commit 38213a5

Browse files
sailroCopilot
andcommitted
feat: add configurable external terminal launcher
Allows customizing the executable and arguments used by Tools → Launch Copilot CLI (External Terminal) via a new VS Unified Settings category (Settings → Copilot CLI IDE Bridge → External Terminal). Defaults preserve existing behavior (`cmd.exe /k copilot`). Supports any shell (`wt.exe`, `pwsh.exe`, `powershell.exe`, custom path) and a `{WorkspaceFolder}` placeholder for terminals like Windows Terminal that don't inherit the parent process working directory. The resolved command/arguments/cwd are written to the Copilot CLI output pane on launch for diagnostics. Also: - Rename existing Terminal settings category to "Embedded Terminal" - Refactor TerminalSettings to share GetString/GetInt32 helpers - Refactor TerminalSettingsProvider to derive moniker from key (ToMoniker) removing duplicate moniker constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3268fa0 commit 38213a5

6 files changed

Lines changed: 132 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [1.0.19] - 2026-04-26
10+
11+
### Added
12+
13+
- **External terminal configuration** via VS Settings (**Settings → Copilot CLI IDE Bridge → External Terminal**) — customize the executable (`Command`) and `Arguments` used by **Tools → Launch Copilot CLI (External Terminal)**. Defaults preserve existing behavior (`cmd.exe /k copilot`); supports any shell (`wt.exe`, `pwsh.exe`, `powershell.exe`, or any executable on PATH / full path).
14+
- `{WorkspaceFolder}` placeholder in `Command` and `Arguments` — substituted with the current solution directory at launch time. Required for terminals that don't inherit the parent process working directory (e.g. `wt.exe -d "{WorkspaceFolder}" cmd /k copilot`).
15+
- Output pane log entry on external terminal launch showing the resolved command, arguments, and working directory — for debugging custom configurations.
16+
917
### Changed
1018

1119
- Replace `Task.Delay(200)` server startup wait with stdout `READY` handshake — deterministic server readiness detection with 10s timeout
20+
- Renamed the existing **Terminal** settings category to **Embedded Terminal** to disambiguate from the new External Terminal category.
1221

1322
### Fixed
1423

1524
- Fix TOCTOU race condition in `DebouncePusher` — timer and key fields now properly synchronized
1625
- Clean up orphaned diff views on solution switch — `CleanupAllDiffs()` runs before RPC teardown in `StopConnection()`
17-
- Trim SSE event historyto last-per-notification-type — prevents unbounded growth from rapid selection/diagnostics changes while preserving initial state for new SSE clients
26+
- Trim SSE event history to last-per-notification-type — prevents unbounded growth from rapid selection/diagnostics changes while preserving initial state for new SSE clients
1827
- Remove no-op assertions and duplicate tests in server test suite
1928

2029
## [1.0.18] - 2026-04-13

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Double-click the `.vsix` to install, or use F5 in Visual Studio to debug in the
4242
1. **Open a solution** in Visual Studio — the extension activates automatically
4343
2. **Launch Copilot CLI** using one of:
4444
- **Tools → Show Copilot CLI (Embedded Terminal)** — opens a dockable terminal inside VS with Copilot CLI running (native Microsoft.Terminal.Wpf control)
45-
- **Tools → Launch Copilot CLI (External Terminal)** — opens Copilot CLI in an external terminal window
45+
- **Tools → Launch Copilot CLI (External Terminal)** — opens Copilot CLI in an external terminal window. Configurable via **Settings → Copilot CLI IDE Bridge → External Terminal** (defaults to `cmd.exe /k copilot`; supports `wt.exe`, `pwsh.exe`, etc., with `{WorkspaceFolder}` placeholder).
4646
- Open a terminal manually in the solution folder
4747
3. **Run `/ide`** in Copilot CLI — it discovers Visual Studio and connects
4848

src/CopilotCliIde/CopilotCliIdePackage.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,17 @@ private void OnLaunchCopilotCli(object sender, EventArgs e)
281281
ThreadHelper.ThrowIfNotOnUIThread();
282282
try
283283
{
284+
var workspaceFolder = GetWorkspaceFolder();
285+
var fileName = ExpandPlaceholders(TerminalSettings.ExternalCommand, workspaceFolder);
286+
var arguments = ExpandPlaceholders(TerminalSettings.ExternalArguments, workspaceFolder);
287+
288+
_logger?.Log($"Launching external terminal: \"{fileName}\" {arguments} (cwd: {workspaceFolder})");
289+
284290
Process.Start(new ProcessStartInfo
285291
{
286-
FileName = "cmd.exe",
287-
Arguments = "/k copilot",
288-
WorkingDirectory = GetWorkspaceFolder(),
292+
FileName = fileName,
293+
Arguments = arguments,
294+
WorkingDirectory = workspaceFolder,
289295
UseShellExecute = true
290296
});
291297
}
@@ -295,6 +301,16 @@ private void OnLaunchCopilotCli(object sender, EventArgs e)
295301
}
296302
}
297303

304+
// Replaces {WorkspaceFolder} with the current solution directory.
305+
// Some terminals (e.g. wt.exe) ignore the parent process WorkingDirectory,
306+
// so users can pass the folder explicitly via arguments.
307+
private static string ExpandPlaceholders(string value, string workspaceFolder)
308+
{
309+
return string.IsNullOrEmpty(value)
310+
? value
311+
: value.Replace("{WorkspaceFolder}", workspaceFolder);
312+
}
313+
298314
private void OnShowCopilotCliWindow(object sender, EventArgs e)
299315
{
300316
_ = JoinableTaskFactory.RunAsync(async () =>

src/CopilotCliIde/TerminalSettings.cs

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,45 @@ internal static class TerminalSettings
99
public const string DefaultFontFamily = "Cascadia Code";
1010
public const short DefaultFontSize = 12;
1111

12+
public const string DefaultExternalCommand = "cmd.exe";
13+
public const string DefaultExternalArguments = "/k copilot";
14+
1215
private const string CollectionPath = "CopilotCliIde\\Terminal";
16+
private const string ExternalCollectionPath = "CopilotCliIde\\ExternalTerminal";
17+
18+
public static string FontFamily => GetString(CollectionPath, TerminalSettingsProvider.FontFamilyKey, DefaultFontFamily);
19+
public static short FontSize => (short)Math.Max(6, Math.Min(72, GetInt32(CollectionPath, TerminalSettingsProvider.FontSizeKey, DefaultFontSize)));
20+
public static string ExternalCommand => GetString(ExternalCollectionPath, TerminalSettingsProvider.ExternalCommandKey, DefaultExternalCommand, requireNonBlank: true);
21+
public static string ExternalArguments => GetString(ExternalCollectionPath, TerminalSettingsProvider.ExternalArgumentsKey, DefaultExternalArguments);
1322

14-
public static string FontFamily
23+
private static string GetString(string collection, string key, string defaultValue, bool requireNonBlank = false)
1524
{
16-
get
25+
try
1726
{
18-
try
27+
var store = GetStore();
28+
if (store != null && store.CollectionExists(collection) && store.PropertyExists(collection, key))
1929
{
20-
var store = GetStore();
21-
if (store != null && store.CollectionExists(CollectionPath) && store.PropertyExists(CollectionPath, TerminalSettingsProvider.FontFamilyKey))
22-
return store.GetString(CollectionPath, TerminalSettingsProvider.FontFamilyKey);
30+
var value = store.GetString(collection, key);
31+
if (!requireNonBlank || !string.IsNullOrWhiteSpace(value))
32+
return value;
2333
}
24-
catch { /* Ignore */ }
25-
26-
return DefaultFontFamily;
2734
}
35+
catch { /* Ignore */ }
36+
37+
return defaultValue;
2838
}
2939

30-
public static short FontSize
40+
private static int GetInt32(string collection, string key, int defaultValue)
3141
{
32-
get
42+
try
3343
{
34-
try
35-
{
36-
var store = GetStore();
37-
if (store != null && store.CollectionExists(CollectionPath) && store.PropertyExists(CollectionPath, TerminalSettingsProvider.FontSizeKey))
38-
{
39-
var size = store.GetInt32(CollectionPath, TerminalSettingsProvider.FontSizeKey);
40-
return (short)Math.Max(6, Math.Min(72, size));
41-
}
42-
}
43-
catch { /* Ignore */ }
44-
45-
return DefaultFontSize;
44+
var store = GetStore();
45+
if (store != null && store.CollectionExists(collection) && store.PropertyExists(collection, key))
46+
return store.GetInt32(collection, key);
4647
}
48+
catch { /* Ignore */ }
49+
50+
return defaultValue;
4751
}
4852

4953
private static WritableSettingsStore? GetStore()

src/CopilotCliIde/TerminalSettingsProvider.cs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ internal sealed class TerminalSettingsProvider : IExternalSettingsProvider
1111
public const string ServiceGuid = "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e";
1212

1313
private const string CollectionPath = "CopilotCliIde\\Terminal";
14+
private const string ExternalCollectionPath = "CopilotCliIde\\ExternalTerminal";
1415
public const string FontFamilyKey = "FontFamily";
1516
public const string FontSizeKey = "FontSize";
16-
private const string FontFamilyMoniker = "fontFamily";
17-
private const string FontSizeMoniker = "fontSize";
17+
public const string ExternalCommandKey = "Command";
18+
public const string ExternalArgumentsKey = "Arguments";
1819

1920
private readonly WritableSettingsStore _store;
2021

@@ -30,24 +31,45 @@ public TerminalSettingsProvider(WritableSettingsStore store)
3031
_store = store;
3132
if (!_store.CollectionExists(CollectionPath))
3233
_store.CreateCollection(CollectionPath);
34+
if (!_store.CollectionExists(ExternalCollectionPath))
35+
_store.CreateCollection(ExternalCollectionPath);
3336
}
3437

38+
// Monikers in registration.json mirror the storage keys with a lowercase first letter
39+
// (e.g. "FontFamily" -> "fontFamily"). Treat them as the same identifier in two casings.
40+
private static string ToMoniker(string key) => char.ToLowerInvariant(key[0]) + key.Substring(1);
41+
42+
private static bool Matches(string moniker, string key)
43+
=> moniker.EndsWith(ToMoniker(key), StringComparison.OrdinalIgnoreCase);
44+
3545
public Task<ExternalSettingOperationResult<T>> GetValueAsync<T>(string moniker, CancellationToken cancellationToken) where T : notnull
3646
{
3747
object? value = null;
3848

39-
if (moniker.EndsWith(FontFamilyMoniker, StringComparison.OrdinalIgnoreCase))
49+
if (Matches(moniker, FontFamilyKey))
4050
{
4151
value = _store.PropertyExists(CollectionPath, FontFamilyKey)
4252
? _store.GetString(CollectionPath, FontFamilyKey)
4353
: TerminalSettings.DefaultFontFamily;
4454
}
45-
else if (moniker.EndsWith(FontSizeMoniker, StringComparison.OrdinalIgnoreCase))
55+
else if (Matches(moniker, FontSizeKey))
4656
{
4757
value = _store.PropertyExists(CollectionPath, FontSizeKey)
4858
? _store.GetInt32(CollectionPath, FontSizeKey)
4959
: TerminalSettings.DefaultFontSize;
5060
}
61+
else if (Matches(moniker, ExternalCommandKey))
62+
{
63+
value = _store.PropertyExists(ExternalCollectionPath, ExternalCommandKey)
64+
? _store.GetString(ExternalCollectionPath, ExternalCommandKey)
65+
: TerminalSettings.DefaultExternalCommand;
66+
}
67+
else if (Matches(moniker, ExternalArgumentsKey))
68+
{
69+
value = _store.PropertyExists(ExternalCollectionPath, ExternalArgumentsKey)
70+
? _store.GetString(ExternalCollectionPath, ExternalArgumentsKey)
71+
: TerminalSettings.DefaultExternalArguments;
72+
}
5173

5274
return value is not null
5375
? ExternalSettingOperationResult.SuccessResultTask((T)value)
@@ -56,14 +78,22 @@ public Task<ExternalSettingOperationResult<T>> GetValueAsync<T>(string moniker,
5678

5779
public Task<ExternalSettingOperationResult> SetValueAsync<T>(string moniker, T value, CancellationToken cancellationToken) where T : notnull
5880
{
59-
if (moniker.EndsWith(FontFamilyMoniker, StringComparison.OrdinalIgnoreCase) && value is string s)
81+
if (Matches(moniker, FontFamilyKey) && value is string s)
6082
{
6183
_store.SetString(CollectionPath, FontFamilyKey, s);
6284
}
63-
else if (moniker.EndsWith(FontSizeMoniker, StringComparison.OrdinalIgnoreCase) && value is int i)
85+
else if (Matches(moniker, FontSizeKey) && value is int i)
6486
{
6587
_store.SetInt32(CollectionPath, FontSizeKey, Math.Max(6, Math.Min(72, i)));
6688
}
89+
else if (Matches(moniker, ExternalCommandKey) && value is string cmd)
90+
{
91+
_store.SetString(ExternalCollectionPath, ExternalCommandKey, cmd);
92+
}
93+
else if (Matches(moniker, ExternalArgumentsKey) && value is string args)
94+
{
95+
_store.SetString(ExternalCollectionPath, ExternalArgumentsKey, args);
96+
}
6797
else
6898
{
6999
return Task.FromResult<ExternalSettingOperationResult>(
@@ -75,7 +105,7 @@ public Task<ExternalSettingOperationResult> SetValueAsync<T>(string moniker, T v
75105

76106
public async Task<ExternalSettingOperationResult<IReadOnlyList<EnumChoice>>> GetEnumChoicesAsync(string enumSettingMoniker, CancellationToken cancellationToken)
77107
{
78-
if (!enumSettingMoniker.EndsWith(FontFamilyMoniker, StringComparison.OrdinalIgnoreCase))
108+
if (!Matches(enumSettingMoniker, FontFamilyKey))
79109
return ExternalSettingOperationResult.SuccessResult<IReadOnlyList<EnumChoice>>([]);
80110

81111
await Task.Yield();

src/CopilotCliIde/registration.json

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,28 @@
44
"title": "Copilot CLI IDE Bridge"
55
},
66
"copilotCliIde.terminal": {
7-
"title": "Terminal",
7+
"title": "Embedded Terminal",
88
"order": 1
9+
},
10+
"copilotCliIde.externalTerminal": {
11+
"title": "External Terminal",
12+
"order": 2,
13+
"messages": [
14+
{
15+
"text": "The external terminal is launched in the working directory of the current solution. Use the {WorkspaceFolder} placeholder in Command or Arguments when a terminal (like Windows Terminal) does not inherit the working directory.",
16+
"severity": "informational"
17+
},
18+
{
19+
"text": "Examples — cmd.exe: '/k copilot' • wt.exe: '-d \"{WorkspaceFolder}\" cmd /k copilot' • pwsh.exe: '-NoExit -Command copilot' • powershell.exe: '-NoExit -Command copilot'",
20+
"severity": "informational"
21+
}
22+
]
923
}
1024
},
1125
"properties": {
1226
"copilotCliIde.terminal.externalSettings": {
1327
"type": "external",
14-
"title": "Terminal",
28+
"title": "Embedded Terminal",
1529
"backingStoreDescription": "Stored in Visual Studio settings.",
1630
"callback": {
1731
"packageId": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
@@ -32,6 +46,28 @@
3246
"description": "Font size (in points) used for the embedded terminal."
3347
}
3448
}
49+
},
50+
"copilotCliIde.externalTerminal.externalSettings": {
51+
"type": "external",
52+
"title": "External Terminal",
53+
"backingStoreDescription": "Stored in Visual Studio settings.",
54+
"callback": {
55+
"packageId": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
56+
"serviceId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
57+
},
58+
"realtimeNotifications": true,
59+
"properties": {
60+
"command": {
61+
"type": "string",
62+
"title": "Command",
63+
"description": "Executable used to launch Copilot CLI from the Tools menu. Any executable on PATH or full path is accepted. Common shells: cmd.exe, powershell.exe, pwsh.exe, wt.exe."
64+
},
65+
"arguments": {
66+
"type": "string",
67+
"title": "Arguments",
68+
"description": "Arguments passed to the command. Use {WorkspaceFolder} to substitute the current solution directory. Examples: cmd.exe → '/k copilot'; wt.exe → '-d \"{WorkspaceFolder}\" cmd /k copilot'; pwsh.exe → '-NoExit -Command copilot'."
69+
}
70+
}
3571
}
3672
}
3773
}

0 commit comments

Comments
 (0)