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
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
terminalZshOhMy: z.boolean().optional(),
terminalZshP10k: z.boolean().optional(),
terminalZdotdir: z.boolean().optional(),
terminalProfile: z.string().optional(),
execaShellPath: z.string().optional(),

diagnosticsEnabled: z.boolean().optional(),
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface ExtensionMessage {
| "commandExecutionStatus"
| "mcpExecutionStatus"
| "vsCodeSetting"
| "terminalProfiles"
| "authenticatedUser"
| "condenseTaskContextStarted"
| "condenseTaskContextResponse"
Expand Down Expand Up @@ -153,6 +154,8 @@ export interface ExtensionMessage {
error?: string
setting?: string
value?: any // eslint-disable-line @typescript-eslint/no-explicit-any
/** Sanitized VS Code terminal profile names for the `terminalProfiles` message. */
profiles?: string[]
hasContent?: boolean
items?: MarketplaceItem[]
userInfo?: CloudUserInfo
Expand Down Expand Up @@ -276,6 +279,7 @@ export type ExtensionState = Pick<
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "terminalProfile"
| "execaShellPath"
| "diagnosticsEnabled"
| "language"
Expand Down Expand Up @@ -454,6 +458,7 @@ export interface WebviewMessage {
| "updateVSCodeSetting"
| "getVSCodeSetting"
| "vsCodeSetting"
| "requestTerminalProfiles"
| "updateCondensingPrompt"
| "playSound"
| "playTts"
Expand Down
5 changes: 5 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ export class ClineProvider
terminalZshP10k = false,
terminalPowershellCounter = false,
terminalZdotdir = false,
terminalProfile,
ttsEnabled,
ttsSpeed,
}) => {
Expand All @@ -757,6 +758,7 @@ export class ClineProvider
Terminal.setTerminalZshP10k(terminalZshP10k)
Terminal.setPowershellCounter(terminalPowershellCounter)
Terminal.setTerminalZdotdir(terminalZdotdir)
Terminal.setTerminalProfile(terminalProfile)
Comment thread
edelauna marked this conversation as resolved.
setTtsEnabled(ttsEnabled ?? false)
setTtsSpeed(ttsSpeed ?? 1)
},
Expand Down Expand Up @@ -2049,6 +2051,7 @@ export class ClineProvider
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
terminalProfile,
mcpEnabled,
currentApiConfigName,
listApiConfigMeta,
Expand Down Expand Up @@ -2201,6 +2204,7 @@ export class ClineProvider
terminalZshOhMy: terminalZshOhMy ?? false,
terminalZshP10k: terminalZshP10k ?? false,
terminalZdotdir: terminalZdotdir ?? false,
terminalProfile,
mcpEnabled: mcpEnabled ?? true,
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
Expand Down Expand Up @@ -2404,6 +2408,7 @@ export class ClineProvider
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
terminalZshP10k: stateValues.terminalZshP10k ?? false,
terminalZdotdir: stateValues.terminalZdotdir ?? false,
terminalProfile: stateValues.terminalProfile,
mode: stateValues.mode ?? defaultModeSlug,
language: stateValues.language ?? formatLanguage(vscode.env.language),
mcpEnabled: stateValues.mcpEnabled ?? true,
Expand Down
15 changes: 15 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Task, TaskOptions } from "../../task/Task"
import { safeWriteJson } from "../../../utils/safeWriteJson"

import { ClineProvider } from "../ClineProvider"
import { Terminal } from "../../../integrations/terminal/Terminal"
import { MessageManager } from "../../message-manager"

// Mock setup must come before imports.
Expand Down Expand Up @@ -471,6 +472,20 @@ describe("ClineProvider", () => {
expect(ClineProvider.getVisibleInstance()).toBe(provider)
})

test("resolveWebviewView hydrates the saved terminalProfile into the process-wide Terminal state", async () => {
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {})
// Seed the persisted setting so the real getState() returns it during hydration.
await (provider as any).contextProxy.setValue("terminalProfile", "Git Bash")

await provider.resolveWebviewView(mockWebviewView)
// The hydration runs in a getState().then(...) callback, so flush microtasks.
await new Promise((resolve) => setImmediate(resolve))

expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash")

setTerminalProfileSpy.mockRestore()
})

