Skip to content

Commit 75d07ab

Browse files
Thewookie-Engclaude
andcommitted
feat(desktop): shell integration for PowerShell and Git Bash on Windows
The agent terminal was refused with NO_SHELL_INTEGRATION on Windows because prompt hooks existed only for zsh and bash. PowerShell now gets the same OSC 633 markers: `prompt` reports the directory, previous exit code and prompt start, and `PSConsoleHostReadLine` reports each command line and its start, as Windows Terminal and VS Code do. The hooks travel as -EncodedCommand because dot-sourcing a file is blocked by the default execution policy on Windows clients, and ReadLine is called with two arguments only: PSReadLine 2.0's third parameter is a CancellationToken, and a bool coerces to a cancelled one, which made the prompt loop. Git for Windows' bash is offered through a Windows-only Terminal Shell submenu when Git is installed; the existing bash hooks work there, with cygpath translating the reported directory to a Windows path. The choice persists as `terminalShell` in settings. Also: the marker parser unescapes `\\` so Windows paths survive the hooks' escaping, tab titles split on either separator, tmux probing is skipped on Windows, and detectShell recognises `.exe` names on any host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent de8f5be commit 75d07ab

9 files changed

Lines changed: 259 additions & 36 deletions

File tree

‎apps/desktop/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ Build on a Windows host (electron-builder cross-compiles NSIS from macOS/Linux o
111111
- Signing: set `CSC_LINK`/`CSC_KEY_PASSWORD` to an Authenticode `.pfx`; unsigned builds trigger SmartScreen on first launch.
112112
- Bun does not run electron's `postinstall` on Windows; if `node_modules/electron/dist/electron.exe` is missing after `bun install`, run `node node_modules/electron/install.js` once.
113113

114-
Windows parity gaps (deliberate, see "Known caveats"): no auto-update (the updater is a no-op off macOS — the installer must be re-downloaded), no Help-menu docs search, no Chrome cookie/password import, no Terminal.app/iTerm2 theme import, and the agent terminal launches PowerShell uninstrumented (shell integration is zsh/bash only), so the agent is refused with `NO_SHELL_INTEGRATION` while the user's terminal still works.
114+
The agent terminal launches PowerShell (`pwsh`, else Windows PowerShell 5.1) with full shell integration: the hooks are passed as `-EncodedCommand` (a dot-sourced file would be blocked by the default execution policy), `prompt` is wrapped to report the directory, exit code and prompt start, and `PSConsoleHostReadLine` is wrapped to report each command line and its start — the arrangement Windows Terminal and VS Code use. Git for Windows' bash is offered under the app menu's **Terminal Shell** submenu when Git is installed; it uses the existing bash hooks, with `cygpath -w` translating the reported directory to a Windows path. The choice persists as `terminalShell` in `settings.json`. tmux integration is skipped on Windows.
115+
116+
Windows parity gaps (deliberate, see "Known caveats"): no auto-update (the updater is a no-op off macOS — the installer must be re-downloaded), no Help-menu docs search, no Chrome cookie/password import, and no Terminal.app/iTerm2 theme import.
115117

116118
Local unsigned pre-release share: `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.
117119

‎apps/desktop/src/main/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-b
33
import { createLogger } from '@sim/logger'
44
import { isLoopbackHostname } from '@sim/security/ssrf'
55
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
6+
import type { WindowsTerminalShell } from '@/main/terminal/default-shell'
67

78
/** settings.json is meant to be readable when a user opens it. */
89
const SETTINGS_INDENT = 2
@@ -114,6 +115,8 @@ export interface DesktopSettings {
114115
terminalTheme?: TerminalAppearanceTheme
115116
/** Device-wide default zoom for built-in terminal canvases. */
116117
terminalDefaultZoom?: DesktopZoomPercent
118+
/** Windows only: which installed shell new terminals launch. */
119+
terminalShell?: WindowsTerminalShell
117120
/**
118121
* Top-level sites visited in the dedicated agent-browser profile. This is
119122
* local inference metadata only; no cookies, credentials, or account data

‎apps/desktop/src/main/index.ts‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ import {
8080
} from '@/main/session-lifecycle'
8181
import { setShellTheme } from '@/main/shell-theme'
8282
import { attachTelemetryPolicy } from '@/main/telemetry-policy'
83+
import {
84+
findGitBash,
85+
getPreferredWindowsShell,
86+
isWindowsTerminalShell,
87+
setPreferredWindowsShell,
88+
} from '@/main/terminal/default-shell'
8389
import { TerminalRegistry } from '@/main/terminal/registry'
8490
import { installTray, type TrayHandle } from '@/main/tray'
8591
import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater'
@@ -147,6 +153,10 @@ function main(): void {
147153
),
148154
})
149155
const scopeEvents = new ScopedEventRouter()
156+
if (process.platform === 'win32') {
157+
const storedShell = config.get('terminalShell')
158+
if (isWindowsTerminalShell(storedShell)) setPreferredWindowsShell(storedShell)
159+
}
150160
const terminal = new TerminalRegistry({
151161
load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined,
152162
save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot),
@@ -844,25 +854,39 @@ function main(): void {
844854
},
845855
})
846856
await ensureMainWindow()
847-
installApplicationMenu({
848-
config,
849-
getMainWindow,
850-
isMainWindow: (win) => windows.has(win) && !win.isDestroyed(),
851-
allowHttpLocalhost,
852-
openSettings,
853-
openServerSettings: () => serverWindow.open(),
854-
newWindow: () => void createAndLoadAppWindow(),
855-
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
856-
handleFocusedResourceShortcut: (win, shortcut) =>
857-
handleFocusedBrowserShortcut(shortcut, win) ||
858-
terminal.handleFocusedShortcut(win, shortcut),
859-
toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'),
860-
openSearch: () => getMainWindow()?.webContents.send('desktop:command', 'open-search'),
861-
signOut: signOutFromMenu,
862-
checkForUpdates: () =>
863-
checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }),
864-
openDiagnostics: () => shell.showItemInFolder(events.filePath),
865-
})
857+
// Rebuilt after a shell choice so the radio reflects it; Electron menus
858+
// are immutable once built.
859+
const installMenu = () =>
860+
installApplicationMenu({
861+
config,
862+
getMainWindow,
863+
isMainWindow: (win) => windows.has(win) && !win.isDestroyed(),
864+
allowHttpLocalhost,
865+
openSettings,
866+
openServerSettings: () => serverWindow.open(),
867+
newWindow: () => void createAndLoadAppWindow(),
868+
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
869+
handleFocusedResourceShortcut: (win, shortcut) =>
870+
handleFocusedBrowserShortcut(shortcut, win) ||
871+
terminal.handleFocusedShortcut(win, shortcut),
872+
toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'),
873+
openSearch: () => getMainWindow()?.webContents.send('desktop:command', 'open-search'),
874+
signOut: signOutFromMenu,
875+
checkForUpdates: () =>
876+
checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }),
877+
openDiagnostics: () => shell.showItemInFolder(events.filePath),
878+
terminalShell: {
879+
current: getPreferredWindowsShell,
880+
gitBashAvailable: () => findGitBash() !== null,
881+
select: (choice) => {
882+
setPreferredWindowsShell(choice)
883+
config.set('terminalShell', choice)
884+
config.flush()
885+
installMenu()
886+
},
887+
},
888+
})
889+
installMenu()
866890
installDocumentationHelpSearch()
867891
setTrayEnabled(config.get('trayEnabled') ?? true)
868892
updater = initUpdater({

‎apps/desktop/src/main/menu.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
FocusedResourceShortcut,
99
ResourceTabSelectionShortcut,
1010
} from '@/main/resource-shortcuts'
11+
import { WINDOWS_TERMINAL_SHELLS, type WindowsTerminalShell } from '@/main/terminal/default-shell'
1112

1213
const ZOOM_STEP = 0.5
1314

@@ -35,6 +36,38 @@ export interface MenuDeps {
3536
signOut: () => void
3637
checkForUpdates: () => void
3738
openDiagnostics: () => void
39+
/**
40+
* Windows only: the shell new terminals launch. macOS has one answer
41+
* (`$SHELL`), so the submenu exists only where there is a choice to make.
42+
*/
43+
terminalShell?: {
44+
current: () => WindowsTerminalShell
45+
gitBashAvailable: () => boolean
46+
select: (shell: WindowsTerminalShell) => void
47+
}
48+
}
49+
50+
const TERMINAL_SHELL_LABELS: Record<WindowsTerminalShell, string> = {
51+
powershell: 'PowerShell',
52+
'git-bash': 'Git Bash',
53+
}
54+
55+
function terminalShellSubmenu(deps: MenuDeps): MenuItemConstructorOptions[] {
56+
const choice = deps.terminalShell
57+
if (!choice || process.platform !== 'win32') return []
58+
const current = choice.current()
59+
return [
60+
{
61+
label: 'Terminal Shell',
62+
submenu: WINDOWS_TERMINAL_SHELLS.map((shell) => ({
63+
label: TERMINAL_SHELL_LABELS[shell],
64+
type: 'radio' as const,
65+
checked: shell === current,
66+
enabled: shell !== 'git-bash' || choice.gitBashAvailable(),
67+
click: () => choice.select(shell),
68+
})),
69+
},
70+
]
3871
}
3972

4073
/**
@@ -199,6 +232,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
199232
},
200233
{ label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings },
201234
{ label: 'Server…', click: deps.openServerSettings },
235+
...terminalShellSubmenu(deps),
202236
{ label: 'Check for Updates…', click: deps.checkForUpdates },
203237
{ label: 'Sign Out', click: deps.signOut },
204238
{ type: 'separator' },

‎apps/desktop/src/main/terminal/default-shell.ts‎

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,36 @@
55
* convention: `COMSPEC` names cmd.exe, which is the shell of last resort, so
66
* PowerShell is preferred when it is installed — pwsh (PowerShell 7) first,
77
* then the Windows PowerShell 5.1 that ships with every supported Windows.
8+
* Git for Windows' bash is offered as an alternative when it is installed,
9+
* because the bash shell-integration hooks work in it unchanged.
810
*/
911
import { existsSync } from 'node:fs'
10-
import { delimiter, join } from 'node:path'
12+
import { delimiter, dirname, join } from 'node:path'
1113

12-
const WINDOWS_SHELL_CANDIDATES = ['pwsh.exe', 'powershell.exe'] as const
14+
export type WindowsTerminalShell = 'powershell' | 'git-bash'
15+
16+
export const WINDOWS_TERMINAL_SHELLS: readonly WindowsTerminalShell[] = ['powershell', 'git-bash']
17+
18+
export function isWindowsTerminalShell(value: unknown): value is WindowsTerminalShell {
19+
return value === 'powershell' || value === 'git-bash'
20+
}
21+
22+
const WINDOWS_POWERSHELL_CANDIDATES = ['pwsh.exe', 'powershell.exe'] as const
23+
24+
/**
25+
* The shell the next Windows terminal launches. Process-wide rather than
26+
* threaded through every service because the choice is a device preference,
27+
* like the terminal theme, and every chat scope's shells share it.
28+
*/
29+
let preferredWindowsShell: WindowsTerminalShell = 'powershell'
30+
31+
export function setPreferredWindowsShell(shell: WindowsTerminalShell): void {
32+
preferredWindowsShell = shell
33+
}
34+
35+
export function getPreferredWindowsShell(): WindowsTerminalShell {
36+
return preferredWindowsShell
37+
}
1338

1439
function findOnPath(executable: string, env: NodeJS.ProcessEnv): string | null {
1540
for (const directory of (env.PATH ?? env.Path ?? '').split(delimiter)) {
@@ -20,16 +45,54 @@ function findOnPath(executable: string, env: NodeJS.ProcessEnv): string | null {
2045
return null
2146
}
2247

48+
/**
49+
* Git for Windows' bash, or null when Git is not installed.
50+
*
51+
* Only Git's own install locations are checked. A bare `bash.exe` on PATH is
52+
* not trusted: `C:\Windows\System32\bash.exe` is the WSL launcher, which runs
53+
* a Linux VM rather than a shell on this machine.
54+
*/
55+
export function findGitBash(env: NodeJS.ProcessEnv = process.env): string | null {
56+
const roots = [
57+
env.ProgramFiles,
58+
env['ProgramFiles(x86)'],
59+
env.ProgramW6432,
60+
env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Programs') : undefined,
61+
]
62+
for (const root of roots) {
63+
if (!root) continue
64+
const candidate = join(root, 'Git', 'bin', 'bash.exe')
65+
if (existsSync(candidate)) return candidate
66+
}
67+
// A Git installed elsewhere still puts `cmd\git.exe` on PATH; bash sits
68+
// beside it in the install's `bin`.
69+
const git = findOnPath('git.exe', env)
70+
if (git) {
71+
const candidate = join(dirname(dirname(git)), 'bin', 'bash.exe')
72+
if (existsSync(candidate)) return candidate
73+
}
74+
return null
75+
}
76+
77+
function findPowerShell(env: NodeJS.ProcessEnv): string | null {
78+
for (const executable of WINDOWS_POWERSHELL_CANDIDATES) {
79+
const found = findOnPath(executable, env)
80+
if (found) return found
81+
}
82+
return null
83+
}
84+
2385
export function defaultShellPath(
2486
env: NodeJS.ProcessEnv = process.env,
25-
platform: NodeJS.Platform = process.platform
87+
platform: NodeJS.Platform = process.platform,
88+
preference: WindowsTerminalShell = preferredWindowsShell
2689
): string {
2790
if (platform !== 'win32') {
2891
return env.SHELL || '/bin/zsh'
2992
}
30-
for (const executable of WINDOWS_SHELL_CANDIDATES) {
31-
const found = findOnPath(executable, env)
32-
if (found) return found
93+
if (preference === 'git-bash') {
94+
const gitBash = findGitBash(env)
95+
if (gitBash) return gitBash
3396
}
34-
return env.COMSPEC || 'cmd.exe'
97+
return findPowerShell(env) ?? env.COMSPEC ?? 'cmd.exe'
3598
}

‎apps/desktop/src/main/terminal/session.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ export class TerminalSession {
443443
* at a glance.
444444
*/
445445
tabState(active: boolean): TerminalTabState {
446-
const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null
446+
const directory = this.cwd ? (this.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '/') : null
447447
return {
448448
terminalId: this.terminalId,
449449
// The directory, always: whether to show the running command instead is

‎apps/desktop/src/main/terminal/shell-integration.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ describe('detectShell', () => {
8686
it('recognises the shells we can instrument', () => {
8787
expect(detectShell('/bin/zsh')).toBe('zsh')
8888
expect(detectShell('/usr/local/bin/bash')).toBe('bash')
89+
expect(detectShell('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash')
90+
expect(detectShell('C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe(
91+
'powershell'
92+
)
93+
expect(detectShell('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('powershell')
8994
})
9095

9196
it('returns null for shells without hooks, leaving the terminal uninstrumented', () => {

0 commit comments

Comments
 (0)