Skip to content

Commit 63510fb

Browse files
feat(terminal): add inline terminal profile selection (#119)
Add a 'terminalProfile' setting that lets users choose which VS Code terminal profile the inline terminal uses. On Windows the default cmd/PowerShell shell may use a non-UTF-8 code page (e.g. GBK) and garble output; selecting a UTF-8 profile such as Git Bash resolves this. The setting reuses VS Code's terminal profile concept: when set, the profile name is resolved against terminal.integrated.profiles.<platform> to derive shellPath/shellArgs for createTerminal. When empty/unset the default terminal behavior is preserved unchanged. Adds backend unit tests for profile resolution and a webview test for the settings dropdown wiring.
1 parent b5c5e21 commit 63510fb

30 files changed

Lines changed: 606 additions & 2 deletions

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
176176
terminalZshOhMy: z.boolean().optional(),
177177
terminalZshP10k: z.boolean().optional(),
178178
terminalZdotdir: z.boolean().optional(),
179+
terminalProfile: z.string().optional(),
179180
execaShellPath: z.string().optional(),
180181

181182
diagnosticsEnabled: z.boolean().optional(),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export type ExtensionState = Pick<
276276
| "terminalZshOhMy"
277277
| "terminalZshP10k"
278278
| "terminalZdotdir"
279+
| "terminalProfile"
279280
| "execaShellPath"
280281
| "diagnosticsEnabled"
281282
| "language"

src/core/webview/ClineProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,7 @@ export class ClineProvider
746746
terminalZshP10k = false,
747747
terminalPowershellCounter = false,
748748
terminalZdotdir = false,
749+
terminalProfile,
749750
ttsEnabled,
750751
ttsSpeed,
751752
}) => {
@@ -757,6 +758,7 @@ export class ClineProvider
757758
Terminal.setTerminalZshP10k(terminalZshP10k)
758759
Terminal.setPowershellCounter(terminalPowershellCounter)
759760
Terminal.setTerminalZdotdir(terminalZdotdir)
761+
Terminal.setTerminalProfile(terminalProfile)
760762
setTtsEnabled(ttsEnabled ?? false)
761763
setTtsSpeed(ttsSpeed ?? 1)
762764
},
@@ -2049,6 +2051,7 @@ export class ClineProvider
20492051
terminalZshOhMy,
20502052
terminalZshP10k,
20512053
terminalZdotdir,
2054+
terminalProfile,
20522055
mcpEnabled,
20532056
currentApiConfigName,
20542057
listApiConfigMeta,
@@ -2201,6 +2204,7 @@ export class ClineProvider
22012204
terminalZshOhMy: terminalZshOhMy ?? false,
22022205
terminalZshP10k: terminalZshP10k ?? false,
22032206
terminalZdotdir: terminalZdotdir ?? false,
2207+
terminalProfile,
22042208
mcpEnabled: mcpEnabled ?? true,
22052209
currentApiConfigName: currentApiConfigName ?? "default",
22062210
listApiConfigMeta: listApiConfigMeta ?? [],
@@ -2404,6 +2408,7 @@ export class ClineProvider
24042408
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
24052409
terminalZshP10k: stateValues.terminalZshP10k ?? false,
24062410
terminalZdotdir: stateValues.terminalZdotdir ?? false,
2411+
terminalProfile: stateValues.terminalProfile,
24072412
mode: stateValues.mode ?? defaultModeSlug,
24082413
language: stateValues.language ?? formatLanguage(vscode.env.language),
24092414
mcpEnabled: stateValues.mcpEnabled ?? true,

