Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/common/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
BranchInfo,
WorktreeIntegrationStatus,
MemoryEmbeddingProgress,
ShellInfo,
} from '@common/types';

export interface ApplicationAPI {
Expand Down Expand Up @@ -183,6 +184,7 @@ export interface ApplicationAPI {

// Terminal operations
isTerminalSupported: () => boolean;
getAvailableShells: () => Promise<ShellInfo[]>;
createTerminal: (baseDir: string, taskId: string, cols?: number, rows?: number) => Promise<string>;
writeToTerminal: (terminalId: string, data: string) => Promise<boolean>;
resizeTerminal: (terminalId: string, cols: number, rows: number) => Promise<boolean>;
Expand Down
9 changes: 9 additions & 0 deletions src/common/locales/en.json
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.

Seems like there has been unwanted changes in the file.

Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@
"aider": "Aider",
"agents": "Agents",
"tasks": "Tasks",
"terminal": "Terminal",
"memory": "Memory",
"voice": "Voice",
"hotkeys": "Hotkeys",
Expand Down Expand Up @@ -443,6 +444,14 @@
"showTaskStateActions": "Show Task State Actions",
"showTaskStateActionsTooltip": "When enabled, task state actions are displayed at the bottom of messages. Disable to hide these actions."
},
"terminal": {
"shell": "Terminal Shell",
"shellPath": "Shell Path",
"autoDetect": "Auto Detect",
"customShellPath": "Custom Shell Path",
"customShellPathPlaceholder": "e.g. /bin/zsh or C:\\Program Files\\Git\\bin\\bash.exe",
"customShellPathDescription": "Enter a custom shell path if your preferred shell is not in the list."
},
"language": "Language",
"notifications": {
"title": "Notifications"
Expand Down
9 changes: 9 additions & 0 deletions src/common/locales/zh.json
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.

Also in here, lots of the unnecessary changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yep, my bad. Will fix.

Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@
"server": "服务器",
"memory": "记忆",
"tasks": "任务",
"terminal": "终端",
"voice": "语音",
"about": "关于"
},
Expand Down Expand Up @@ -442,6 +443,14 @@
"showTaskStateActions": "显示任务状态操作",
"showTaskStateActionsTooltip": "启用后,任务状态操作将显示在消息底部。禁用以隐藏这些操作。"
},
"terminal": {
"shell": "终端 Shell",
"shellPath": "Shell 路径",
"autoDetect": "自动检测",
"customShellPath": "自定义 Shell 路径",
"customShellPathPlaceholder": "例如 /bin/zsh 或 C:\\Program Files\\Git\\bin\\bash.exe",
"customShellPathDescription": "如果列表中没有您首选的 shell,请输入自定义 shell 路径。"
},
"language": "语言",
"notifications": {
"title": "通知"
Expand Down
9 changes: 9 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,9 @@ export interface SettingsData {
};
memory: MemoryConfig;
taskSettings: TaskSettings;
terminal: {
shell: string;
};
hotkeyConfig?: HotkeyConfig;
}

Expand Down Expand Up @@ -898,3 +901,9 @@ export interface BranchInfo {
isCurrent: boolean;
hasWorktree: boolean;
}

