Fix/windows issues (#471)

* fix(windows): Claude CLI detection failing on Windows

On Windows, npm installs CLI tools as .cmd batch wrappers alongside
bash scripts for Git Bash/Cygwin. Two issues prevented detection:

1. findExecutable() checked for extensionless files first, finding
   the unusable bash script before the .cmd wrapper

2. execFileSync() cannot execute .cmd files without shell: true

Changes:
- Reorder extension search to prioritize .exe/.cmd over extensionless
- Add shell: true when validating .cmd/.bat files on Windows

Both changes are Windows-specific and don't affect macOS behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(windows): terminal shortcuts and invoke Claude not working

- Fix Ctrl+T/W shortcuts not working inside terminals on Windows
  xterm.js was capturing Ctrl+T as ^T character instead of letting it
  bubble up to window handler. Added T and W to custom key handler
  bypass list in useXterm.ts

- Fix "Invoke Claude" button failing on Windows with path error
  buildCdCommand() was using single quotes which cmd.exe doesn't
  recognize. Now uses platform-appropriate quoting (double quotes
  on Windows, single quotes on Unix)

- Improve terminal auto-naming to skip common commands
  Expanded skip list to include claude, git, npm, node, python,
  and other shell/dev commands that don't represent meaningful work.
  Terminal naming should come from actual task descriptions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(windows): reduce installer size by 75% (~300MB savings)

Strip unnecessary files from Python site-packages during bundling:
- Remove googleapiclient/discovery_cache/documents (92MB cached API docs)
- Remove claude_agent_sdk/_bundled (224MB bundled Claude CLI)
- Remove pythonwin directory (9MB Windows IDE)
- Remove .chm help files (2.6MB)

The Claude Agent SDK will fall back to the system-installed Claude Code
CLI, which is already a prerequisite for Auto-Claude (required for
'claude setup-token').

Site-packages reduced from 446MB to 111MB (75% reduction).
Expected installer size: ~100MB instead of ~206MB.

* fix(frontend): sync project tabs with settings by removing project on tab close

Closing a project tab now removes the project from the app entirely,
keeping tabs and settings dropdown in sync. Files remain on disk so
users can re-add projects later.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(settings): remove redundant Claude Auth project settings tab

Claude authentication is already available in the app-level Integrations
section, making this project-level tab redundant.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(onboarding): add one-click Ollama installation for Windows/macOS/Linux

When users select Ollama as their embedding provider during onboarding
but don't have it installed, they now see an "Install Ollama" button
that opens their preferred terminal with the official install command.

Changes:
- Add checkOllamaInstalled() to detect if Ollama binary exists on system
- Add installOllama() to open terminal with platform-specific install:
  - Windows: winget install --id Ollama.Ollama
  - macOS/Linux: curl -fsSL https://ollama.com/install.sh | sh
- Update OllamaModelSelector to show install UI when Ollama not found
- Fix Windows terminal handling for commands with pipes (PowerShell)
- Use fire-and-forget pattern so UI doesn't hang waiting for terminal
- Add i18n translations (English & French) for install UI

The install uses the user's preferred terminal from the DevTools
onboarding step, respecting their configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ipc-handlers): enhance task status persistence in implementation plan

- Added functionality to persist task status updates to the implementation plan file, preventing inconsistencies between UI and file state during task refresh.
- Implemented error handling for file read/write operations to ensure robustness in status updates.
- Updated task execution handlers to reflect changes in task status, including transitions to 'human_review' and 'backlog'.
- Introduced critical comments to clarify the importance of status persistence in maintaining accurate task states.

This update improves the reliability of task status management across the application.

* fix(security): address PR review findings for Windows issues

- Fix command injection vulnerability in buildCdCommand by using
  escapeShellArgWindows() to properly escape cmd.exe metacharacters
- Add confirmation dialog before removing project on tab close
- Add documentation explaining why skipCommands list exists
- Add security comment for shell: true usage in Claude CLI validation
- Remove unused EnvironmentSettings component (dead code cleanup)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address follow-up PR review findings

- Fix command injection in Windows terminal by escaping PowerShell
  metacharacters (backticks, double quotes, dollar signs)
- Fix Git Bash to use passed command parameter instead of hardcoded value
- Add shared plan-file-utils with mutex locking for thread-safe updates
- Refactor agent-events-handlers and execution-handlers to use shared
  persistence utility, eliminating code duplication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): strengthen shell escaping and document sync limitations

- Add escaping for parentheses, semicolons, ampersands in PowerShell
- Add escaping for semicolons, pipes, exclamation marks in Git Bash
- Add comprehensive documentation warning about persistPlanStatusSync
  bypassing the async locking mechanism

Note: The CRITICAL finding about EnvironmentSettings in SectionRouter.tsx
is a false positive - grep confirms no such import exists in the file.
Line 5 imports SecuritySettings, not EnvironmentSettings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code scanning and TypeScript errors

- Fix TypeScript error in plan-file-utils.ts (generic type assertion)
- Add security comment for ollamaPath explaining hardcoded paths
- Remove useless isLoading conditional in OllamaModelSelector.tsx

Note: The EnvironmentSettings import finding remains a false positive -
grep confirms no such import exists in SectionRouter.tsx.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ui): restore Retry button disabled state for defensive programming

Restored disabled={isLoading} and animate-spin on Retry buttons.

FALSE POSITIVES in review (verified via grep/sed):
- CRITICAL "EnvironmentSettings import at line 5": Line 5 is actually
  `import { SecuritySettings }` - no EnvironmentSettings import exists
- LOW "Dead code escape functions": Functions ARE used at lines 268, 275
  in openTerminalWithCommand for PowerShell and Git Bash escaping

The review tool appears to be using cached/stale file data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address CodeQL security alerts

- Fix 6 HIGH TOCTOU race conditions by removing existsSync checks
  and using try/catch with ENOENT detection instead
- Fix MEDIUM command injection by using execFileSync instead of
  execSync for Ollama path detection (avoids shell interpretation)
- Fix unused imports in execution-handlers.ts
- Remove useless isLoading conditional and add explanatory comment
  about React batching behavior preventing double-clicks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(build): remove TOCTOU race condition in download-python script

Replace existsSync check with try/catch pattern to avoid race condition
between existence check and file operations. Handle ENOENT as no-op.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review issues for error handling and i18n

- Add error handling with toast notification for project removal in App.tsx
- Replace hardcoded strings with i18n translation keys in ProjectSettingsContent.tsx
- Add translation keys for projectSettings.noProjectSelected (en/fr)
- Add removeProject.error translation key to dialogs.json (en/fr)
- Add errors.unknownError translation key to common.json (en/fr)
- Refactor terminal command escaping in claude-code-handlers.ts
- Remove unused imports in execution-handlers.ts
- Add explanatory comment for React batching in OllamaModelSelector.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(app): replace toast with inline error display in remove project dialog

