Skip to content

Commit 2286933

Browse files
committed
refactor(terminal): scope profile picker to VS Code integrated terminal, filter source-only profiles
1 parent 40997c6 commit 2286933

28 files changed

Lines changed: 503 additions & 153 deletions

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export interface WebviewMessage {
459459
| "getVSCodeSetting"
460460
| "vsCodeSetting"
461461
| "requestTerminalProfiles"
462+
| "openTerminalProfilePicker"
462463
| "updateCondensingPrompt"
463464
| "playSound"
464465
| "playTts"

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ vi.mock("vscode", () => {
105105
openTextDocument,
106106
getConfiguration: vi.fn(() => ({ get: vi.fn() })),
107107
},
108+
commands: {
109+
executeCommand: vi.fn().mockResolvedValue(undefined),
110+
},
108111
}
109112
})
110113

@@ -897,17 +900,48 @@ describe("webviewMessageHandler - terminalProfile", () => {
897900
describe("webviewMessageHandler - requestTerminalProfiles", () => {
898901
beforeEach(() => {
899902
vi.clearAllMocks()
903+
vi.spyOn(Terminal, "resolveProfilePath").mockImplementation((profilePath) => {
904+
const candidates = Array.isArray(profilePath) ? profilePath : [profilePath]
905+
const value = candidates.find(
906+
(candidate) =>
907+
typeof candidate === "string" && candidate.trim().length > 0 && !candidate.includes("missing"),
908+
)
909+
return typeof value === "string" ? value.trim() : undefined
910+
})
911+
})
912+
913+
afterEach(() => {
914+
vi.restoreAllMocks()
900915
})
901916

902-
it("posts sorted profile names for the active platform", async () => {
903-
const mockGet = vi.fn().mockReturnValue({ "Git Bash": {}, PowerShell: {}, "Command Prompt": {} })
917+
it("posts sorted path-resolvable profile names for the active platform", async () => {
918+
const mockGet = vi.fn().mockReturnValue({
919+
"Git Bash": { path: "C:\\Git\\bin\\bash.exe" },
920+
bash: { path: "/bin/bash" },
921+
PowerShell: { source: "PowerShell" }, // source-only — must be excluded
922+
})
904923
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any)
905924

906925
await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" })
907926

908927
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
909928
type: "terminalProfiles",
910-
profiles: ["Command Prompt", "Git Bash", "PowerShell"],
929+
profiles: ["Git Bash", "bash"],
930+
})
931+
})
932+
933+
it("excludes source-only profiles that have no path field", async () => {
934+
const mockGet = vi.fn().mockReturnValue({
935+
PowerShell: { source: "PowerShell" },
936+
"Windows PowerShell": { source: "PowerShell" },
937+
})
938+
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any)
939+
940+
await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" })
941+
942+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
943+
type: "terminalProfiles",
944+
profiles: [],
911945
})
912946
})
913947

@@ -935,6 +969,64 @@ describe("webviewMessageHandler - requestTerminalProfiles", () => {
935969
profiles: [],
936970
})
937971
})
972+
973+
it("excludes profiles with empty or whitespace-only path strings", async () => {
974+
const mockGet = vi.fn().mockReturnValue({
975+
empty: { path: "" },
976+
whitespace: { path: " " },
977+
valid: { path: "/bin/bash" },
978+
})
979+
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any)
980+
981+
await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" })
982+
983+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
984+
type: "terminalProfiles",
985+
profiles: ["valid"],
986+
})
987+
})
988+
989+
it("excludes profiles with path arrays containing only empty or whitespace strings", async () => {
990+
const mockGet = vi.fn().mockReturnValue({
991+
emptyArray: { path: [] },
992+
whitespaceArray: { path: ["", " "] },
993+
valid: { path: ["/bin/bash", "/usr/bin/bash"] },
994+
})
995+
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any)
996+
997+
await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" })
998+
999+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
1000+
type: "terminalProfiles",
1001+
profiles: ["valid"],
1002+
})
1003+
})
1004+
1005+
it("excludes profiles whose executable cannot be resolved", async () => {
1006+
const mockGet = vi.fn().mockReturnValue({
1007+
missing: { path: "/missing/bash" },
1008+
valid: { path: "/bin/bash" },
1009+
})
1010+
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: mockGet } as any)
1011+
1012+
await webviewMessageHandler(mockClineProvider, { type: "requestTerminalProfiles" })
1013+
1014+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
1015+
type: "terminalProfiles",
1016+
profiles: ["valid"],
1017+
})
1018+
})
1019+
})
1020+
1021+
describe("webviewMessageHandler - openTerminalProfilePicker", () => {
1022+
beforeEach(() => {
1023+
vi.clearAllMocks()
1024+
})
1025+
1026+
it("executes the VS Code selectDefaultShell command", async () => {
1027+
await webviewMessageHandler(mockClineProvider, { type: "openTerminalProfilePicker" })
1028+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.selectDefaultShell")
1029+
})
9381030
})
9391031