src/core/webview/webviewMessageHandler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,8 @@ export const webviewMessageHandler = async (
726726
if (value !== undefined) {
727727
Terminal.setTerminalZdotdir(value as boolean)
728728
}
729+
} else if (key === "terminalProfile") {
730+
Terminal.setTerminalProfile(value as string | undefined)
729731
} else if (key === "execaShellPath") {
730732
Terminal.setExecaShellPath(value as string | undefined)
731733
} else if (key === "mcpEnabled") {

src/integrations/terminal/BaseTerminal.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export abstract class BaseTerminal implements RooTerminal {
161161
private static terminalZshOhMy: boolean = false
162162
private static terminalZshP10k: boolean = false
163163
private static terminalZdotdir: boolean = false
164+
private static terminalProfile: string | undefined = undefined
164165
private static execaShellPath: string | undefined = undefined
165166

166167
/**
@@ -296,6 +297,24 @@ export abstract class BaseTerminal implements RooTerminal {
296297
return BaseTerminal.terminalZdotdir
297298
}
298299

300+
/**
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.
304+
* @param profile The terminal profile name, or undefined for the default
305+
*/
306+
public static setTerminalProfile(profile: string | undefined): void {
307+
BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined
308+
}
309+
310+
/**
311+
* Gets the name of the VS Code terminal profile to use for the inline terminal.
312+
* @returns The terminal profile name, or undefined when the default should be used
313+
*/
314+
public static getTerminalProfile(): string | undefined {
315+
return BaseTerminal.terminalProfile
316+
}
317+
299318
public static setExecaShellPath(shellPath: string | undefined): void {
300319
BaseTerminal.execaShellPath = shellPath
301320
}

src/integrations/terminal/Terminal.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,28 @@ export class Terminal extends BaseTerminal {
1717

1818
const env = Terminal.getEnv()
1919
const iconPath = new vscode.ThemeIcon("rocket")
20-
this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env })
20+
21+
if (terminal) {
22+
this.terminal = terminal
23+
} else {
24+
const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env }
25+
26+
// When the user has chosen a specific terminal profile, resolve it to a
27+
// shell path/args so the inline terminal uses that shell (e.g. Git Bash
28+
// with a UTF-8 charset on Windows). When unset, we leave shellPath/shellArgs
29+
// undefined so VS Code's default terminal behavior is preserved (#119).
30+
const profileShell = Terminal.getProfileShell()
31+
32+
if (profileShell?.shellPath) {
33+
options.shellPath = profileShell.shellPath
34+
35+
if (profileShell.shellArgs) {
36+
options.shellArgs = profileShell.shellArgs
37+
}
38+
}
39+
40+
this.terminal = vscode.window.createTerminal(options)
41+
}
2142