export interface ShellInfo {
path: string;
name: string;
args?: string[];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The args property is populated in getAvailableShells but it's not used by the frontend or the TerminalManager. The TerminalManager has its own logic to determine shell arguments. To reduce complexity and remove dead code, this property should be removed. This would also allow for the removal of the getShellArgs method in src/main/utils/shell.ts.

}
5 changes: 5 additions & 0 deletions src/main/events-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { getDefaultProjectSettings, getEffectiveEnvironmentVariable, getFilePath
import { AIDER_DESK_TMP_DIR, LOGS_DIR } from '@/constants';
import { EventManager } from '@/events';
import { isElectron } from '@/app';
import { getAvailableShells, ShellInfo } from '@/utils/shell';

export class EventsHandler {
constructor(
Expand Down Expand Up @@ -912,4 +913,8 @@ export class EventsHandler {
getMemoryEmbeddingProgress() {
return this.memoryManager.getProgress();
}

getAvailableShells(): ShellInfo[] {
return getAvailableShells();
}
}
4 changes: 4 additions & 0 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,4 +460,8 @@ export const setupIpcHandlers = (eventsHandler: EventsHandler, serverController:
ipcMain.handle('clipboard-write-text', async (_, text: string) => {
clipboard.writeText(text);
});

ipcMain.handle('get-available-shells', async () => {
return eventsHandler.getAvailableShells();
});
};
2 changes: 1 addition & 1 deletion src/main/managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const initManagers = async (store: Store, mainWindow: BrowserWindow | nul
);

// Initialize terminal manager
const terminalManager = new TerminalManager(eventManager, worktreeManager, telemetryManager);
const terminalManager = new TerminalManager(eventManager, worktreeManager, store, telemetryManager);

// Initialize Versions Manager
const versionsManager = new VersionsManager(eventManager, store);
Expand Down
9 changes: 9 additions & 0 deletions src/main/server/rest-api/settings-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export class SettingsApi extends BaseApi {
}),
);

// Get available shells
router.get(
'/settings/available-shells',
this.handleRequest(async (_, res) => {
const shells = this.eventsHandler.getAvailableShells();
res.status(200).json(shells);
}),
);

// Update settings
router.post(
'/settings',
Expand Down
10 changes: 10 additions & 0 deletions src/main/store/migrations/v17-to-v18.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { SettingsData } from '@common/types';

export const migrateSettingsV17toV18 = async (settings: SettingsData): Promise<SettingsData> => {
if (!settings.terminal) {
settings.terminal = {
shell: '',
};
}
return settings;
};
15 changes: 14 additions & 1 deletion src/main/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { migrateProvidersV13toV14 } from '@/store/migrations/v13-to-v14';
import { migrateSettingsV14toV15 } from '@/store/migrations/v14-to-v15';
import { migrateSettingsV15toV16 } from '@/store/migrations/v15-to-v16';
import { migrateSettingsV16toV17 } from '@/store/migrations/v16-to-v17';
import { migrateSettingsV17toV18 } from '@/store/migrations/v17-to-v18';

export const DEFAULT_SETTINGS: SettingsData = {
language: 'en',
Expand Down Expand Up @@ -87,6 +88,9 @@ export const DEFAULT_SETTINGS: SettingsData = {
autoGenerateTaskName: true,
showTaskStateActions: true,
},
terminal: {
shell: '',
},
};

const compareBaseDirs = (baseDir1: string, baseDir2: string): boolean => {
Expand All @@ -104,7 +108,7 @@ interface StoreSchema {
userId?: string;
}

const CURRENT_SETTINGS_VERSION = 17;
const CURRENT_SETTINGS_VERSION = 18;

export class Store {
// @ts-expect-error expected to be initialized
Expand Down Expand Up @@ -172,6 +176,10 @@ export class Store {
...DEFAULT_SETTINGS.taskSettings,
...settings?.taskSettings,
},
terminal: {
...DEFAULT_SETTINGS.terminal,
...settings?.terminal,
},
};
}

Expand Down Expand Up @@ -268,6 +276,11 @@ export class Store {
settingsVersion = 17;
}

if (settingsVersion === 17) {
settings = await migrateSettingsV17toV18(settings);
settingsVersion = 18;
}

this.store.set('settings', settings as SettingsData);
this.store.set('openProjects', openProjects || []);
this.store.set('providers', providers);
Expand Down
23 changes: 16 additions & 7 deletions src/main/terminal/terminal-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as os from 'os';
import * as path from 'path';

import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
import { v4 as uuidv4 } from 'uuid';
Expand All @@ -7,6 +8,7 @@ import logger from '@/logger';
import { TelemetryManager } from '@/telemetry';
import { EventManager } from '@/events';
import { WorktreeManager } from '@/worktrees/worktree-manager';
import { Store } from '@/store';

export interface TerminalInstance {
id: string;
Expand All @@ -23,10 +25,16 @@ export class TerminalManager {
constructor(
private readonly eventManager: EventManager,
private readonly worktreeManager: WorktreeManager,
private readonly store: Store,
private readonly telemetryManager?: TelemetryManager,
) {}

private getShellCommand(): string {
const settings = this.store.getSettings();
if (settings.terminal?.shell) {
return settings.terminal.shell;
}

const platform = os.platform();

if (platform === 'win32') {
Expand All @@ -39,14 +47,15 @@ export class TerminalManager {
}

private getShellArgs(): string[] {
const platform = os.platform();
const shell = this.getShellCommand();
const shellName = path.basename(shell).toLowerCase();

if (platform === 'win32') {
const shell = this.getShellCommand();
if (shell.includes('powershell')) {
return ['-NoLogo'];
}
return [];
if (shellName.includes('powershell') || shellName.includes('pwsh')) {
return ['-NoLogo'];
}

if (shellName === 'bash' || shellName === 'zsh' || shellName === 'fish' || shellName === 'sh') {
return ['-i'];
}

return [];
Expand Down
72 changes: 71 additions & 1 deletion src/main/utils/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const execAsync = promisify(exec);
let cachedPath: string | null = null;
let isFirstCall: boolean = true;

interface ShellInfo {
export interface ShellInfo {
path: string;
name: string;
args?: string[];
Expand Down Expand Up @@ -205,6 +205,72 @@ class ShellDetector {
return false;
}
}

/**
* Get all available shells on the system
* @returns Array of available shells
*/
static getAvailableShells(): ShellInfo[] {
const shells: ShellInfo[] = [];
const platform = process.platform;

if (platform === 'win32') {
const pwshPath = this.findExecutable('pwsh.exe');
if (pwshPath) shells.push({ path: pwshPath, name: 'pwsh' });

const powershellPath = this.findExecutable('powershell.exe');
if (powershellPath) shells.push({ path: powershellPath, name: 'powershell' });

const cmdPath = path.join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe');
if (fs.existsSync(cmdPath)) shells.push({ path: cmdPath, name: 'cmd' });

// Git Bash
const gitBashPath = path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'bin', 'bash.exe');
if (fs.existsSync(gitBashPath)) shells.push({ path: gitBashPath, name: 'git-bash' });
} else {
const commonShells = [
'/usr/local/bin/zsh',
'/bin/zsh',
'/usr/bin/zsh',
'/usr/local/bin/fish',
'/usr/bin/fish',
'/usr/local/bin/bash',
'/bin/bash',
'/usr/bin/bash',
'/bin/sh',
'/usr/bin/sh',
];

const seen = new Set<string>();
for (const shellPath of commonShells) {
if (fs.existsSync(shellPath) && !seen.has(shellPath)) {
const name = path.basename(shellPath);
shells.push({ path: shellPath, name, args: this.getShellArgs(name) });
seen.add(shellPath);
}
}

// Also check /etc/shells
try {
if (fs.existsSync('/etc/shells')) {
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.

This does not seem like a good approach. It returns bunch of commands that are not really shells in my system.

Image

const content = fs.readFileSync('/etc/shells', 'utf8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && fs.existsSync(trimmed) && !seen.has(trimmed)) {
const name = path.basename(trimmed);
shells.push({ path: trimmed, name, args: this.getShellArgs(name) });
seen.add(trimmed);
}
}
}
} catch {
// Ignore
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Swallowing errors without logging can hide potential issues and make debugging harder. For instance, if reading /etc/shells fails due to permissions, it would be helpful to see this in the logs. Please consider logging the error.

Suggested change
} catch {
// Ignore
}
} catch (error) {
logger.warn('Failed to read /etc/shells, continuing without it.', { error: error instanceof Error ? error.message : String(error) });
}

}

return shells;
}
}

/**
Expand Down Expand Up @@ -629,6 +695,10 @@ export const findExecutableInPath = (executable: string): string | null => {
return null;
};

export const getAvailableShells = (): ShellInfo[] => {
return ShellDetector.getAvailableShells();
};

// Wrapper for execAsync that includes enhanced PATH
export const execWithShellPath = async (command: string, options?: { cwd?: string; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }> => {
const shellPath = getShellPath();
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ const api: ApplicationAPI = {

// Terminal operations
isTerminalSupported: () => true,
getAvailableShells: () => ipcRenderer.invoke('get-available-shells'),
createTerminal: (baseDir, taskId, cols, rows) => ipcRenderer.invoke('terminal-create', baseDir, taskId, cols, rows),
writeToTerminal: (terminalId, data) => ipcRenderer.invoke('terminal-write', terminalId, data),
resizeTerminal: (terminalId, cols, rows) => ipcRenderer.invoke('terminal-resize', terminalId, cols, rows),
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/__tests__/mocks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AgentProfile,
MemoryEntry,
MemoryEmbeddingProgress,
ShellInfo,
} from '@common/types';

/**
Expand Down Expand Up @@ -194,6 +195,7 @@ export const createMockApi = (overrides: Partial<ApplicationAPI> = {}): MockedOb

// Terminal operations
isTerminalSupported: vi.fn((): boolean => true),
getAvailableShells: vi.fn((): Promise<ShellInfo[]> => Promise.resolve([])),
createTerminal: vi.fn((): Promise<string> => Promise.resolve('mock-terminal-id')),
writeToTerminal: vi.fn((): Promise<boolean> => Promise.resolve(true)),
resizeTerminal: vi.fn((): Promise<boolean> => Promise.resolve(true)),
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/api/browser-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
MemoryEmbeddingProgress,
WorktreeIntegrationStatus,
WorktreeIntegrationStatusUpdatedData,
ShellInfo,
} from '@common/types';
import { ApplicationAPI } from '@common/api';
import axios, { type AxiosInstance } from 'axios';
Expand Down Expand Up @@ -708,6 +709,9 @@ export class BrowserApi implements ApplicationAPI {
isTerminalSupported(): boolean {
return false;
}
getAvailableShells(): Promise<ShellInfo[]> {
return this.get('/settings/available-shells');
}
createTerminal(baseDir: string, taskId: string, cols?: number, rows?: number): Promise<string> {
void baseDir;
void taskId;
Expand Down
Loading
Loading