9401032
describe("webviewMessageHandler - requestCommands", () => {

src/core/webview/webviewMessageHandler.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,12 @@ export const webviewMessageHandler = async (
13111311

13121312
break
13131313
}
1314+
case "openTerminalProfilePicker": {
1315+
// Open VS Code's native terminal profile picker so the user can set the
1316+
// default shell without leaving VS Code's own settings UI.
1317+
await vscode.commands.executeCommand("workbench.action.terminal.selectDefaultShell")
1318+
break
1319+
}
13141320
case "openKeyboardShortcuts": {
13151321
// Open VSCode keyboard shortcuts settings and optionally filter to show the Roo Code commands
13161322
const searchQuery = message.text || ""
@@ -1517,18 +1523,30 @@ export const webviewMessageHandler = async (
15171523
// return only the sanitized profile names. The terminal profile dropdown
15181524
// only needs names, so this avoids routing it through the generic
15191525
// `getVSCodeSetting` handler (which reads any key the webview supplies).
1526+
// Only profiles with a resolvable `path` are returned — source-only
1527+
// profiles (e.g. { source: "PowerShell" }) cannot be mapped to a shell
1528+
// binary by an extension and would silently fall back to the default.
15201529
try {
15211530
const names = new Set<string>()
15221531

1523-
const platformKey =
1524-
process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux"
1532+
const platformKey = Terminal.getPlatformProfileKey()
15251533
const profiles = vscode.workspace
15261534
.getConfiguration("terminal.integrated.profiles")
15271535
.get<Record<string, unknown>>(platformKey)
15281536

15291537
if (profiles && typeof profiles === "object") {
1530-
for (const name of Object.keys(profiles)) {
1531-
names.add(name)
1538+
for (const [name, entry] of Object.entries(profiles)) {
1539+
if (!entry || typeof entry !== "object") {
1540+
continue
1541+
}
1542+
1543+
const { path } = entry as { path?: unknown }
1544+
1545+
// Source-only profiles and paths that cannot be found on disk or
1546+
// PATH are excluded because the override would fail at launch.
1547+
if (Terminal.resolveProfilePath(path)) {
1548+
names.add(name)
1549+
}
15321550
}
15331551
}
15341552

src/integrations/terminal/BaseTerminal.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,9 @@ export abstract class BaseTerminal implements RooTerminal {
298298
}
299299

300300
/**
301-
* Sets the name of the VS Code terminal profile to use for the inline
302-
* (shell-integration) terminal. An empty/undefined value falls back to
303-
* VS Code's default terminal behavior.
301+
* Sets the name of the VS Code terminal profile to use for the integrated
302+
* terminal. An empty/undefined value falls back to VS Code's default terminal
303+
* behavior.
304304
* @param profile The terminal profile name, or undefined for the default
305305
*/
306306
public static setTerminalProfile(profile: string | undefined): void {
@@ -309,7 +309,7 @@ export abstract class BaseTerminal implements RooTerminal {
309309
}
310310

311311
/**
312-
* Gets the name of the VS Code terminal profile to use for the inline terminal.
312+
* Gets the name of the VS Code terminal profile to use for the integrated terminal.
313313
* @returns The terminal profile name, or undefined when the default should be used
314314
*/
315315
public static getTerminalProfile(): string | undefined {

src/integrations/terminal/Terminal.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync } from "fs"
2+
import * as path from "path"
23

34
import * as vscode from "vscode"
45
import pWaitFor from "p-wait-for"
@@ -25,10 +26,10 @@ export class Terminal extends BaseTerminal {
2526
} else {
2627
const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env }
2728

28-
// When the user has chosen a specific terminal profile, resolve it to a
29-
// shell path/args/env so the inline terminal uses that shell (e.g. Git Bash
30-
// with a UTF-8 charset on Windows). When unset, we leave shellPath/shellArgs
31-
// undefined so VS Code's default terminal behavior is preserved (#119).
29+
// When the user has chosen a VS Code terminal profile, resolve it to a
30+
// shell path/args/env so the integrated terminal uses that shell. When
31+
// unset, shellPath/shellArgs are left undefined so VS Code's default
32+
// terminal behavior is preserved.
3233
const profileShell = Terminal.getProfileShell()
3334

3435
if (profileShell?.shellPath) {
@@ -225,7 +226,7 @@ export class Terminal extends BaseTerminal {
225226
* Returns the VS Code config section key (`windows`/`osx`/`linux`) used for
226227
* platform-specific terminal profiles.
227228
*/
228-
private static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" {
229+
public static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" {
229230
if (platform === "win32") {
230231
return "windows"
231232
}
@@ -238,13 +239,69 @@ export class Terminal extends BaseTerminal {
238239
}
239240

240241
/**
241-
* Resolves the configured inline terminal profile (see `terminalProfile`
242+
* Resolves a profile path to an executable on disk. VS Code's built-in Unix
243+
* profiles commonly use bare command names such as `bash`, so check PATH in
244+
* addition to explicit filesystem paths.
245+
*/
246+
public static resolveProfilePath(
247+
profilePath: unknown,
248+
platform: NodeJS.Platform = process.platform,
249+
env: NodeJS.ProcessEnv = process.env,
250+
): string | undefined {
251+
const candidates = Array.isArray(profilePath) ? profilePath : [profilePath]
252+
const pathValue = env.PATH ?? env.Path ?? env.path
253+
const pathEntries = pathValue?.split(platform === "win32" ? ";" : ":") ?? []
254+
255+
for (const value of candidates) {
256+
if (typeof value !== "string") {
257+
continue
258+
}
259+
260+
const candidate = value.trim()
261+
262+
if (!candidate) {
263+
continue
264+
}
265+
266+
if (/[\\/]/.test(candidate)) {
267+
if (existsSync(candidate)) {
268+
return candidate
269+
}
270+
271+
continue
272+
}
273+
274+
const extensions =
275+
platform === "win32" && path.extname(candidate) === ""
276+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")
277+
: [""]
278+
279+
for (const entry of pathEntries) {
280+
const directory = entry.replace(/^"(.*)"$/, "$1")
281+
282+
for (const extension of extensions) {
283+
const resolved = path.join(directory, `${candidate}${extension}`)
284+
285+
if (existsSync(resolved)) {
286+
return resolved
287+
}
288+
}
289+
}
290+
}
291+
292+
return undefined
293+
}
294+
295+
/**
296+
* Resolves the configured VS Code terminal profile (see `terminalProfile`
242297
* setting / {@link Terminal.getTerminalProfile}) into a shell path and args by
243298
* reading VS Code's `terminal.integrated.profiles.<platform>` configuration.
244299
*
245300
* This reuses VS Code's terminal profile concept so users can pick, for
246-
* example, a Git Bash profile (UTF-8) instead of the default cmd/PowerShell
247-
* (which may use a non-UTF-8 charset such as GBK) on Windows (#119).
301+
* example, a Git Bash profile instead of the default shell. Only profiles
302+
* with a resolvable `path` are supported; source-only profiles (e.g.
303+
* `{ source: "PowerShell" }`) cannot be mapped to a shell binary by an
304+
* extension and return undefined.
248305
*
249306
* @returns The resolved shell path/args, or undefined when no profile is
250307
* configured or the profile cannot be resolved (default behavior).
@@ -279,12 +336,7 @@ export class Terminal extends BaseTerminal {
279336
return undefined
280337
}
281338

282-
// A `path` may be a single string or an array of candidate paths. VS Code
283-
// picks the first candidate that exists on disk, so mirror that: prefer the
284-
// first existing path, otherwise fall back to the first non-empty candidate.
285-
const candidates = Array.isArray(profile.path) ? profile.path : [profile.path]
286-
const nonEmpty = candidates.filter((p): p is string => typeof p === "string" && p.length > 0)
287-
const pathValue = nonEmpty.find((p) => existsSync(p)) ?? nonEmpty[0]
339+
const pathValue = Terminal.resolveProfilePath(profile.path, platform)
288340

289341
if (!pathValue) {
290342
// Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to

0 commit comments

Comments
 (0)