test("resolveWebviewView sets up webview correctly", async () => {
await provider.resolveWebviewView(mockWebviewView)

Expand Down
33 changes: 33 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ vi.mock("../../mentions/resolveImageMentions", () => ({
}))

import { resolveImageMentions } from "../../mentions/resolveImageMentions"
import { Terminal } from "../../../integrations/terminal/Terminal"

describe("webviewMessageHandler - requestLmStudioModels", () => {
beforeEach(() => {
Expand Down Expand Up @@ -860,6 +861,38 @@ describe("webviewMessageHandler - mcpEnabled", () => {
})
})

describe("webviewMessageHandler - terminalProfile", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("bridges a saved terminalProfile from updateSettings into the process-wide terminal state", async () => {
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {})

await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { terminalProfile: "Git Bash" },
})

expect(setTerminalProfileSpy).toHaveBeenCalledWith("Git Bash")

setTerminalProfileSpy.mockRestore()
})

it("clears the terminal profile when updateSettings sends undefined", async () => {
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile").mockImplementation(() => {})

await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { terminalProfile: undefined },
})

expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined)

setTerminalProfileSpy.mockRestore()
})
})

describe("webviewMessageHandler - requestCommands", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
34 changes: 34 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ export const webviewMessageHandler = async (
if (value !== undefined) {
Terminal.setTerminalZdotdir(value as boolean)
}
} else if (key === "terminalProfile") {
Terminal.setTerminalProfile(value as string | undefined)
Comment thread
edelauna marked this conversation as resolved.
} else if (key === "execaShellPath") {
Terminal.setExecaShellPath(value as string | undefined)
} else if (key === "mcpEnabled") {
Expand Down Expand Up @@ -1510,6 +1512,38 @@ export const webviewMessageHandler = async (

break

case "requestTerminalProfiles": {
// Allowlisted request: read VS Code's terminal profiles server-side and
// return only the sanitized profile names. The terminal profile dropdown
// only needs names, so this avoids routing it through the generic
// `getVSCodeSetting` handler (which reads any key the webview supplies).
try {
const names = new Set<string>()

for (const platform of ["windows", "osx", "linux"] as const) {
const profiles = vscode.workspace
.getConfiguration("terminal.integrated.profiles")
.get<Record<string, unknown>>(platform)

if (profiles && typeof profiles === "object") {
for (const name of Object.keys(profiles)) {
names.add(name)
}
}
}

await provider.postMessageToWebview({
type: "terminalProfiles",
profiles: Array.from(names).sort(),
})
Comment on lines +1523 to +1538
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Limit returned profile names to the active platform.

Collecting from windows, osx, and linux exposes non-resolvable profiles on the current machine, which can lead to confusing no-op selections.

Proposed fix
-				const names = new Set<string>()
-
-				for (const platform of ["windows", "osx", "linux"] as const) {
-					const profiles = vscode.workspace
-						.getConfiguration("terminal.integrated.profiles")
-						.get<Record<string, unknown>>(platform)
-
-					if (profiles && typeof profiles === "object") {
-						for (const name of Object.keys(profiles)) {
-							names.add(name)
-						}
-					}
-				}
+				const platform =
+					process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux"
+				const profiles = vscode.workspace
+					.getConfiguration("terminal.integrated.profiles")
+					.get<Record<string, unknown>>(platform)
+				const names =
+					profiles && typeof profiles === "object" ? Object.keys(profiles).sort() : []

 				await provider.postMessageToWebview({
 					type: "terminalProfiles",
-					profiles: Array.from(names).sort(),
+					profiles: names,
 				})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const platform of ["windows", "osx", "linux"] as const) {
const profiles = vscode.workspace
.getConfiguration("terminal.integrated.profiles")
.get<Record<string, unknown>>(platform)
if (profiles && typeof profiles === "object") {
for (const name of Object.keys(profiles)) {
names.add(name)
}
}
}
await provider.postMessageToWebview({
type: "terminalProfiles",
profiles: Array.from(names).sort(),
})
const platform =
process.platform === "win32" ? "windows" : process.platform === "darwin" ? "osx" : "linux"
const profiles = vscode.workspace
.getConfiguration("terminal.integrated.profiles")
.get<Record<string, unknown>>(platform)
const names =
profiles && typeof profiles === "object" ? Object.keys(profiles).sort() : []
await provider.postMessageToWebview({
type: "terminalProfiles",
profiles: names,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/webview/webviewMessageHandler.ts` around lines 1523 - 1538, The code
currently iterates ["windows","osx","linux"] and aggregates terminal profile
names across all platforms; change it to only read profiles for the active
runtime platform so we don't surface non-resolvable names. Detect the active
platform (e.g. using process.platform and map
"win32"→"windows","darwin"→"osx","linux"→"linux"), then call
vscode.workspace.getConfiguration("terminal.integrated.profiles").get<Record<string,
unknown>>(mappedPlatform) and add keys from that single object into the existing
names Set before calling provider.postMessageToWebview({ type:
"terminalProfiles", profiles: Array.from(names).sort() }).

} catch (error) {
console.error("Failed to get terminal profiles:", error)
await provider.postMessageToWebview({ type: "terminalProfiles", profiles: [] })
}

break
}
Comment on lines +1515 to +1545
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateSettings -> Terminal.setTerminalProfile path has tests, but does this handler itself have any? The dedup+sort logic, the catch fallback (empty-array response), and edge cases like an all-null profiles object all seem untested -- worth adding a case to the webviewMessageHandler - terminalProfile suite that sends requestTerminalProfiles and asserts on what gets posted back.


case "mode":
await provider.handleModeSwitch(message.text as Mode)
break
Expand Down
19 changes: 19 additions & 0 deletions src/integrations/terminal/BaseTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export abstract class BaseTerminal implements RooTerminal {
private static terminalZshOhMy: boolean = false
private static terminalZshP10k: boolean = false
private static terminalZdotdir: boolean = false
private static terminalProfile: string | undefined = undefined
private static execaShellPath: string | undefined = undefined

/**
Expand Down Expand Up @@ -296,6 +297,24 @@ export abstract class BaseTerminal implements RooTerminal {
return BaseTerminal.terminalZdotdir
}

/**
* Sets the name of the VS Code terminal profile to use for the inline
* (shell-integration) terminal. An empty/undefined value falls back to
* VS Code's default terminal behavior.
* @param profile The terminal profile name, or undefined for the default
*/
public static setTerminalProfile(profile: string | undefined): void {
BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined
}
Comment on lines +306 to +308
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Persist the normalized profile name, not the raw input.

Line 307 validates with trim() but stores the untrimmed string. A value like " Git Bash " passes validation but won’t match profile keys later.

Suggested fix
 public static setTerminalProfile(profile: string | undefined): void {
-	BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined
+	const normalized = profile?.trim()
+	BaseTerminal.terminalProfile = normalized && normalized.length > 0 ? normalized : undefined
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static setTerminalProfile(profile: string | undefined): void {
BaseTerminal.terminalProfile = profile && profile.trim().length > 0 ? profile : undefined
}
public static setTerminalProfile(profile: string | undefined): void {
const normalized = profile?.trim()
BaseTerminal.terminalProfile = normalized && normalized.length > 0 ? normalized : undefined
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/terminal/BaseTerminal.ts` around lines 306 - 308, The setter
currently validates using profile.trim() but stores the original untrimmed
string; update BaseTerminal.setTerminalProfile to persist a normalized value
(use const normalized = profile?.trim();) and set BaseTerminal.terminalProfile
to normalized if normalized.length > 0, otherwise undefined, so stored profile
keys match lookups (reference: BaseTerminal.setTerminalProfile and
BaseTerminal.terminalProfile).


/**
* Gets the name of the VS Code terminal profile to use for the inline terminal.
* @returns The terminal profile name, or undefined when the default should be used
*/
public static getTerminalProfile(): string | undefined {
return BaseTerminal.terminalProfile
}

public static setExecaShellPath(shellPath: string | undefined): void {
BaseTerminal.execaShellPath = shellPath
}
Expand Down
134 changes: 133 additions & 1 deletion src/integrations/terminal/Terminal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from "fs"

import * as vscode from "vscode"
import pWaitFor from "p-wait-for"

Expand All @@ -17,7 +19,34 @@ export class Terminal extends BaseTerminal {

const env = Terminal.getEnv()
const iconPath = new vscode.ThemeIcon("rocket")
this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env })

if (terminal) {
this.terminal = terminal
} else {
const options: vscode.TerminalOptions = { cwd, name: "Roo Code", iconPath, env }

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

if (profileShell?.shellPath) {
options.shellPath = profileShell.shellPath

if (profileShell.shellArgs) {
options.shellArgs = profileShell.shellArgs
}

// Merge the profile's own env on top of the base env so profile-specific
// variables (e.g. locale/PATH) are not lost. A `null` value unsets one.
if (profileShell.env) {
options.env = { ...env, ...profileShell.env }
}
}

this.terminal = vscode.window.createTerminal(options)
}

if (Terminal.getTerminalZdotdir()) {
ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR)
Expand Down Expand Up @@ -191,4 +220,107 @@ export class Terminal extends BaseTerminal {

return env
}

/**
* Returns the VS Code config section key (`windows`/`osx`/`linux`) used for
* platform-specific terminal profiles.
*/
private static getPlatformProfileKey(platform: NodeJS.Platform = process.platform): "windows" | "osx" | "linux" {
if (platform === "win32") {
return "windows"
}

if (platform === "darwin") {
return "osx"
}

return "linux"
}

/**
* Resolves the configured inline terminal profile (see `terminalProfile`
* setting / {@link Terminal.getTerminalProfile}) into a shell path and args by
* reading VS Code's `terminal.integrated.profiles.<platform>` configuration.
*
* This reuses VS Code's terminal profile concept so users can pick, for
* example, a Git Bash profile (UTF-8) instead of the default cmd/PowerShell
* (which may use a non-UTF-8 charset such as GBK) on Windows (#119).
*
* @returns The resolved shell path/args, or undefined when no profile is
* configured or the profile cannot be resolved (default behavior).
*/
public static getProfileShell(
platform: NodeJS.Platform = process.platform,
): { shellPath: string; shellArgs?: string[]; env?: Record<string, string | null> } | undefined {
const profileName = Terminal.getTerminalProfile()

if (!profileName) {
return undefined
}

const platformKey = Terminal.getPlatformProfileKey(platform)

const profiles = vscode.workspace
.getConfiguration("terminal.integrated.profiles")
.get<Record<string, unknown>>(platformKey)

const profile = profiles?.[profileName] as
| {
path?: string | string[]
args?: string | string[]
source?: string
env?: Record<string, unknown>
}
| null
| undefined

if (!profile) {
console.warn(`[Terminal] Configured terminal profile "${profileName}" not found for ${platformKey}.`)
return undefined
}

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

if (!pathValue) {
// Profiles defined only by `source` (e.g. "PowerShell") can't be mapped to
// a shell path here, so we fall back to the default terminal.
console.warn(
`[Terminal] Terminal profile "${profileName}" has no resolvable "path"; using default terminal.`,
)
return undefined
}

const shellArgs = Array.isArray(profile.args)
? profile.args
: typeof profile.args === "string"
? [profile.args]
: undefined

// VS Code profiles may declare their own `env` (e.g. to set a UTF-8 locale or
// a custom PATH). Preserve it so the inline terminal doesn't lose environment
// the user configured on the profile. A `null` value unsets that variable.
// Values come from user `settings.json`, so sanitize to string/null only.
let env: Record<string, string | null> | undefined

if (profile.env && typeof profile.env === "object") {
const sanitized: Record<string, string | null> = {}

for (const [key, val] of Object.entries(profile.env)) {
if (typeof val === "string" || val === null) {
sanitized[key] = val
}
}

if (Object.keys(sanitized).length > 0) {
env = sanitized
}
}

return { shellPath: pathValue, shellArgs, env }
}
}
Loading
Loading