Toast function doesn't exist in this codebase. Use inline error display
with AlertCircle icon to show removal errors in the dialog itself.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-01 12:53:27 +01:00
committed by GitHub
parent 52a4fcc6d3
commit 72106109a2
30 changed files with 1139 additions and 419 deletions
+35
View File
@@ -46,6 +46,8 @@ const STRIP_PATTERNS = {
'.pytest_cache',
'.mypy_cache',
'__pypackages__',
// Windows-specific bloat
'pythonwin', // PyWin32 IDE - not needed (9MB)
],
// File extensions to remove
extensions: [
@@ -68,6 +70,7 @@ const STRIP_PATTERNS = {
'.gitignore',
'.gitattributes',
'.editorconfig',
'.chm', // Windows help files - not needed
],
// Specific files to remove
files: [
@@ -97,6 +100,12 @@ const STRIP_PATTERNS = {
'conftest.py',
'pytest.ini',
],
// Specific paths within packages to remove (relative to package directory)
// Format: 'package_name/subpath' - removes the entire subpath
packagePaths: [
'googleapiclient/discovery_cache/documents', // Cached Google API discovery docs (92MB!)
'claude_agent_sdk/_bundled', // Bundled Claude CLI (224MB!) - users have it installed separately
],
// Packages that should NEVER be bundled (too large, specialized)
// If these appear in dependencies, warn and skip
blockedPackages: [
@@ -455,6 +464,32 @@ function stripSitePackages(sitePackagesDir) {
const sizeBefore = getDirectorySize(sitePackagesDir);
let removedCount = 0;
// First, remove specific package paths (e.g., googleapiclient/discovery_cache/documents)
// Use try/catch instead of existsSync to avoid TOCTOU race conditions
if (STRIP_PATTERNS.packagePaths) {
for (const pkgPath of STRIP_PATTERNS.packagePaths) {
const fullPath = path.join(sitePackagesDir, pkgPath);
try {
// Get size first (may throw ENOENT if path doesn't exist)
let pathSize = 0;
try {
pathSize = getDirectorySize(fullPath);
} catch {
// Path doesn't exist or can't get size - skip
continue;
}
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`[download-python] Removed ${pkgPath} (${formatBytes(pathSize)})`);
removedCount++;
} catch (err) {
// ENOENT means file was already gone - not an error
if (err.code !== 'ENOENT') {
console.warn(`[download-python] Failed to remove ${pkgPath}: ${err.message}`);
}
}
}
}
function shouldRemoveDir(name) {
return STRIP_PATTERNS.dirs.includes(name.toLowerCase());
}
@@ -674,10 +674,19 @@ class CLIToolManager {
*/
private validateClaude(claudeCmd: string): ToolValidation {
try {
// On Windows, .cmd files need shell: true to execute properly.
// SECURITY NOTE: shell: true is safe here because:
// 1. claudeCmd comes from internal path detection (user config or known system paths)
// 2. Only '--version' is passed as an argument (no user input)
// If claudeCmd origin ever changes to accept user input, use escapeShellArgWindows.
const needsShell = process.platform === 'win32' &&
(claudeCmd.endsWith('.cmd') || claudeCmd.endsWith('.bat'));
const version = execFileSync(claudeCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
shell: needsShell,
}).trim();
// Claude CLI version output format: "claude-code version X.Y.Z" or similar
+3 -2
View File
@@ -157,9 +157,10 @@ export function findExecutable(command: string): string | null {
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const pathDirs = (env.PATH || '').split(pathSeparator);
// On Windows, also check with common extensions
// On Windows, check Windows-native extensions first (.exe, .cmd) before
// extensionless files (which are typically bash/sh scripts for Git Bash/Cygwin)
const extensions = process.platform === 'win32'
? ['', '.exe', '.cmd', '.bat', '.ps1']
? ['.exe', '.cmd', '.bat', '.ps1', '']
: [''];
for (const dir of pathDirs) {
@@ -1,6 +1,5 @@
import type { BrowserWindow } from 'electron';
import path from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
import type {
SDKRateLimitInfo,
@@ -15,6 +14,7 @@ import { titleGenerator } from '../title-generator';
import { fileWatcher } from '../file-watcher';
import { projectStore } from '../project-store';
import { notificationService } from '../notification-service';
import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils';
/**
@@ -92,6 +92,15 @@ export function registerAgenteventsHandlers(
if (task && project) {
const taskTitle = task.title || task.specId;
const planPath = getPlanPath(project, task);
// Use shared utility for persisting status (prevents race conditions)
const persistStatus = (status: TaskStatus) => {
const persisted = persistPlanStatusSync(planPath, status);
if (persisted) {
console.log(`[Task ${taskId}] Persisted status to plan: ${status}`);
}
};
if (code === 0) {
notificationService.notifyReviewNeeded(taskTitle, project.id, taskId);
@@ -105,6 +114,7 @@ export function registerAgenteventsHandlers(
if (isActiveStatus && !hasIncompleteSubtasks) {
console.log(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`);
persistStatus('human_review');
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
@@ -113,6 +123,7 @@ export function registerAgenteventsHandlers(
}
} else {
notificationService.notifyTaskFailed(taskTitle, project.id, taskId);
persistStatus('human_review');
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
@@ -148,6 +159,26 @@ export function registerAgenteventsHandlers(
taskId,
newStatus
);
// CRITICAL: Persist status to plan file to prevent flip-flop on task list refresh
// When getTasks() is called, it reads status from the plan file. Without persisting,
// the status in the file might differ from the UI, causing inconsistent state.
// Uses shared utility with locking to prevent race conditions.
try {
const projects = projectStore.getProjects();
for (const p of projects) {
const tasks = projectStore.getTasks(p.id);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
if (task) {
const planPath = getPlanPath(p, task);
persistPlanStatusSync(planPath, newStatus);
break;
}
}
} catch (err) {
// Ignore persistence errors - UI will still work, just might flip on refresh
console.warn('[execution-progress] Could not persist status:', err);
}
}
}
});
@@ -92,6 +92,39 @@ export function escapeAppleScriptString(str: string): string {
return str.replace(/'/g, "'\\''");
}
/**
* Escape a string for safe use in PowerShell -Command context.
* PowerShell requires escaping backticks, double quotes, dollar signs,
* parentheses, semicolons, and ampersands.
*/
export function escapePowerShellCommand(str: string): string {
return str
.replace(/`/g, '``') // Escape backticks (PowerShell escape char)
.replace(/"/g, '`"') // Escape double quotes
.replace(/\$/g, '`$') // Escape dollar signs (variable expansion)
.replace(/\(/g, '`(') // Escape opening parentheses
.replace(/\)/g, '`)') // Escape closing parentheses
.replace(/;/g, '`;') // Escape semicolons (statement separator)
.replace(/&/g, '`&'); // Escape ampersands (call operator)
}
/**
* Escape a string for safe use in Git Bash -c context.
* Bash requires escaping single quotes, double quotes, backslashes, and other metacharacters.
*/
export function escapeGitBashCommand(str: string): string {
// For bash -c with double quotes, escape: backslash, double quote, dollar, backtick,
// semicolon, pipe, and exclamation mark (all bash metacharacters that could allow command injection)
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/`/g, '\\`') // Escape backticks
.replace(/;/g, '\\;') // Escape semicolons (command separator)
.replace(/\|/g, '\\|') // Escape pipes (command piping)
.replace(/!/g, '\\!'); // Escape exclamation marks (history expansion)
}
/**
* Open a terminal with the given command
* Uses the user's preferred terminal from settings
@@ -204,58 +237,172 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
} else if (platform === 'win32') {
// Windows: Use appropriate terminal
// Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder', 'gitbash'
// Values match SupportedTerminal type: 'windowsterminal', 'powershell', 'cmd', 'conemu', 'cmder',
// 'gitbash', 'alacritty', 'wezterm', 'hyper', 'tabby', 'cygwin', 'msys2'
const terminalId = preferredTerminal?.toLowerCase() || 'powershell';
console.log('[Claude Code] Using terminal:', terminalId);
console.log('[Claude Code] Command to run:', command);
if (terminalId === 'windowsterminal') {
// Windows Terminal
spawn('wt.exe', ['powershell.exe', '-NoExit', '-Command', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'cmd') {
// Command Prompt
spawn('cmd.exe', ['/K', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'conemu') {
// ConEmu
spawn('ConEmu64.exe', ['-run', 'powershell.exe', '-NoExit', '-Command', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'cmder') {
// Cmder (ConEmu-based)
spawn('cmder.exe', ['/TASK', 'powershell', '/CMD', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'gitbash') {
// Git Bash
spawn('C:\\Program Files\\Git\\git-bash.exe', ['-c', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'hyper') {
// Hyper
spawn('hyper.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref();
// Note: Hyper doesn't support direct command execution, user needs to paste
} else if (terminalId === 'tabby') {
// Tabby
spawn('tabby.exe', [], { detached: true, stdio: 'ignore', shell: false }).unref();
} else if (terminalId === 'alacritty') {
// Alacritty on Windows
spawn('alacritty.exe', ['-e', 'powershell.exe', '-NoExit', '-Command', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else if (terminalId === 'wezterm') {
// WezTerm on Windows
spawn('wezterm.exe', ['start', '--', 'powershell.exe', '-NoExit', '-Command', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
} else {
// Default: PowerShell (handles 'powershell', 'system', or any unknown value)
spawn('powershell.exe', ['-NoExit', '-Command', command], {
detached: true, stdio: 'ignore', shell: false,
}).unref();
// For Windows, use exec with a properly formed command string
// This is more reliable than spawn for complex PowerShell commands with pipes
const { exec } = require('child_process');
const runWindowsCommand = (cmdString: string): Promise<void> => {
return new Promise((resolve) => {
console.log(`[Claude Code] Executing: ${cmdString}`);
// Fire and forget - don't wait for the terminal to close
// The -NoExit flag keeps the terminal open, so we can't wait for exec to complete
const child = exec(cmdString, { windowsHide: false });
// Detach from the child process so we don't wait for it
child.unref?.();
// Resolve immediately after starting the process
// Give it a brief moment to ensure the window opens
setTimeout(() => resolve(), 300);
});
};
try {
// Escape command for PowerShell context to prevent command injection
const escapedCommand = escapePowerShellCommand(command);
if (terminalId === 'windowsterminal') {
// Windows Terminal - open new tab with PowerShell
await runWindowsCommand(`wt new-tab powershell -NoExit -Command "${escapedCommand}"`);
} else if (terminalId === 'gitbash') {
// Git Bash - use the passed command (escaped for bash context)
const escapedBashCommand = escapeGitBashCommand(command);
const gitBashPaths = [
'C:\\Program Files\\Git\\git-bash.exe',
'C:\\Program Files (x86)\\Git\\git-bash.exe',
];
let gitBashPath = gitBashPaths.find(p => existsSync(p));
if (gitBashPath) {
await runWindowsCommand(`"${gitBashPath}" -c "${escapedBashCommand}"`);
} else {
throw new Error('Git Bash not found');
}
} else if (terminalId === 'alacritty') {
// Alacritty
await runWindowsCommand(`start alacritty -e powershell -NoExit -Command "${escapedCommand}"`);
} else if (terminalId === 'wezterm') {
// WezTerm
await runWindowsCommand(`start wezterm start -- powershell -NoExit -Command "${escapedCommand}"`);
} else if (terminalId === 'cmd') {
// Command Prompt - use cmd /k to run command and keep window open
// Note: cmd.exe uses its own escaping rules, so we pass the raw command
// and let cmd handle it. The command is typically PowerShell-formatted
// for install scripts, so we run PowerShell from cmd.
await runWindowsCommand(`start cmd /k "powershell -NoExit -Command ${escapedCommand}"`);
} else if (terminalId === 'conemu') {
// ConEmu - open with PowerShell tab running the command
const conemuPaths = [
'C:\\Program Files\\ConEmu\\ConEmu64.exe',
'C:\\Program Files (x86)\\ConEmu\\ConEmu.exe',
];
const conemuPath = conemuPaths.find(p => existsSync(p));
if (conemuPath) {
// ConEmu uses -run to specify the command to execute
await runWindowsCommand(`start "" "${conemuPath}" -run "powershell -NoExit -Command ${escapedCommand}"`);
} else {
// Fall back to PowerShell if ConEmu not found
console.warn('[Claude Code] ConEmu not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else if (terminalId === 'cmder') {
// Cmder - portable console emulator for Windows
const cmderPaths = [
'C:\\cmder\\Cmder.exe',
'C:\\tools\\cmder\\Cmder.exe',
path.join(process.env.CMDER_ROOT || '', 'Cmder.exe'),
].filter(p => p); // Remove empty paths
const cmderPath = cmderPaths.find(p => existsSync(p));
if (cmderPath) {
// Cmder uses /TASK for predefined tasks or /START for directory, but we can use /C for command
await runWindowsCommand(`start "" "${cmderPath}" /SINGLE /START "" /TASK "powershell -NoExit -Command ${escapedCommand}"`);
} else {
// Fall back to PowerShell if Cmder not found
console.warn('[Claude Code] Cmder not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else if (terminalId === 'hyper') {
// Hyper - Electron-based terminal
const hyperPaths = [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Hyper', 'Hyper.exe'),
path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'Programs', 'Hyper', 'Hyper.exe'),
];
const hyperPath = hyperPaths.find(p => existsSync(p));
if (hyperPath) {
// Launch Hyper and it will pick up the shell; send command via PowerShell since Hyper
// doesn't have a built-in way to run commands on startup
await runWindowsCommand(`start "" "${hyperPath}"`);
console.log('[Claude Code] Hyper opened - command must be pasted manually');
} else {
console.warn('[Claude Code] Hyper not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else if (terminalId === 'tabby') {
// Tabby (formerly Terminus) - modern terminal for Windows
const tabbyPaths = [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Tabby', 'Tabby.exe'),
path.join(process.env.USERPROFILE || '', 'AppData', 'Local', 'Programs', 'Tabby', 'Tabby.exe'),
];
const tabbyPath = tabbyPaths.find(p => existsSync(p));
if (tabbyPath) {
// Tabby opens with default shell; similar to Hyper, no command line arg for running commands
await runWindowsCommand(`start "" "${tabbyPath}"`);
console.log('[Claude Code] Tabby opened - command must be pasted manually');
} else {
console.warn('[Claude Code] Tabby not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else if (terminalId === 'cygwin') {
// Cygwin terminal
const cygwinPaths = [
'C:\\cygwin64\\bin\\mintty.exe',
'C:\\cygwin\\bin\\mintty.exe',
];
const cygwinPath = cygwinPaths.find(p => existsSync(p));
if (cygwinPath) {
// mintty with bash, escaping for bash context
const escapedBashCommand = escapeGitBashCommand(command);
await runWindowsCommand(`"${cygwinPath}" -e /bin/bash -lc "${escapedBashCommand}"`);
} else {
console.warn('[Claude Code] Cygwin not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else if (terminalId === 'msys2') {
// MSYS2 terminal
const msys2Paths = [
'C:\\msys64\\msys2_shell.cmd',
'C:\\msys64\\mingw64.exe',
'C:\\msys64\\usr\\bin\\mintty.exe',
];
const msys2Path = msys2Paths.find(p => existsSync(p));
if (msys2Path) {
const escapedBashCommand = escapeGitBashCommand(command);
if (msys2Path.endsWith('.cmd')) {
// Use the shell launcher script
await runWindowsCommand(`"${msys2Path}" -mingw64 -c "${escapedBashCommand}"`);
} else {
// Use mintty directly
await runWindowsCommand(`"${msys2Path}" -e /bin/bash -lc "${escapedBashCommand}"`);
}
} else {
console.warn('[Claude Code] MSYS2 not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} else {
// Default: PowerShell (handles 'powershell', 'system', or any unknown value)
// Use 'start' command to open a new PowerShell window
// The command is wrapped in double quotes and passed via -Command
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
}
} catch (err) {
console.error('[Claude Code] Terminal execution failed:', err);
throw new Error(`Failed to open terminal: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
} else {
// Linux: Use preferred terminal or try common emulators
@@ -6,9 +6,10 @@
*/
import { ipcMain, app } from 'electron';
import { spawn } from 'child_process';
import { spawn, execFileSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { IPC_CHANNELS } from '../../shared/constants';
import type {
IPCResult,
@@ -25,6 +26,7 @@ import {
import { validateOpenAIApiKey } from '../api-validation-service';
import { parsePythonCommand } from '../python-detector';
import { getConfiguredPythonPath } from '../python-env-manager';
import { openTerminalWithCommand } from './claude-code-handlers';
/**
* Ollama Service Status
@@ -85,6 +87,148 @@ interface OllamaPullResult {
output: string[]; // Log messages from pull operation
}
/**
* Ollama Installation Status
* Information about whether Ollama is installed on the system
*/
interface OllamaInstallStatus {
installed: boolean; // Whether Ollama binary is found on the system
path?: string; // Path to Ollama binary (if found)
version?: string; // Installed version (if available)
}
/**
* Check if Ollama is installed on the system by looking for the binary.
* Checks common installation paths and PATH environment variable.
*
* @returns {OllamaInstallStatus} Installation status with path if found
*/
function checkOllamaInstalled(): OllamaInstallStatus {
const platform = process.platform;
// Common paths to check based on platform
const pathsToCheck: string[] = [];
if (platform === 'win32') {
// Windows: Check common installation paths
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
pathsToCheck.push(
path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe'),
path.join(localAppData, 'Ollama', 'ollama.exe'),
'C:\\Program Files\\Ollama\\ollama.exe',
'C:\\Program Files (x86)\\Ollama\\ollama.exe'
);
} else if (platform === 'darwin') {
// macOS: Check common paths
pathsToCheck.push(
'/usr/local/bin/ollama',
'/opt/homebrew/bin/ollama',
path.join(os.homedir(), '.local', 'bin', 'ollama')
);
} else {
// Linux: Check common paths
pathsToCheck.push(
'/usr/local/bin/ollama',
'/usr/bin/ollama',
path.join(os.homedir(), '.local', 'bin', 'ollama')
);
}
// Check each path
// SECURITY NOTE: ollamaPath values come from the hardcoded pathsToCheck array above,
// not from user input or environment variables. These are known system installation paths.
for (const ollamaPath of pathsToCheck) {
if (fs.existsSync(ollamaPath)) {
// Try to get version - use execFileSync to avoid shell injection
let version: string | undefined;
try {
const versionOutput = execFileSync(ollamaPath, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
}).toString().trim();
// Parse version from output like "ollama version 0.1.23"
const match = versionOutput.match(/(\d+\.\d+\.\d+)/);
if (match) {
version = match[1];
}
} catch {
// Couldn't get version, but binary exists
}
return {
installed: true,
path: ollamaPath,
version,
};
}
}
// Also check if ollama is in PATH using where/which command
// Use execFileSync with explicit command to avoid shell injection
try {
const whichCmd = platform === 'win32' ? 'where.exe' : 'which';
const ollamaPath = execFileSync(whichCmd, ['ollama'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
}).toString().trim().split('\n')[0]; // Get first result on Windows
if (ollamaPath && fs.existsSync(ollamaPath)) {
let version: string | undefined;
try {
// Use the discovered path directly with execFileSync
const versionOutput = execFileSync(ollamaPath, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
}).toString().trim();
const match = versionOutput.match(/(\d+\.\d+\.\d+)/);
if (match) {
version = match[1];
}
} catch {
// Couldn't get version
}
return {
installed: true,
path: ollamaPath,
version,
};
}
} catch {
// Not in PATH
}
return { installed: false };
}
/**
* Get the platform-specific install command for Ollama
* Uses the official Ollama installation methods
*
* Windows: Uses winget (Windows Package Manager)
* - Official method per https://winstall.app/apps/Ollama.Ollama
* - Winget is pre-installed on Windows 10 (1709+) and Windows 11
*
* macOS/Linux: Uses official install script from https://ollama.com/download
*
* @returns {string} The install command to run in terminal
*/
function getOllamaInstallCommand(): string {
if (process.platform === 'win32') {
// Windows: Use winget (Windows Package Manager)
// This is an official installation method for Ollama on Windows
// Reference: https://winstall.app/apps/Ollama.Ollama
return 'winget install --id Ollama.Ollama --accept-source-agreements';
} else {
// macOS/Linux: Use shell script from official Ollama
// Reference: https://ollama.com/download
return 'curl -fsSL https://ollama.com/install.sh | sh';
}
}
/**
* Execute the ollama_model_detector.py Python script.
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
@@ -427,7 +571,56 @@ export function registerMemoryHandlers(): void {
};
}
}
);
);
// Check if Ollama is installed (binary exists on system)
ipcMain.handle(
IPC_CHANNELS.OLLAMA_CHECK_INSTALLED,
async (): Promise<IPCResult<OllamaInstallStatus>> => {
try {
const installStatus = checkOllamaInstalled();
return {
success: true,
data: installStatus,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check Ollama installation',
};
}
}
);
// Install Ollama (opens terminal with official install command)
ipcMain.handle(
IPC_CHANNELS.OLLAMA_INSTALL,
async (): Promise<IPCResult<{ command: string }>> => {
try {
const command = getOllamaInstallCommand();
console.log('[Ollama] Platform:', process.platform);
console.log('[Ollama] Install command:', command);
console.log('[Ollama] Opening terminal...');
await openTerminalWithCommand(command);
console.log('[Ollama] Terminal opened successfully');
return {
success: true,
data: { command },
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : '';
console.error('[Ollama] Install failed:', errorMsg);
console.error('[Ollama] Error stack:', errorStack);
return {
success: false,
error: `Failed to open terminal for installation: ${errorMsg}`,
};
}
}
);
// ============================================
// Ollama Model Discovery & Management
@@ -9,6 +9,12 @@ import { fileWatcher } from '../../file-watcher';
import { findTaskAndProject } from './shared';
import { checkGitStatus } from '../../project-initializer';
import { getClaudeProfileManager } from '../../claude-profile-manager';
import {
getPlanPath,
persistPlanStatus,
persistPlanStatusSync,
createPlanIfNotExists
} from './plan-file-utils';
/**
* Helper function to check subtask completion status
@@ -169,6 +175,18 @@ export function registerTaskExecutionHandlers(
);
}
// CRITICAL: Persist status to implementation_plan.json to prevent status flip-flop
// When getTasks() is called (on refresh), it reads status from the plan file.
// Without persisting here, the old status (e.g., 'human_review') would override
// the in-memory 'in_progress' status, causing the task to flip back and forth.
// Uses shared utility for consistency with agent-events-handlers.ts
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
const persisted = persistPlanStatusSync(planPath, 'in_progress');
if (persisted) {
console.warn('[TASK_START] Updated plan status to: in_progress');
}
// Note: Plan file may not exist yet for new tasks - that's fine (persistPlanStatusSync handles ENOENT)
// Notify status change
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
@@ -185,6 +203,20 @@ export function registerTaskExecutionHandlers(
agentManager.killTask(taskId);
fileWatcher.unwatch(taskId);
// Find task and project to update the plan file
const { task, project } = findTaskAndProject(taskId);
if (task && project) {
// Persist status to implementation_plan.json to prevent status flip-flop on refresh
// Uses shared utility for consistency with agent-events-handlers.ts
const planPath = getPlanPath(project, task);
const persisted = persistPlanStatusSync(planPath, 'backlog');
if (persisted) {
console.warn('[TASK_STOP] Updated plan status to backlog');
}
// Note: File not found is expected for tasks without a plan file (persistPlanStatusSync handles ENOENT)
}
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
@@ -381,55 +413,18 @@ export function registerTaskExecutionHandlers(
}
}
// Get the spec directory
// Get the spec directory and plan path using shared utility
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = path.join(
project.path,
specsBaseDir,
task.specId
);
// Update implementation_plan.json if it exists
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
const specDir = path.join(project.path, specsBaseDir, task.specId);
const planPath = getPlanPath(project, task);
try {
if (existsSync(planPath)) {
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
// Use shared utility for thread-safe plan file updates
const persisted = await persistPlanStatus(planPath, status);
// Store the exact UI status - project-store.ts will map it back
plan.status = status;
// Also store mapped version for Python compatibility
plan.planStatus = status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: status === 'done' ? 'completed'
: 'pending';
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
} else {
if (!persisted) {
// If no implementation plan exists yet, create a basic one
const plan = {
feature: task.title,
description: task.description || '',
created_at: task.createdAt.toISOString(),
updated_at: new Date().toISOString(),
status: status, // Store exact UI status for persistence
planStatus: status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: status === 'done' ? 'completed'
: 'pending',
phases: []
};
// Ensure spec directory exists
if (!existsSync(specDir)) {
mkdirSync(specDir, { recursive: true });
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
await createPlanIfNotExists(planPath, task, status);
}
// Auto-stop task when status changes AWAY from 'in_progress' and process IS running
@@ -0,0 +1,249 @@
/**
* Plan File Utilities
*
* Provides thread-safe operations for reading and writing implementation_plan.json files.
* Uses an in-memory lock to serialize updates and prevent race conditions when multiple
* IPC handlers try to update the same plan file concurrently.
*
* IMPORTANT LIMITATION:
* The synchronous function `persistPlanStatusSync` does NOT participate in the locking
* mechanism. It bypasses the async lock entirely, which means:
* - It can race with concurrent async operations (persistPlanStatus, updatePlanFile, etc.)
* - It should ONLY be used when you are certain no async operations are pending on the same file
* - Prefer using the async `persistPlanStatus` whenever possible
*
* If you need synchronous behavior, ensure that:
* 1. No async plan operations are in flight for the same file path
* 2. The calling context truly cannot use async/await (e.g., synchronous event handlers)
*/
import path from 'path';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import type { TaskStatus, Project, Task } from '../../../shared/types';
// In-memory locks for plan file operations
// Key: plan file path, Value: Promise chain for serializing operations
const planLocks = new Map<string, Promise<void>>();
/**
* Serialize operations on a specific plan file to prevent race conditions.
* Each operation waits for the previous one to complete before starting.
*/
async function withPlanLock<T>(planPath: string, operation: () => Promise<T>): Promise<T> {
// Get or create the lock chain for this file
const currentLock = planLocks.get(planPath) || Promise.resolve();
// Create a new promise that will resolve after our operation completes
let resolve: () => void;
const newLock = new Promise<void>((r) => { resolve = r; });
planLocks.set(planPath, newLock);
try {
// Wait for any previous operation to complete
await currentLock;
// Execute our operation
return await operation();
} finally {
// Release the lock
resolve!();
// Clean up if this was the last operation
if (planLocks.get(planPath) === newLock) {
planLocks.delete(planPath);
}
}
}
/**
* Check if an error is a "file not found" error
*/
function isFileNotFoundError(err: unknown): boolean {
return (err as NodeJS.ErrnoException).code === 'ENOENT';
}
/**
* Get the plan file path for a task
*/
export function getPlanPath(project: Project, task: Task): string {
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = path.join(project.path, specsBaseDir, task.specId);
return path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
}
/**
* Map UI TaskStatus to Python-compatible planStatus
*/
export function mapStatusToPlanStatus(status: TaskStatus): string {
switch (status) {
case 'in_progress':
return 'in_progress';
case 'ai_review':
case 'human_review':
return 'review';
case 'done':
return 'completed';
default:
return 'pending';
}
}
/**
* Persist task status to implementation_plan.json file.
* This is thread-safe and prevents race conditions when multiple handlers update the same file.
*
* @param planPath - Path to the implementation_plan.json file
* @param status - The TaskStatus to persist
* @returns true if status was persisted, false if plan file doesn't exist
*/
export async function persistPlanStatus(planPath: string, status: TaskStatus): Promise<boolean> {
return withPlanLock(planPath, async () => {
try {
// Read file directly without existence check to avoid TOCTOU race condition
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
plan.status = status;
plan.planStatus = mapStatusToPlanStatus(status);
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
return true;
} catch (err) {
// File not found is expected - return false
if (isFileNotFoundError(err)) {
return false;
}
console.warn(`[plan-file-utils] Could not persist status to ${planPath}:`, err);
return false;
}
});
}
/**
* Persist task status synchronously (for use in event handlers where async isn't practical).
*
* WARNING: This function bypasses the async locking mechanism entirely!
*
* This means it can race with concurrent async operations (persistPlanStatus, updatePlanFile,
* createPlanIfNotExists) that may be in flight for the same file. Using this function while
* async operations are pending can result in:
* - Lost updates (this write may overwrite changes from an async operation, or vice versa)
* - Corrupted JSON (if writes interleave at the filesystem level)
* - Inconsistent state between what was written and what the async operation expected to read
*
* ONLY use this function when ALL of the following conditions are met:
* 1. You are in a synchronous context that cannot use async/await (e.g., certain event handlers)
* 2. You are certain no async plan operations are pending or in-flight for this file path
* 3. No other code will initiate async plan operations until this function returns
*
* When possible, prefer using the async `persistPlanStatus` function instead, which properly
* participates in the locking mechanism and prevents race conditions.
*
* @param planPath - Path to the implementation_plan.json file
* @param status - The TaskStatus to persist
* @returns true if status was persisted, false otherwise
*/
export function persistPlanStatusSync(planPath: string, status: TaskStatus): boolean {
try {
// Read file directly without existence check to avoid TOCTOU race condition
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
plan.status = status;
plan.planStatus = mapStatusToPlanStatus(status);
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
return true;
} catch (err) {
// File not found is expected - return false
if (isFileNotFoundError(err)) {
return false;
}
console.warn(`[plan-file-utils] Could not persist status to ${planPath}:`, err);
return false;
}
}
/**
* Read and update the plan file atomically.
*
* @param planPath - Path to the implementation_plan.json file
* @param updater - Function that receives the current plan and returns the updated plan
* @returns The updated plan, or null if the file doesn't exist
*/
export async function updatePlanFile<T extends Record<string, unknown>>(
planPath: string,
updater: (plan: T) => T
): Promise<T | null> {
return withPlanLock(planPath, async () => {
try {
// Read file directly without existence check to avoid TOCTOU race condition
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent) as T;
const updatedPlan = updater(plan);
// Add updated_at timestamp - use type assertion since T extends Record<string, unknown>
(updatedPlan as Record<string, unknown>).updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2));
return updatedPlan;
} catch (err) {
// File not found is expected - return null
if (isFileNotFoundError(err)) {
return null;
}
console.warn(`[plan-file-utils] Could not update plan at ${planPath}:`, err);
return null;
}
});
}
/**
* Create a new plan file if it doesn't exist.
*
* @param planPath - Path to the implementation_plan.json file
* @param task - The task to create the plan for
* @param status - Initial status for the plan
*/
export async function createPlanIfNotExists(
planPath: string,
task: Task,
status: TaskStatus
): Promise<void> {
return withPlanLock(planPath, async () => {
// Try to read the file first - if it exists, do nothing
try {
readFileSync(planPath, 'utf-8');
return; // File exists, nothing to do
} catch (err) {
if (!isFileNotFoundError(err)) {
throw err; // Re-throw unexpected errors
}
// File doesn't exist, continue to create it
}
const plan = {
feature: task.title,
description: task.description || '',
created_at: task.createdAt.toISOString(),
updated_at: new Date().toISOString(),
status: status,
planStatus: mapStatusToPlanStatus(status),
phases: []
};
// Ensure directory exists - use try/catch pattern
const planDir = path.dirname(planPath);
try {
mkdirSync(planDir, { recursive: true });
} catch (err) {
// Directory might already exist or be created concurrently - that's fine
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
});
}
@@ -105,6 +105,12 @@ export interface ProjectAPI {
version?: string;
message?: string;
}>>;
checkOllamaInstalled: () => Promise<IPCResult<{
installed: boolean;
path?: string;
version?: string;
}>>;
installOllama: () => Promise<IPCResult<{ command: string }>>;
listOllamaModels: (baseUrl?: string) => Promise<IPCResult<{
models: Array<{
name: string;
@@ -275,6 +281,12 @@ export const createProjectAPI = (): ProjectAPI => ({
checkOllamaStatus: (baseUrl?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_CHECK_STATUS, baseUrl),
checkOllamaInstalled: () =>
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_CHECK_INSTALLED),
installOllama: () =>
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_INSTALL),
listOllamaModels: (baseUrl?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.OLLAMA_LIST_MODELS, baseUrl),
+67 -3
View File
@@ -49,7 +49,7 @@ import { OnboardingWizard } from './components/onboarding';
import { AppUpdateNotification } from './components/AppUpdateNotification';
import { ProactiveSwapListener } from './components/ProactiveSwapListener';
import { GitHubSetupModal } from './components/GitHubSetupModal';
import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store';
import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store';
import { useTaskStore, loadTasks } from './stores/task-store';
import { useSettingsStore, loadSettings } from './stores/settings-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
@@ -113,7 +113,6 @@ export function App() {
const getProjectTabs = useProjectStore((state) => state.getProjectTabs);
const openProjectIds = useProjectStore((state) => state.openProjectIds);
const openProjectTab = useProjectStore((state) => state.openProjectTab);
const closeProjectTab = useProjectStore((state) => state.closeProjectTab);
const setActiveProject = useProjectStore((state) => state.setActiveProject);
const reorderTabs = useProjectStore((state) => state.reorderTabs);
const tasks = useTaskStore((state) => state.tasks);
@@ -142,6 +141,11 @@ export function App() {
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
const [gitHubSetupProject, setGitHubSetupProject] = useState<Project | null>(null);
// Remove project confirmation state
const [showRemoveProjectDialog, setShowRemoveProjectDialog] = useState(false);
const [removeProjectError, setRemoveProjectError] = useState<string | null>(null);
const [projectToRemove, setProjectToRemove] = useState<Project | null>(null);
// Setup drag sensors
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -483,7 +487,39 @@ export function App() {
};
const handleProjectTabClose = (projectId: string) => {
closeProjectTab(projectId);
// Show confirmation dialog before removing the project
const project = projects.find(p => p.id === projectId);
if (project) {
setProjectToRemove(project);
setShowRemoveProjectDialog(true);
}
};
const handleConfirmRemoveProject = () => {
if (projectToRemove) {
try {
// Clear any previous error
setRemoveProjectError(null);
// Remove the project from the app (files are preserved on disk for re-adding later)
removeProject(projectToRemove.id);
// Only clear dialog state on success
setShowRemoveProjectDialog(false);
setProjectToRemove(null);
} catch (err) {
// Log error and keep dialog open so user can retry or cancel
console.error('[App] Failed to remove project:', err);
// Show error in dialog
setRemoveProjectError(
err instanceof Error ? err.message : t('common:errors.unknownError')
);
}
}
};
const handleCancelRemoveProject = () => {
setShowRemoveProjectDialog(false);
setProjectToRemove(null);
setRemoveProjectError(null);
};
// Handle drag start - set the active dragged project
@@ -894,6 +930,34 @@ export function App() {
/>
)}
{/* Remove Project Confirmation Dialog */}
<Dialog open={showRemoveProjectDialog} onOpenChange={(open) => {
if (!open) handleCancelRemoveProject();
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('removeProject.title')}</DialogTitle>
<DialogDescription>
{t('removeProject.description', { projectName: projectToRemove?.name || '' })}
</DialogDescription>
</DialogHeader>
{removeProjectError && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 rounded-md">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{removeProjectError}</span>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancelRemoveProject}>
{t('removeProject.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmRemoveProject}>
{t('removeProject.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rate Limit Modal - shows when Claude Code hits usage limits (terminal) */}
<RateLimitModal />
@@ -1,15 +1,19 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Check,
Download,
Loader2,
AlertCircle,
RefreshCw
RefreshCw,
ExternalLink
} from 'lucide-react';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
import { useDownloadStore } from '../../stores/download-store';
type OllamaState = 'checking' | 'not-installed' | 'not-running' | 'available';
interface OllamaModel {
name: string;
description: string;
@@ -104,10 +108,16 @@ export function OllamaModelSelector({
disabled = false,
className,
}: OllamaModelSelectorProps) {
const { t } = useTranslation('onboarding');
const [models, setModels] = useState<OllamaModel[]>(RECOMMENDED_MODELS);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [ollamaAvailable, setOllamaAvailable] = useState(true);
const [ollamaState, setOllamaState] = useState<OllamaState>('checking');
const [isInstalling, setIsInstalling] = useState(false);
const [installSuccess, setInstallSuccess] = useState(false);
// Track timeout for cleanup on unmount
const installCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Use global download store for tracking downloads
const downloads = useDownloadStore((state) => state.downloads);
@@ -116,8 +126,8 @@ export function OllamaModelSelector({
const failDownload = useDownloadStore((state) => state.failDownload);
/**
* Checks Ollama service status and fetches list of installed embedding models.
* Updates component state with installation status for each recommended model.
* Checks if Ollama is installed, running, and fetches installed models.
* Updates component state based on Ollama availability.
*
* @param {AbortSignal} [abortSignal] - Optional abort signal to cancel the request
* @returns {Promise<void>}
@@ -125,19 +135,30 @@ export function OllamaModelSelector({
const checkInstalledModels = async (abortSignal?: AbortSignal) => {
setIsLoading(true);
setError(null);
setOllamaState('checking');
try {
// Check Ollama status first
const statusResult = await window.electronAPI.checkOllamaStatus();
// First check if Ollama is installed (binary exists)
const installResult = await window.electronAPI.checkOllamaInstalled();
if (abortSignal?.aborted) return;
if (!statusResult?.success || !statusResult?.data?.running) {
setOllamaAvailable(false);
if (!installResult?.success || !installResult?.data?.installed) {
setOllamaState('not-installed');
setIsLoading(false);
return;
}
setOllamaAvailable(true);
// Ollama is installed, now check if it's running
const statusResult = await window.electronAPI.checkOllamaStatus();
if (abortSignal?.aborted) return;
if (!statusResult?.success || !statusResult?.data?.running) {
setOllamaState('not-running');
setIsLoading(false);
return;
}
setOllamaState('available');
// Get list of installed embedding models
const result = await window.electronAPI.listOllamaEmbeddingModels();
@@ -186,11 +207,46 @@ export function OllamaModelSelector({
}
};
/**
* Install Ollama by opening terminal with the official install command.
*/
const handleInstallOllama = async () => {
setIsInstalling(true);
setError(null);
try {
const result = await window.electronAPI.installOllama();
if (result?.success) {
setInstallSuccess(true);
// Clear any existing timeout before setting a new one
if (installCheckTimeoutRef.current) {
clearTimeout(installCheckTimeoutRef.current);
}
// Re-check after a delay to give user time to complete installation
installCheckTimeoutRef.current = setTimeout(() => {
checkInstalledModels();
}, 5000);
} else {
setError(result?.error || 'Failed to start Ollama installation');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to install Ollama');
} finally {
setIsInstalling(false);
}
};
// Fetch installed models on mount with cleanup
useEffect(() => {
const controller = new AbortController();
checkInstalledModels(controller.signal);
return () => controller.abort();
return () => {
controller.abort();
// Clean up the install check timeout to prevent setState on unmounted component
if (installCheckTimeoutRef.current) {
clearTimeout(installCheckTimeoutRef.current);
}
};
}, []);
// Progress is now handled globally by the download store listener initialized in App.tsx
@@ -245,15 +301,99 @@ export function OllamaModelSelector({
);
}
if (!ollamaAvailable) {
// Ollama not installed - show install option
if (ollamaState === 'not-installed') {
return (
<div className={cn('rounded-lg border border-info/30 bg-info/10 p-4', className)}>
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-info shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{t('ollama.notInstalled.title')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t('ollama.notInstalled.description')}
</p>
{/* Install success message */}
{installSuccess && (
<div className="mt-3 p-2 rounded-md bg-success/10 border border-success/30">
<p className="text-sm text-success">
{t('ollama.notInstalled.installSuccess')}
</p>
</div>
)}
{/* Error message */}
{error && (
<div className="mt-3 p-2 rounded-md bg-destructive/10 border border-destructive/30">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex items-center gap-2 mt-3">
<Button
onClick={handleInstallOllama}
disabled={isInstalling}
size="sm"
>
{isInstalling ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
{t('ollama.notInstalled.installing')}
</>
) : (
<>
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('ollama.notInstalled.installButton')}
</>
)}
</Button>
{/* Note: isLoading is always false when this block renders because we only show
this block after setIsLoading(false) is called. However, clicking Retry calls
checkInstalledModels() which immediately sets isLoading=true, triggering a
re-render that shows the loading block instead. This React batching behavior
naturally prevents double-clicks without needing the disabled prop. */}
<Button
variant="outline"
size="sm"
onClick={() => checkInstalledModels()}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t('ollama.notInstalled.retry')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => window.electronAPI?.openExternal?.('https://ollama.com')}
className="text-muted-foreground"
>
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
{t('ollama.notInstalled.learnMore')}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-3">
{t('ollama.notInstalled.fallbackNote')}
</p>
</div>
</div>
</div>
);
}
// Ollama installed but not running
if (ollamaState === 'not-running') {
return (
<div className={cn('rounded-lg border border-warning/30 bg-warning/10 p-4', className)}>
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-warning">Ollama not running</p>
<p className="text-sm font-medium text-warning">
{t('ollama.notRunning.title')}
</p>
<p className="text-sm text-warning/80 mt-1">
Start Ollama to use local embedding models. Memory will still work with keyword search.
{t('ollama.notRunning.description')}
</p>
<Button
variant="outline"
@@ -262,8 +402,11 @@ export function OllamaModelSelector({
className="mt-3"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Retry
{t('ollama.notRunning.retry')}
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('ollama.notRunning.fallbackNote')}
</p>
</div>
</div>
</div>
@@ -1,248 +0,0 @@
import { useState, useEffect } from 'react';
import {
Key,
ExternalLink,
ChevronDown,
ChevronUp,
Loader2,
Globe,
Check,
Star,
Settings,
Users
} from 'lucide-react';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { cn } from '../../lib/utils';
import type { ProjectEnvConfig, ClaudeProfile } from '../../../shared/types';
interface EnvironmentSettingsProps {
envConfig: ProjectEnvConfig | null;
isLoadingEnv: boolean;
envError: string | null;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
// Claude auth state
isCheckingClaudeAuth: boolean;
claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error';
handleClaudeSetup: () => Promise<void>;
// Password visibility (kept for interface compatibility but not used)
showClaudeToken: boolean;
setShowClaudeToken: React.Dispatch<React.SetStateAction<boolean>>;
// Collapsible section
expanded: boolean;
onToggle: () => void;
}
export function EnvironmentSettings({
envConfig,
isLoadingEnv,
envError,
isCheckingClaudeAuth,
claudeAuthStatus,
handleClaudeSetup,
expanded,
onToggle
}: EnvironmentSettingsProps) {
// Load global Claude profiles to show active account
const [claudeProfiles, setClaudeProfiles] = useState<ClaudeProfile[]>([]);
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
const [isLoadingProfiles, setIsLoadingProfiles] = useState(false);
useEffect(() => {
const loadProfiles = async () => {
setIsLoadingProfiles(true);
try {
const result = await window.electronAPI.getClaudeProfiles();
if (result.success && result.data) {
setClaudeProfiles(result.data.profiles);
setActiveProfileId(result.data.activeProfileId);
}
} catch (err) {
console.error('Failed to load Claude profiles:', err);
} finally {
setIsLoadingProfiles(false);
}
};
loadProfiles();
}, []);
const activeProfile = claudeProfiles.find(p => p.id === activeProfileId);
const hasAuthenticatedProfiles = claudeProfiles.some(p => p.oauthToken);
return (
<section className="space-y-3">
<button
onClick={onToggle}
className="w-full flex items-center justify-between text-sm font-semibold text-foreground hover:text-foreground/80"
>
<div className="flex items-center gap-2">
<Key className="h-4 w-4" />
Claude Authentication
{claudeAuthStatus === 'authenticated' && (
<span className="px-2 py-0.5 text-xs bg-success/10 text-success rounded-full">
Connected
</span>
)}
{claudeAuthStatus === 'not_authenticated' && (
<span className="px-2 py-0.5 text-xs bg-warning/10 text-warning rounded-full">
Not Connected
</span>
)}
</div>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expanded && (
<div className="space-y-4 pl-6 pt-2">
{isLoadingEnv || isLoadingProfiles ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading configuration...
</div>
) : envConfig ? (
<>
{/* Inheritance Info */}
<div className="rounded-lg border border-info/30 bg-info/5 p-3">
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-info mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
Using Global Authentication
</p>
<p className="text-xs text-muted-foreground mt-1">
Claude authentication is managed in{' '}
<span className="font-medium text-info">Settings Integrations</span>.
All projects share the same Claude accounts.
</p>
</div>
</div>
</div>
{/* Active Account Display */}
{hasAuthenticatedProfiles ? (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium text-foreground">Active Account</Label>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={handleClaudeSetup}
disabled={isCheckingClaudeAuth}
>
{isCheckingClaudeAuth ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Re-authenticate
</>
)}
</Button>
</div>
{activeProfile ? (
<div className="mt-3 flex items-center gap-3">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0",
"bg-primary text-primary-foreground"
)}>
{activeProfile.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{activeProfile.name}</span>
<span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded flex items-center gap-1">
<Star className="h-3 w-3" />
Active
</span>
{(activeProfile.oauthToken || (activeProfile.isDefault && activeProfile.configDir)) ? (
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
<Check className="h-3 w-3" />
Authenticated
</span>
) : (
<span className="text-xs bg-warning/20 text-warning px-1.5 py-0.5 rounded">
Needs Auth
</span>
)}
</div>
{activeProfile.email && (
<span className="text-xs text-muted-foreground">{activeProfile.email}</span>
)}
</div>
</div>
) : claudeProfiles.length > 0 ? (
<p className="text-xs text-warning mt-2">
No active account selected. Go to Settings Integrations to select an account.
</p>
) : null}
{/* Show other authenticated accounts */}
{claudeProfiles.filter(p => p.id !== activeProfileId && p.oauthToken).length > 0 && (
<div className="mt-3 pt-3 border-t border-border/50">
<p className="text-xs text-muted-foreground mb-2">
Other authenticated accounts (used for rate limit fallback):
</p>
<div className="flex flex-wrap gap-2">
{claudeProfiles
.filter(p => p.id !== activeProfileId && p.oauthToken)
.map(profile => (
<div
key={profile.id}
className="flex items-center gap-1.5 text-xs bg-muted px-2 py-1 rounded"
>
<div className="h-4 w-4 rounded-full bg-muted-foreground/30 flex items-center justify-center text-[10px]">
{profile.name.charAt(0).toUpperCase()}
</div>
<span className="text-muted-foreground">{profile.name}</span>
</div>
))
}
</div>
</div>
)}
</div>
) : (
/* No accounts configured */
<div className="rounded-lg border border-warning/30 bg-warning/5 p-4">
<div className="flex flex-col items-center text-center">
<Users className="h-8 w-8 text-warning mb-2" />
<p className="text-sm font-medium text-foreground">No Claude Accounts Configured</p>
<p className="text-xs text-muted-foreground mt-1 mb-3">
Add a Claude account in the global settings to use Auto-Build.
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
// Emit event to open app settings at Integrations
window.dispatchEvent(new CustomEvent('open-app-settings', { detail: 'integrations' }));
}}
>
<Settings className="h-4 w-4 mr-2" />
Open Integrations Settings
</Button>
</div>
</div>
)}
</>
) : envError ? (
<p className="text-sm text-destructive">{envError}</p>
) : null}
</div>
)}
</section>
);
}
@@ -1,6 +1,5 @@
// Note: ProjectSettings component is deprecated - use unified AppSettings instead
export { GeneralSettings } from './GeneralSettings';
export { EnvironmentSettings } from './EnvironmentSettings';
export { IntegrationSettings } from './IntegrationSettings';
export { SecuritySettings } from './SecuritySettings';
export { useProjectSettings } from './hooks/useProjectSettings';
@@ -87,7 +87,6 @@ const appNavItemsConfig: NavItemConfig<AppSection>[] = [
const projectNavItemsConfig: NavItemConfig<ProjectSettingsSection>[] = [
{ id: 'general', icon: Settings2 },
{ id: 'claude', icon: Key },
{ id: 'linear', icon: Zap },
{ id: 'github', icon: Github },
{ id: 'gitlab', icon: GitLabIcon },
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { LinearTaskImportModal } from '../LinearTaskImportModal';
import { SettingsSection } from './SettingsSection';
import { useProjectSettings, UseProjectSettingsReturn } from '../project-settings/hooks/useProjectSettings';
@@ -9,7 +10,7 @@ import { SectionRouter } from './sections/SectionRouter';
import { createHookProxy } from './utils/hookProxyFactory';
import type { Project } from '../../../shared/types';
export type ProjectSettingsSection = 'general' | 'claude' | 'linear' | 'github' | 'gitlab' | 'memory';
export type ProjectSettingsSection = 'general' | 'linear' | 'github' | 'gitlab' | 'memory';
interface ProjectSettingsContentProps {
project: Project | undefined;
@@ -28,12 +29,14 @@ export function ProjectSettingsContent({
isOpen,
onHookReady
}: ProjectSettingsContentProps) {
const { t } = useTranslation('settings');
// Show empty state if no project selected
if (!project) {
return (
<SettingsSection
title="No Project Selected"
description="Select a project from the dropdown above to configure its settings"
title={t('projectSettings.noProjectSelected.title')}
description={t('projectSettings.noProjectSelected.description')}
>
<EmptyProjectState />
</SettingsSection>
@@ -81,8 +84,6 @@ function ProjectSettingsContentInner({
isLoadingEnv,
envError,
updateEnvConfig,
showClaudeToken,
setShowClaudeToken,
showLinearKey,
setShowLinearKey,
showOpenAIKey,
@@ -97,14 +98,11 @@ function ProjectSettingsContentInner({
setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
isCheckingClaudeAuth,
claudeAuthStatus,
showLinearImportModal,
setShowLinearImportModal,
linearConnectionStatus,
isCheckingLinear,
handleInitialize,
handleClaudeSetup,
error
} = hook;
@@ -134,8 +132,6 @@ function ProjectSettingsContentInner({
isLoadingEnv={isLoadingEnv}
envError={envError}
updateEnvConfig={updateEnvConfig}
showClaudeToken={showClaudeToken}
setShowClaudeToken={setShowClaudeToken}
showLinearKey={showLinearKey}
setShowLinearKey={setShowLinearKey}
showOpenAIKey={showOpenAIKey}
@@ -148,12 +144,9 @@ function ProjectSettingsContentInner({
setShowGitLabToken={setShowGitLabToken}
gitLabConnectionStatus={gitLabConnectionStatus}
isCheckingGitLab={isCheckingGitLab}
isCheckingClaudeAuth={isCheckingClaudeAuth}
claudeAuthStatus={claudeAuthStatus}
linearConnectionStatus={linearConnectionStatus}
isCheckingLinear={isCheckingLinear}
handleInitialize={handleInitialize}
handleClaudeSetup={handleClaudeSetup}
onOpenLinearImport={() => setShowLinearImportModal(true)}
/>
@@ -2,7 +2,6 @@ import { useTranslation } from 'react-i18next';
import type { Project, ProjectSettings as ProjectSettingsType, AutoBuildVersionInfo, ProjectEnvConfig, LinearSyncStatus, GitHubSyncStatus, GitLabSyncStatus } from '../../../../shared/types';
import { SettingsSection } from '../SettingsSection';
import { GeneralSettings } from '../../project-settings/GeneralSettings';
import { EnvironmentSettings } from '../../project-settings/EnvironmentSettings';
import { SecuritySettings } from '../../project-settings/SecuritySettings';
import { LinearIntegration } from '../integrations/LinearIntegration';
import { GitHubIntegration } from '../integrations/GitHubIntegration';
@@ -22,8 +21,6 @@ interface SectionRouterProps {
isLoadingEnv: boolean;
envError: string | null;
updateEnvConfig: (updates: Partial<ProjectEnvConfig>) => void;
showClaudeToken: boolean;
setShowClaudeToken: React.Dispatch<React.SetStateAction<boolean>>;
showLinearKey: boolean;
setShowLinearKey: React.Dispatch<React.SetStateAction<boolean>>;
showOpenAIKey: boolean;
@@ -36,12 +33,9 @@ interface SectionRouterProps {
setShowGitLabToken: React.Dispatch<React.SetStateAction<boolean>>;
gitLabConnectionStatus: GitLabSyncStatus | null;
isCheckingGitLab: boolean;
isCheckingClaudeAuth: boolean;
claudeAuthStatus: 'checking' | 'authenticated' | 'not_authenticated' | 'error';
linearConnectionStatus: LinearSyncStatus | null;
isCheckingLinear: boolean;
handleInitialize: () => Promise<void>;
handleClaudeSetup: () => Promise<void>;
onOpenLinearImport: () => void;
}
@@ -61,8 +55,6 @@ export function SectionRouter({
isLoadingEnv,
envError,
updateEnvConfig,
showClaudeToken,
setShowClaudeToken,
showLinearKey,
setShowLinearKey,
showOpenAIKey,
@@ -75,12 +67,9 @@ export function SectionRouter({
setShowGitLabToken,
gitLabConnectionStatus,
isCheckingGitLab,
isCheckingClaudeAuth,
claudeAuthStatus,
linearConnectionStatus,
isCheckingLinear,
handleInitialize,
handleClaudeSetup,
onOpenLinearImport
}: SectionRouterProps) {
const { t } = useTranslation('settings');
@@ -104,34 +93,6 @@ export function SectionRouter({
</SettingsSection>
);
case 'claude':
return (
<SettingsSection
title="Claude Authentication"
description="Configure Claude CLI authentication for this project"
>
<InitializationGuard
initialized={!!project.autoBuildPath}
title="Claude Authentication"
description="Configure Claude CLI authentication"
>
<EnvironmentSettings
envConfig={envConfig}
isLoadingEnv={isLoadingEnv}
envError={envError}
updateEnvConfig={updateEnvConfig}
isCheckingClaudeAuth={isCheckingClaudeAuth}
claudeAuthStatus={claudeAuthStatus}
handleClaudeSetup={handleClaudeSetup}
showClaudeToken={showClaudeToken}
setShowClaudeToken={setShowClaudeToken}
expanded={true}
onToggle={() => {}}
/>
</InitializationGuard>
</SettingsSection>
);
case 'linear':
return (
<SettingsSection
@@ -20,8 +20,41 @@ export function useAutoNaming({ terminalId, cwd }: UseAutoNamingOptions) {
}
const command = lastCommandRef.current.trim();
// Skip very short or common commands
if (command.length < 2 || ['ls', 'cd', 'll', 'pwd', 'exit', 'clear'].includes(command)) {
const commandLower = command.toLowerCase();
const firstWord = commandLower.split(/\s+/)[0];
// Skip very short commands
if (command.length < 3) {
return;
}
// Skip common shell/navigation commands that don't represent meaningful work.
// These commands are too generic to produce useful terminal names - they don't indicate
// a specific task or purpose. For example, "git" could be any git operation,
// "npm" could be install, run, or test. Meaningful names come from project-specific
// commands like "npm run build:prod" or application-specific scripts.
const skipCommands = [
// Navigation & file listing
'ls', 'cd', 'll', 'la', 'pwd', 'dir', 'tree',
// Shell control
'exit', 'clear', 'cls', 'reset', 'history',
// Claude CLI - naming should come from the task description inside Claude, not the launch command
'claude',
// Common dev tools that are too generic
'git', 'npm', 'yarn', 'pnpm', 'node', 'python', 'pip', 'cargo', 'go',
'docker', 'kubectl', 'make', 'cmake',
// Package managers
'brew', 'apt', 'yum', 'pacman', 'choco', 'scoop', 'winget',
// Editors
'vim', 'nvim', 'nano', 'code', 'cursor',
// System commands
'cat', 'head', 'tail', 'less', 'more', 'grep', 'find', 'which', 'where',
'echo', 'env', 'export', 'set', 'unset', 'alias', 'source',
'chmod', 'chown', 'mkdir', 'rmdir', 'rm', 'cp', 'mv', 'touch',
'man', 'help', 'whoami', 'hostname', 'date', 'time', 'top', 'htop', 'ps',
];
if (skipCommands.includes(firstWord)) {
return;
}
@@ -83,6 +83,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio
return false;
}
// Let Cmd/Ctrl + T pass through for new terminal shortcut
// Let Cmd/Ctrl + W pass through for close terminal shortcut
if (isMod && (event.key === 't' || event.key === 'T' || event.key === 'w' || event.key === 'W')) {
return false;
}
// Handle all other keys in xterm
return true;
});
@@ -69,6 +69,22 @@ export const infrastructureMock = {
}
}),
checkOllamaInstalled: async () => ({
success: true,
data: {
installed: true,
path: '/usr/local/bin/ollama',
version: '0.1.0',
}
}),
installOllama: async () => ({
success: true,
data: {
command: 'curl -fsSL https://ollama.com/install.sh | sh',
}
}),
listOllamaModels: async () => ({
success: true,
data: {
@@ -377,6 +377,8 @@ export const IPC_CHANNELS = {
// Ollama model detection and management
OLLAMA_CHECK_STATUS: 'ollama:checkStatus',
OLLAMA_CHECK_INSTALLED: 'ollama:checkInstalled',
OLLAMA_INSTALL: 'ollama:install',
OLLAMA_LIST_MODELS: 'ollama:listModels',
OLLAMA_LIST_EMBEDDING_MODELS: 'ollama:listEmbeddingModels',
OLLAMA_PULL_MODEL: 'ollama:pullModel',
@@ -50,6 +50,7 @@
},
"errors": {
"generic": "An error occurred",
"unknownError": "An unknown error occurred",
"networkError": "Network error",
"notFound": "Not found",
"unauthorized": "Unauthorized"
@@ -118,5 +118,12 @@
"thinkingLevel": "Thinking Level",
"cancel": "Cancel",
"apply": "Apply"
},
"removeProject": {
"title": "Remove Project?",
"description": "This will remove \"{{projectName}}\" from the app. Your files will be preserved on disk and you can re-add the project later.",
"cancel": "Cancel",
"remove": "Remove",
"error": "Failed to remove project"
}
}
@@ -116,5 +116,23 @@
"noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)",
"custom": "Custom...",
"saveAndContinue": "Save & Continue"
},
"ollama": {
"notInstalled": {
"title": "Ollama not installed",
"description": "Ollama provides free, local embedding models for semantic search. Install it with one click to enable this feature.",
"installSuccess": "Installation started in your terminal. Complete the installation there, then click Retry.",
"installButton": "Install Ollama",
"installing": "Installing...",
"retry": "Retry",
"learnMore": "Learn more",
"fallbackNote": "Memory will still work with keyword search even without Ollama."
},
"notRunning": {
"title": "Ollama not running",
"description": "Ollama is installed but not running. Start Ollama to use local embedding models.",
"retry": "Retry",
"fallbackNote": "Memory will still work with keyword search even without embeddings."
}
}
}
@@ -319,6 +319,12 @@
"helpTitle": "Reporting Issues",
"helpText": "When reporting bugs, click \"Copy Debug Info\" to get system information and recent errors that help us diagnose the issue."
},
"projectSettings": {
"noProjectSelected": {
"title": "No Project Selected",
"description": "Select a project from the sidebar to configure its settings."
}
},
"mcp": {
"title": "MCP Server Overview",
"titleWithProject": "MCP Server Overview for {{projectName}}",
@@ -50,6 +50,7 @@
},
"errors": {
"generic": "Une erreur s'est produite",
"unknownError": "Une erreur inconnue s'est produite",
"networkError": "Erreur réseau",
"notFound": "Non trouvé",
"unauthorized": "Non autorisé"
@@ -118,5 +118,12 @@
"thinkingLevel": "Niveau de réflexion",
"cancel": "Annuler",
"apply": "Appliquer"
},
"removeProject": {
"title": "Retirer le projet ?",
"description": "Ceci va retirer \"{{projectName}}\" de l'application. Vos fichiers seront préservés sur le disque et vous pourrez ré-ajouter le projet plus tard.",
"cancel": "Annuler",
"remove": "Retirer",
"error": "Échec de la suppression du projet"
}
}
@@ -116,5 +116,23 @@
"noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)",
"custom": "Personnalisé...",
"saveAndContinue": "Enregistrer et continuer"
},
"ollama": {
"notInstalled": {
"title": "Ollama non installé",
"description": "Ollama fournit des modèles d'embeddings locaux gratuits pour la recherche sémantique. Installez-le en un clic pour activer cette fonctionnalité.",
"installSuccess": "Installation lancée dans votre terminal. Terminez l'installation là-bas, puis cliquez sur Réessayer.",
"installButton": "Installer Ollama",
"installing": "Installation...",
"retry": "Réessayer",
"learnMore": "En savoir plus",
"fallbackNote": "La mémoire fonctionnera toujours avec la recherche par mots-clés même sans Ollama."
},
"notRunning": {
"title": "Ollama non démarré",
"description": "Ollama est installé mais non démarré. Lancez Ollama pour utiliser les modèles d'embeddings locaux.",
"retry": "Réessayer",
"fallbackNote": "La mémoire fonctionnera toujours avec la recherche par mots-clés même sans embeddings."
}
}
}
@@ -319,6 +319,12 @@
"helpTitle": "Signaler des problèmes",
"helpText": "Lors du signalement de bugs, cliquez sur \"Copier les infos de débogage\" pour obtenir les informations système et les erreurs récentes qui nous aident à diagnostiquer le problème."
},
"projectSettings": {
"noProjectSelected": {
"title": "Aucun projet sélectionné",
"description": "Sélectionnez un projet dans la barre latérale pour configurer ses paramètres."
}
},
"mcp": {
"title": "Aperçu des serveurs MCP",
"titleWithProject": "Aperçu des serveurs MCP pour {{projectName}}",
+6
View File
@@ -680,6 +680,12 @@ export interface ElectronAPI {
version?: string;
message?: string;
}>>;
checkOllamaInstalled: () => Promise<IPCResult<{
installed: boolean;
path?: string;
version?: string;
}>>;
installOllama: () => Promise<IPCResult<{ command: string }>>;
listOllamaModels: (baseUrl?: string) => Promise<IPCResult<{
models: Array<{
name: string;
@@ -40,6 +40,7 @@ export function escapeShellPath(path: string): string {
/**
* Build a safe cd command from a path.
* Uses platform-appropriate quoting (double quotes on Windows, single quotes on Unix).
*
* @param path - The directory path
* @returns A safe "cd '<path>' && " string, or empty string if path is undefined
@@ -48,6 +49,15 @@ export function buildCdCommand(path: string | undefined): string {
if (!path) {
return '';
}
// Windows cmd.exe uses double quotes, Unix shells use single quotes
if (process.platform === 'win32') {
// On Windows, escape cmd.exe metacharacters (& | < > ^) that could enable command injection,
// then wrap in double quotes. Using escapeShellArgWindows for proper escaping.
const escaped = escapeShellArgWindows(path);
return `cd "${escaped}" && `;
}
return `cd ${escapeShellPath(path)} && `;
}