2243
if (Terminal.getTerminalZdotdir()) {
2344
ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR)
@@ -191,4 +212,79 @@ export class Terminal extends BaseTerminal {
191212

192213
return env
193214
}
215+
216+
/**
217+
* Returns the VS Code config section key (`windows`/`osx`/`linux`) used for
218+
* platform-specific terminal profiles.
219+
*/
220+
private static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" {
221+
if (platform === "win32") {
222+
return "windows"
223+
}
224+
225+
if (platform === "darwin") {
226+
return "osx"
227+
}
228+
229+
return "linux"
230+
}
231+
232+
/**
233+
* Resolves the configured inline terminal profile (see `terminalProfile`
234+
* setting / {@link Terminal.getTerminalProfile}) into a shell path and args by
235+
* reading VS Code's `terminal.integrated.profiles.<platform>` configuration.
236+
*
237+
* This reuses VS Code's terminal profile concept so users can pick, for
238+
* example, a Git Bash profile (UTF-8) instead of the default cmd/PowerShell
239+
* (which may use a non-UTF-8 charset such as GBK) on Windows (#119).
240+
*
241+
* @returns The resolved shell path/args, or undefined when no profile is
242+
* configured or the profile cannot be resolved (default behavior).
243+
*/
244+
public static getProfileShell(
245+
platform: NodeJS.Platform = process.platform,
246+
): { shellPath: string; shellArgs?: string[] } | undefined {
247+
const profileName = Terminal.getTerminalProfile()
248+
249+
if (!profileName) {
250+
return undefined
251+
}
252+
253+
const platformKey = Terminal.getPlatformProfileKey(platform)
254+
255+
const profiles = vscode.workspace
256+
.getConfiguration("terminal.integrated.profiles")
257+
.get<Record<string, unknown>>(platformKey)
258+
259+
const profile = profiles?.[profileName] as
260+
| { path?: string | string[]; args?: string | string[]; source?: string }
261+
| null
262+
| undefined
263+
264+
if (!profile) {
265+
console.warn(`[Terminal] Configured terminal profile "${profileName}" not found for ${platformKey}.`)
266+
return undefined
267+
}
268+
269+
// A `path` may be a single string or an array of candidate paths (VS Code
270+
// picks the first that exists). We pass the first candidate to createTerminal.
271+
const pathValue = Array.isArray(profile.path) ? profile.path[0] : profile.path
272+
273+
if (!pathValue) {
274+
// Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to
275+
// a shell path here, so we fall back to the default terminal.
276+
console.warn(
277+
`[Terminal] Terminal profile "${profileName}" has no resolvable "path"; using default terminal.`,
278+
)
279+
return undefined
280+
}
281+
282+
const shellArgs = Array.isArray(profile.args)
283+
? profile.args
284+
: typeof profile.args === "string"
285+
? [profile.args]
286+
: undefined
287+
288+
return { shellPath: pathValue, shellArgs }
289+
}
194290
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// npx vitest run src/integrations/terminal/__tests__/TerminalProfile.spec.ts
2+
3+
import * as vscode from "vscode"
4+
5+
import { Terminal } from "../Terminal"
6+
import { TerminalRegistry } from "../TerminalRegistry"
7+
8+
vi.mock("execa", () => ({
9+
execa: vi.fn(),
10+
}))
11+
12+
describe("Terminal inline terminal profile (#119)", () => {
13+
let getConfigurationSpy: ReturnType<typeof vi.spyOn>
14+
let createTerminalSpy: ReturnType<typeof vi.spyOn>
15+
16+
const mockTerminal = () =>
17+
({
18+
exitStatus: undefined,
19+
name: "Roo Code",
20+
processId: Promise.resolve(123),
21+
creationOptions: {},
22+
state: { isInteractedWith: true },
23+
dispose: vi.fn(),
24+
hide: vi.fn(),
25+
show: vi.fn(),
26+
sendText: vi.fn(),
27+
shellIntegration: { executeCommand: vi.fn() },
28+
}) as any
29+
30+
// Helper to stub `terminal.integrated.profiles.<platform>` config reads.
31+
const stubProfiles = (profilesByPlatform: Record<string, unknown>) => {
32+
getConfigurationSpy = vi.spyOn(vscode.workspace, "getConfiguration").mockImplementation((section?: string) => {
33+
if (section === "terminal.integrated.profiles") {
34+
return {
35+
get: (platformKey: string) => profilesByPlatform[platformKey],
36+
} as any
37+
}
38+
39+
return { get: (_key: string, defaultValue?: unknown) => defaultValue } as any
40+
})
41+
}
42+
43+
beforeEach(() => {
44+
createTerminalSpy = vi.spyOn(vscode.window, "createTerminal").mockImplementation(() => mockTerminal())
45+
// Reset to default (unset) before each test.
46+
Terminal.setTerminalProfile(undefined)
47+
})
48+
49+
afterEach(() => {
50+
Terminal.setTerminalProfile(undefined)
51+
vi.restoreAllMocks()
52+
})
53+
54+
describe("getTerminalProfile / setTerminalProfile", () => {
55+
it("defaults to undefined", () => {
56+
expect(Terminal.getTerminalProfile()).toBeUndefined()
57+
})
58+
59+
it("stores a profile name", () => {
60+
Terminal.setTerminalProfile("Git Bash")
61+
expect(Terminal.getTerminalProfile()).toBe("Git Bash")
62+
})
63+
64+
it("treats empty/whitespace strings as unset (default behavior)", () => {
65+
Terminal.setTerminalProfile("Git Bash")
66+
Terminal.setTerminalProfile("")
67+
expect(Terminal.getTerminalProfile()).toBeUndefined()
68+
69+
Terminal.setTerminalProfile(" ")
70+
expect(Terminal.getTerminalProfile()).toBeUndefined()
71+
})
72+
})
73+
74+
describe("getProfileShell", () => {
75+
it("returns undefined when no profile is configured (default behavior preserved)", () => {
76+
stubProfiles({})
77+
expect(Terminal.getProfileShell("win32")).toBeUndefined()
78+
})
79+
80+
it("resolves a Windows Git Bash profile to its shell path and args", () => {
81+
stubProfiles({
82+
windows: {
83+
"Git Bash": {
84+
path: "C:\\Program Files\\Git\\bin\\bash.exe",
85+
args: ["--login", "-i"],
86+
},
87+
},
88+
})
89+
90+
Terminal.setTerminalProfile("Git Bash")
91+
92+
expect(Terminal.getProfileShell("win32")).toEqual({
93+
shellPath: "C:\\Program Files\\Git\\bin\\bash.exe",
94+
shellArgs: ["--login", "-i"],
95+
})
96+
})
97+
98+
it("uses the first path candidate when path is an array", () => {
99+
stubProfiles({
100+
windows: {
101+
"Git Bash": {
102+
path: ["C:\\missing\\bash.exe", "C:\\Program Files\\Git\\bin\\bash.exe"],
103+
},
104+
},
105+
})
106+
107+
Terminal.setTerminalProfile("Git Bash")
108+
109+
expect(Terminal.getProfileShell("win32")).toEqual({
110+
shellPath: "C:\\missing\\bash.exe",
111+
shellArgs: undefined,
112+
})
113+
})
114+
115+
it("wraps a string args value into an array", () => {
116+
stubProfiles({
117+
linux: {
118+
bash: { path: "/bin/bash", args: "-l" },
119+
},
120+
})
121+
122+
Terminal.setTerminalProfile("bash")
123+
124+
expect(Terminal.getProfileShell("linux")).toEqual({
125+
shellPath: "/bin/bash",
126+
shellArgs: ["-l"],
127+
})
128+
})
129+
130+
it("reads the osx profile section on darwin", () => {
131+
stubProfiles({
132+
osx: { zsh: { path: "/bin/zsh" } },
133+
})
134+
135+
Terminal.setTerminalProfile("zsh")
136+
137+
expect(Terminal.getProfileShell("darwin")).toEqual({
138+
shellPath: "/bin/zsh",
139+
shellArgs: undefined,
140+
})
141+
})
142+
143+
it("falls back to default when the configured profile is not found", () => {
144+
stubProfiles({ windows: { PowerShell: { path: "pwsh.exe" } } })
145+
146+
Terminal.setTerminalProfile("Nonexistent")
147+
148+
expect(Terminal.getProfileShell("win32")).toBeUndefined()
149+
})
150+
151+
it("falls back to default when the profile has no resolvable path (source-only profile)", () => {
152+
stubProfiles({ windows: { PowerShell: { source: "PowerShell" } } })
153+
154+
Terminal.setTerminalProfile("PowerShell")
155+
156+
expect(Terminal.getProfileShell("win32")).toBeUndefined()
157+
})
158+
})
159+
160+
describe("createTerminal integration", () => {
161+
afterEach(() => {
162+
TerminalRegistry["terminals"] = []
163+
})
164+
165+
it("does NOT pass shellPath/shellArgs when no profile is configured", () => {
166+
stubProfiles({})
167+
TerminalRegistry.createTerminal("/test/path", "vscode")
168+
169+
const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions
170+
expect(options.shellPath).toBeUndefined()
171+
expect(options.shellArgs).toBeUndefined()
172+
})
173+
174+
it("passes the resolved shellPath/shellArgs when a profile is configured", () => {
175+
stubProfiles({
176+
[Terminal["getPlatformProfileKey"](process.platform)]: {
177+
"Git Bash": { path: "/usr/bin/bash", args: ["-i"] },
178+
},
179+
})
180+
181+
Terminal.setTerminalProfile("Git Bash")
182+
TerminalRegistry.createTerminal("/test/path", "vscode")
183+
184+
const options = createTerminalSpy.mock.calls[0][0] as vscode.TerminalOptions
185+
expect(options.shellPath).toBe("/usr/bin/bash")
186+
expect(options.shellArgs).toEqual(["-i"])
187+
})
188+
})
189+
})

0 commit comments

Comments
 (0)