mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'main' of github.com:webdevcody/automaker
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
|
||||
❌ Error: Reconnecting... 1/5
|
||||
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
@@ -0,0 +1,4 @@
|
||||
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
Submodule .automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba added at a78b6763de
Submodule .automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op added at a78b6763de
453
.cursor/plans/codex_cli_openai_model_support_9987f5e4.plan.md
Normal file
453
.cursor/plans/codex_cli_openai_model_support_9987f5e4.plan.md
Normal file
@@ -0,0 +1,453 @@
|
||||
---
|
||||
name: Codex CLI OpenAI Model Support
|
||||
overview: Extend the model support system to integrate OpenAI Codex CLI, enabling users to use OpenAI models (GPT-4o, o3, etc.) alongside existing Claude models. This includes CLI detection, model provider abstraction, execution wrapper, and UI updates.
|
||||
todos:
|
||||
- id: model-provider-abstraction
|
||||
content: Create model provider abstraction layer with base interface and Claude/Codex implementations
|
||||
status: pending
|
||||
- id: codex-cli-detector
|
||||
content: Implement Codex CLI detector service to check installation status and version
|
||||
status: pending
|
||||
- id: codex-executor
|
||||
content: Create Codex CLI execution wrapper that spawns subprocess and parses JSON output
|
||||
status: pending
|
||||
- id: codex-config-manager
|
||||
content: Implement Codex TOML configuration manager for model provider setup
|
||||
status: pending
|
||||
- id: model-registry
|
||||
content: Create centralized model registry with provider mappings and metadata
|
||||
status: pending
|
||||
- id: update-feature-executor
|
||||
content: Refactor feature-executor.js to use model provider abstraction instead of direct SDK calls
|
||||
status: pending
|
||||
- id: update-agent-service
|
||||
content: Update agent-service.js to support configurable model selection via provider abstraction
|
||||
status: pending
|
||||
- id: message-converter
|
||||
content: Create message format converter to translate Codex JSONL output to Claude SDK format
|
||||
status: pending
|
||||
- id: update-ui-types
|
||||
content: Extend TypeScript types in app-store.ts to include OpenAI models and provider metadata
|
||||
status: pending
|
||||
- id: update-board-view
|
||||
content: Expand model selection dropdown in board-view.tsx to include OpenAI models with provider grouping
|
||||
status: pending
|
||||
- id: update-settings-view
|
||||
content: Add OpenAI API key input, Codex CLI status check, and test connection button to settings-view.tsx
|
||||
status: pending
|
||||
- id: openai-test-api
|
||||
content: Create OpenAI API test endpoint at app/src/app/api/openai/test/route.ts
|
||||
status: pending
|
||||
- id: ipc-handlers
|
||||
content: Add IPC handlers in main.js for model management (checkCodexCli, getAvailableModels, testOpenAI)
|
||||
status: pending
|
||||
- id: preload-api
|
||||
content: Update preload.js and electron.d.ts to expose new IPC methods to renderer process
|
||||
status: pending
|
||||
- id: env-manager
|
||||
content: Create environment variable manager for centralized API key and config handling
|
||||
status: pending
|
||||
- id: error-handling
|
||||
content: Implement provider fallback logic and user-friendly error messages for missing CLI/API keys
|
||||
status: pending
|
||||
---
|
||||
|
||||
# Codex CLI OpenAI Model Support Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Extend Automaker's model support to integrate OpenAI Codex CLI, allowing users to use the latest GPT-5.1 Codex models (`gpt-5.1-codex-max`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5.1`) alongside existing Claude models. Codex CLI defaults to `gpt-5.1-codex-max` and uses ChatGPT Enterprise authentication (no API key required). The implementation will follow the existing Claude CLI pattern but add abstraction for multiple model providers.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Model Usage Points
|
||||
|
||||
1. **Feature Executor** (`app/electron/services/feature-executor.js`):
|
||||
|
||||
- Uses `MODEL_MAP` with hardcoded Claude models (haiku, sonnet, opus)
|
||||
- Calls `@anthropic-ai/claude-agent-sdk` `query()` function
|
||||
- Model selection via `getModelString(feature)` method
|
||||
|
||||
2. **Agent Service** (`app/electron/agent-service.js`):
|
||||
|
||||
- Hardcoded model: `"claude-opus-4-5-20251101"`
|
||||
- Uses Claude Agent SDK directly
|
||||
|
||||
3. **API Route** (`app/src/app/api/chat/route.ts`):
|
||||
|
||||
- Hardcoded model: `"claude-opus-4-5-20251101"`
|
||||
- Uses Claude Agent SDK
|
||||
|
||||
4. **Project Analyzer** (`app/electron/services/project-analyzer.js`):
|
||||
|
||||
- Hardcoded model: `"claude-sonnet-4-20250514"`
|
||||
|
||||
5. **UI Components**:
|
||||
|
||||
- `board-view.tsx`: Model dropdown (haiku/sonnet/opus)
|
||||
- `app-store.ts`: `AgentModel` type limited to Claude models
|
||||
|
||||
### Authentication
|
||||
|
||||
- Claude: Uses `CLAUDE_CODE_OAUTH_TOKEN` environment variable
|
||||
- Codex: Uses `OPENAI_API_KEY` environment variable (per Codex docs)
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Model Provider Abstraction Layer
|
||||
|
||||
#### 1.1 Create Model Provider Interface
|
||||
|
||||
**File**: `app/electron/services/model-provider.js`
|
||||
|
||||
- Abstract base class/interface for model providers
|
||||
- Methods: `executeQuery()`, `detectInstallation()`, `getAvailableModels()`, `validateConfig()`
|
||||
- Implementations:
|
||||
- `ClaudeProvider` (wraps existing SDK usage)
|
||||
- `CodexProvider` (new, wraps Codex CLI execution)
|
||||
|
||||
#### 1.2 Create Codex CLI Detector
|
||||
|
||||
**File**: `app/electron/services/codex-cli-detector.js`
|
||||
|
||||
- Similar to `claude-cli-detector.js`
|
||||
- Check for `codex` command in PATH
|
||||
- Check for npm global installation: `npm list -g @openai/codex`
|
||||
- Check for Homebrew installation on macOS
|
||||
- Return: `{ installed: boolean, path: string, version: string, method: 'cli'|'npm'|'brew'|'none' }`
|
||||
|
||||
#### 1.3 Create Codex Provider Implementation
|
||||
|
||||
**File**: `app/electron/services/codex-provider.js`
|
||||
|
||||
- Extends model provider interface
|
||||
- Executes Codex CLI via `child_process.spawn()` or `execSync()`
|
||||
- Handles JSON output parsing (`codex exec --json`)
|
||||
- Manages TOML configuration file creation/updates
|
||||
- Supports latest GPT-5.1 Codex models:
|
||||
- `gpt-5.1-codex-max` (default, latest flagship for deep and fast reasoning)
|
||||
- `gpt-5.1-codex` (optimized for codex)
|
||||
- `gpt-5.1-codex-mini` (cheaper, faster, less capable)
|
||||
- `gpt-5.1` (broad world knowledge with strong general reasoning)
|
||||
- Uses ChatGPT Enterprise authentication (no API key required for these models)
|
||||
- Note: Legacy models (GPT-4o, o3, o1, etc.) are not supported - Codex CLI focuses on GPT-5.1 Codex family only
|
||||
|
||||
### Phase 2: Model Configuration System
|
||||
|
||||
#### 2.1 Extended Model Registry
|
||||
|
||||
**File**: `app/electron/services/model-registry.js`
|
||||
|
||||
- Centralized model configuration
|
||||
- Model definitions with provider mapping:
|
||||
```javascript
|
||||
{
|
||||
id: "claude-opus",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "claude",
|
||||
modelString: "claude-opus-4-5-20251101",
|
||||
...
|
||||
},
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
provider: "codex",
|
||||
modelString: "gpt-4o",
|
||||
requiresApiKey: "OPENAI_API_KEY",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- Model categories: `claude`, `openai`, `azure`, `custom`
|
||||
|
||||
#### 2.2 Codex Configuration Manager
|
||||
|
||||
**File**: `app/electron/services/codex-config-manager.js`
|
||||
|
||||
- Manages Codex TOML config file (typically `~/.config/codex/config.toml` or project-specific)
|
||||
- Creates/updates model provider configurations:
|
||||
```toml
|
||||
[model_providers.openai-chat-completions]
|
||||
name = "OpenAI using Chat Completions"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
wire_api = "chat"
|
||||
|
||||
[profiles.gpt4o]
|
||||
model = "gpt-4o"
|
||||
model_provider = "openai-chat-completions"
|
||||
```
|
||||
|
||||
- Profile management for different use cases
|
||||
- Validates configuration before execution
|
||||
|
||||
### Phase 3: Execution Integration
|
||||
|
||||
#### 3.1 Update Feature Executor
|
||||
|
||||
**File**: `app/electron/services/feature-executor.js`
|
||||
|
||||
- Replace direct SDK calls with model provider abstraction
|
||||
- Update `getModelString()` to return model ID instead of string
|
||||
- Add `getModelProvider(modelId)` method
|
||||
- Modify `implementFeature()` to:
|
||||
- Get provider for selected model
|
||||
- Use provider's `executeQuery()` method
|
||||
- Handle different response formats (SDK vs CLI JSON)
|
||||
|
||||
#### 3.2 Update Agent Service
|
||||
|
||||
**File**: `app/electron/agent-service.js`
|
||||
|
||||
- Replace hardcoded model with configurable model selection
|
||||
- Use model provider abstraction
|
||||
- Support model selection per session
|
||||
|
||||
#### 3.3 Update Project Analyzer
|
||||
|
||||
**File**: `app/electron/services/project-analyzer.js`
|
||||
|
||||
- Use model provider abstraction
|
||||
- Make model configurable (currently hardcoded to sonnet)
|
||||
|
||||
#### 3.4 Update API Route
|
||||
|
||||
**File**: `app/src/app/api/chat/route.ts`
|
||||
|
||||
- Support model selection from request
|
||||
- Use model provider abstraction (if running in Electron context)
|
||||
- Fallback to Claude SDK for web-only usage
|
||||
|
||||
### Phase 4: Codex CLI Execution Wrapper
|
||||
|
||||
#### 4.1 Codex Executor
|
||||
|
||||
**File**: `app/electron/services/codex-executor.js`
|
||||
|
||||
- Wraps `codex exec` command execution
|
||||
- Handles subprocess spawning with proper environment variables
|
||||
- Parses JSON output (JSONL format from `--json` flag)
|
||||
- Converts Codex output format to match Claude SDK message format
|
||||
- Handles streaming responses
|
||||
- Error handling and timeout management
|
||||
|
||||
#### 4.2 Message Format Conversion
|
||||
|
||||
**File**: `app/electron/services/message-converter.js`
|
||||
|
||||
- Converts Codex JSONL output to Claude SDK message format
|
||||
- Maps Codex events:
|
||||
- `thread.started` → session initialization
|
||||
- `item.completed` (reasoning) → thinking output
|
||||
- `item.completed` (command_execution) → tool use
|
||||
- `item.completed` (agent_message) → assistant message
|
||||
- Maintains compatibility with existing UI components
|
||||
|
||||
### Phase 5: UI Updates
|
||||
|
||||
#### 5.1 Update Type Definitions
|
||||
|
||||
**File**: `app/src/store/app-store.ts`
|
||||
|
||||
- Extend `AgentModel` type to include OpenAI models:
|
||||
```typescript
|
||||
export type AgentModel =
|
||||
| "opus" | "sonnet" | "haiku" // Claude
|
||||
| "gpt-4o" | "gpt-4o-mini" | "gpt-3.5-turbo" | "o3" | "o1"; // OpenAI
|
||||
```
|
||||
|
||||
- Add `modelProvider` field to `Feature` interface
|
||||
- Add provider metadata to model selection
|
||||
|
||||
#### 5.2 Update Board View
|
||||
|
||||
**File**: `app/src/components/views/board-view.tsx`
|
||||
|
||||
- Expand model dropdown to include OpenAI models
|
||||
- Group models by provider (Claude / OpenAI)
|
||||
- Show provider badges/icons
|
||||
- Display model availability based on CLI detection
|
||||
- Add tooltips showing model capabilities
|
||||
|
||||
#### 5.3 Update Settings View
|
||||
|
||||
**File**: `app/src/components/views/settings-view.tsx`
|
||||
|
||||
- Add OpenAI API key input field (similar to Anthropic key)
|
||||
- Add Codex CLI status check (similar to Claude CLI check)
|
||||
- Show installation instructions if Codex CLI not detected
|
||||
- Add test connection button for OpenAI API
|
||||
- Display detected Codex CLI version/path
|
||||
|
||||
#### 5.4 Create API Test Route
|
||||
|
||||
**File**: `app/src/app/api/openai/test/route.ts`
|
||||
|
||||
- Similar to `app/src/app/api/claude/test/route.ts`
|
||||
- Test OpenAI API connection
|
||||
- Validate API key format
|
||||
- Return connection status
|
||||
|
||||
### Phase 6: Configuration & Environment
|
||||
|
||||
#### 6.1 Environment Variable Management
|
||||
|
||||
**File**: `app/electron/services/env-manager.js`
|
||||
|
||||
- Centralized environment variable handling
|
||||
- Loads from `.env` file and system environment
|
||||
- Validates required variables per provider
|
||||
- Provides fallback mechanisms
|
||||
|
||||
#### 6.2 IPC Handlers for Model Management
|
||||
|
||||
**File**: `app/electron/main.js`
|
||||
|
||||
- Add IPC handlers:
|
||||
- `model:checkCodexCli` - Check Codex CLI installation
|
||||
- `model:getAvailableModels` - List available models per provider
|
||||
- `model:testOpenAI` - Test OpenAI API connection
|
||||
- `model:updateCodexConfig` - Update Codex TOML config
|
||||
|
||||
#### 6.3 Preload API Updates
|
||||
|
||||
**File**: `app/electron/preload.js`
|
||||
|
||||
- Expose new IPC methods to renderer
|
||||
- Add TypeScript definitions in `app/src/types/electron.d.ts`
|
||||
|
||||
### Phase 7: Error Handling & Fallbacks
|
||||
|
||||
#### 7.1 Provider Fallback Logic
|
||||
|
||||
- If Codex CLI not available, fallback to Claude
|
||||
- If OpenAI API key missing, show clear error messages
|
||||
- Graceful degradation when provider unavailable
|
||||
|
||||
#### 7.2 Error Messages
|
||||
|
||||
- User-friendly error messages for missing CLI
|
||||
- Installation instructions per platform
|
||||
- API key validation errors
|
||||
- Model availability warnings
|
||||
|
||||
## File Structure Summary
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
app/electron/services/
|
||||
├── model-provider.js # Abstract provider interface
|
||||
├── claude-provider.js # Claude SDK wrapper
|
||||
├── codex-provider.js # Codex CLI wrapper
|
||||
├── codex-cli-detector.js # Codex CLI detection
|
||||
├── codex-executor.js # Codex CLI execution wrapper
|
||||
├── codex-config-manager.js # TOML config management
|
||||
├── model-registry.js # Centralized model definitions
|
||||
├── message-converter.js # Format conversion utilities
|
||||
└── env-manager.js # Environment variable management
|
||||
|
||||
app/src/app/api/openai/
|
||||
└── test/route.ts # OpenAI API test endpoint
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
app/electron/services/
|
||||
├── feature-executor.js # Use model provider abstraction
|
||||
├── agent-service.js # Support multiple providers
|
||||
└── project-analyzer.js # Configurable model selection
|
||||
|
||||
app/electron/
|
||||
├── main.js # Add IPC handlers
|
||||
└── preload.js # Expose new APIs
|
||||
|
||||
app/src/
|
||||
├── store/app-store.ts # Extended model types
|
||||
├── components/views/
|
||||
│ ├── board-view.tsx # Expanded model selection UI
|
||||
│ └── settings-view.tsx # OpenAI API key & Codex CLI status
|
||||
└── types/electron.d.ts # Updated IPC type definitions
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Codex CLI Execution Pattern
|
||||
|
||||
```javascript
|
||||
// Example execution flow
|
||||
const codexExecutor = require('./codex-executor');
|
||||
const result = await codexExecutor.execute({
|
||||
prompt: "Implement feature X",
|
||||
model: "gpt-4o",
|
||||
cwd: projectPath,
|
||||
systemPrompt: "...",
|
||||
maxTurns: 20,
|
||||
allowedTools: ["Read", "Write", "Edit", "Bash"],
|
||||
env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
|
||||
});
|
||||
```
|
||||
|
||||
### Model Provider Interface
|
||||
|
||||
```javascript
|
||||
class ModelProvider {
|
||||
async executeQuery(options) {
|
||||
// Returns async generator of messages
|
||||
}
|
||||
|
||||
async detectInstallation() {
|
||||
// Returns installation status
|
||||
}
|
||||
|
||||
getAvailableModels() {
|
||||
// Returns list of supported models
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
// Validates provider configuration
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration File Location
|
||||
|
||||
- User config: `~/.config/codex/config.toml` (or platform equivalent)
|
||||
- Project config: `.codex/config.toml` (optional, project-specific)
|
||||
- Fallback: In-memory config passed via CLI args
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
1. **CLI Detection**: Test on macOS, Linux, Windows
|
||||
2. **Model Execution**: Test with different OpenAI models
|
||||
3. **Error Handling**: Test missing CLI, invalid API keys, network errors
|
||||
4. **Format Conversion**: Verify message format compatibility
|
||||
5. **Concurrent Execution**: Test multiple features with different providers
|
||||
6. **Fallback Logic**: Test provider fallback scenarios
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
1. Update README with Codex CLI installation instructions:
|
||||
|
||||
- `npm install -g @openai/codex@latest` or `brew install codex`
|
||||
- ChatGPT Enterprise authentication (no API key needed)
|
||||
- API-based authentication for older models
|
||||
|
||||
2. Add model selection guide:
|
||||
|
||||
- GPT-5.1 Codex Max (default, best for coding)
|
||||
- o3/o4-mini with reasoning efforts
|
||||
- GPT-5.1/GPT-5 with verbosity control
|
||||
|
||||
3. Document reasoning effort and verbosity settings
|
||||
4. Add troubleshooting section for common issues
|
||||
5. Document model list discovery via MCP interface
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Implement provider abstraction alongside existing code
|
||||
2. Add Codex support without breaking existing Claude functionality
|
||||
3. Gradually migrate services to use abstraction layer
|
||||
4. Maintain backward compatibility during transition
|
||||
5. Remove hardcoded models after full migration
|
||||
@@ -3,6 +3,7 @@ const featureExecutor = require("./services/feature-executor");
|
||||
const featureVerifier = require("./services/feature-verifier");
|
||||
const contextManager = require("./services/context-manager");
|
||||
const projectAnalyzer = require("./services/project-analyzer");
|
||||
const worktreeManager = require("./services/worktree-manager");
|
||||
|
||||
/**
|
||||
* Auto Mode Service - Autonomous feature implementation
|
||||
@@ -33,13 +34,78 @@ class AutoModeService {
|
||||
const context = {
|
||||
abortController: null,
|
||||
query: null,
|
||||
projectPath: null,
|
||||
projectPath: null, // Original project path
|
||||
worktreePath: null, // Path to worktree (where agent works)
|
||||
branchName: null, // Feature branch name
|
||||
sendToRenderer: null,
|
||||
isActive: () => this.runningFeatures.has(featureId),
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup worktree for a feature
|
||||
* Creates an isolated git worktree where the agent can work
|
||||
* @param {Object} feature - The feature object
|
||||
* @param {string} projectPath - Path to the project
|
||||
* @param {Function} sendToRenderer - Function to send events to the renderer
|
||||
* @param {boolean} useWorktreesEnabled - Whether worktrees are enabled in settings (default: false)
|
||||
*/
|
||||
async setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktreesEnabled = false) {
|
||||
// If worktrees are disabled in settings, skip entirely
|
||||
if (!useWorktreesEnabled) {
|
||||
console.log(`[AutoMode] Worktrees disabled in settings, working directly on main project`);
|
||||
return { useWorktree: false, workPath: projectPath };
|
||||
}
|
||||
|
||||
// Check if worktrees are enabled (project must be a git repo)
|
||||
const isGit = await worktreeManager.isGitRepo(projectPath);
|
||||
if (!isGit) {
|
||||
console.log(`[AutoMode] Project is not a git repo, skipping worktree creation`);
|
||||
return { useWorktree: false, workPath: projectPath };
|
||||
}
|
||||
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: "Creating isolated worktree for feature...\n",
|
||||
});
|
||||
|
||||
const result = await worktreeManager.createWorktree(projectPath, feature);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[AutoMode] Failed to create worktree: ${result.error}. Falling back to main project.`);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: `Warning: Could not create worktree (${result.error}). Working directly on main project.\n`,
|
||||
});
|
||||
return { useWorktree: false, workPath: projectPath };
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Created worktree at: ${result.worktreePath}, branch: ${result.branchName}`);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: `Working in isolated branch: ${result.branchName}\n`,
|
||||
});
|
||||
|
||||
// Update feature with worktree info in feature_list.json
|
||||
await featureLoader.updateFeatureWorktree(
|
||||
feature.id,
|
||||
projectPath,
|
||||
result.worktreePath,
|
||||
result.branchName
|
||||
);
|
||||
|
||||
return {
|
||||
useWorktree: true,
|
||||
workPath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
baseBranch: result.baseBranch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto mode - continuously implement features
|
||||
*/
|
||||
@@ -108,14 +174,18 @@ class AutoModeService {
|
||||
|
||||
/**
|
||||
* Run a specific feature by ID
|
||||
* @param {string} projectPath - Path to the project
|
||||
* @param {string} featureId - ID of the feature to run
|
||||
* @param {Function} sendToRenderer - Function to send events to renderer
|
||||
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
|
||||
*/
|
||||
async runFeature({ projectPath, featureId, sendToRenderer }) {
|
||||
async runFeature({ projectPath, featureId, sendToRenderer, useWorktrees = false }) {
|
||||
// Check if this specific feature is already running
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Running specific feature: ${featureId}`);
|
||||
console.log(`[AutoMode] Running specific feature: ${featureId} (worktrees: ${useWorktrees})`);
|
||||
|
||||
// Register this feature as running
|
||||
const execution = this.createExecutionContext(featureId);
|
||||
@@ -134,6 +204,14 @@ class AutoModeService {
|
||||
|
||||
console.log(`[AutoMode] Running feature: ${feature.description}`);
|
||||
|
||||
// Setup worktree for isolated work (if enabled)
|
||||
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
|
||||
execution.worktreePath = worktreeSetup.workPath;
|
||||
execution.branchName = worktreeSetup.branchName;
|
||||
|
||||
// Determine working path (worktree or main project)
|
||||
const workPath = worktreeSetup.workPath;
|
||||
|
||||
// Update feature status to in_progress
|
||||
await featureLoader.updateFeatureStatus(
|
||||
featureId,
|
||||
@@ -144,24 +222,27 @@ class AutoModeService {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_start",
|
||||
featureId: feature.id,
|
||||
feature: feature,
|
||||
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
||||
});
|
||||
|
||||
// Implement the feature
|
||||
// Implement the feature (agent works in worktree)
|
||||
const result = await featureExecutor.implementFeature(
|
||||
feature,
|
||||
projectPath,
|
||||
workPath, // Use worktree path instead of main project
|
||||
sendToRenderer,
|
||||
execution
|
||||
);
|
||||
|
||||
// Update feature status based on result
|
||||
// For skipTests features, go to waiting_approval on success instead of verified
|
||||
// On failure, skipTests features should also go to waiting_approval for user review
|
||||
let newStatus;
|
||||
if (result.passes) {
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
||||
} else {
|
||||
newStatus = "backlog";
|
||||
// For skipTests features, keep in waiting_approval so user can review
|
||||
// For normal TDD features, move to backlog for retry
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
|
||||
}
|
||||
await featureLoader.updateFeatureStatus(
|
||||
feature.id,
|
||||
@@ -554,8 +635,12 @@ class AutoModeService {
|
||||
|
||||
/**
|
||||
* Start a feature asynchronously (similar to drag operation)
|
||||
* @param {Object} feature - The feature to start
|
||||
* @param {string} projectPath - Path to the project
|
||||
* @param {Function} sendToRenderer - Function to send events to renderer
|
||||
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
|
||||
*/
|
||||
async startFeatureAsync(feature, projectPath, sendToRenderer) {
|
||||
async startFeatureAsync(feature, projectPath, sendToRenderer, useWorktrees = false) {
|
||||
const featureId = feature.id;
|
||||
|
||||
// Skip if already running
|
||||
@@ -566,7 +651,7 @@ class AutoModeService {
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[AutoMode] Starting feature: ${feature.description.slice(0, 50)}...`
|
||||
`[AutoMode] Starting feature: ${feature.description.slice(0, 50)}... (worktrees: ${useWorktrees})`
|
||||
);
|
||||
|
||||
// Register this feature as running
|
||||
@@ -575,6 +660,14 @@ class AutoModeService {
|
||||
execution.sendToRenderer = sendToRenderer;
|
||||
this.runningFeatures.set(featureId, execution);
|
||||
|
||||
// Setup worktree for isolated work (if enabled)
|
||||
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
|
||||
execution.worktreePath = worktreeSetup.workPath;
|
||||
execution.branchName = worktreeSetup.branchName;
|
||||
|
||||
// Determine working path (worktree or main project)
|
||||
const workPath = worktreeSetup.workPath;
|
||||
|
||||
// Update status to in_progress with timestamp
|
||||
await featureLoader.updateFeatureStatus(
|
||||
featureId,
|
||||
@@ -585,23 +678,27 @@ class AutoModeService {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_start",
|
||||
featureId: feature.id,
|
||||
feature: feature,
|
||||
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
||||
});
|
||||
|
||||
// Implement the feature (this runs async in background)
|
||||
// Implement the feature (agent works in worktree)
|
||||
const result = await featureExecutor.implementFeature(
|
||||
feature,
|
||||
projectPath,
|
||||
workPath, // Use worktree path instead of main project
|
||||
sendToRenderer,
|
||||
execution
|
||||
);
|
||||
|
||||
// Update feature status based on result
|
||||
// For skipTests features, go to waiting_approval on success instead of verified
|
||||
// On failure, skipTests features should also go to waiting_approval for user review
|
||||
let newStatus;
|
||||
if (result.passes) {
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
||||
} else {
|
||||
newStatus = "backlog";
|
||||
// For skipTests features, keep in waiting_approval so user can review
|
||||
// For normal TDD features, move to backlog for retry
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
|
||||
}
|
||||
await featureLoader.updateFeatureStatus(
|
||||
feature.id,
|
||||
@@ -975,6 +1072,170 @@ class AutoModeService {
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert feature changes by removing the worktree
|
||||
* This effectively discards all changes made by the agent
|
||||
*/
|
||||
async revertFeature({ projectPath, featureId, sendToRenderer }) {
|
||||
console.log(`[AutoMode] Reverting feature: ${featureId}`);
|
||||
|
||||
try {
|
||||
// Stop the feature if it's running
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
await this.stopFeature({ featureId });
|
||||
}
|
||||
|
||||
// Remove the worktree and delete the branch
|
||||
const result = await worktreeManager.removeWorktree(projectPath, featureId, true);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to remove worktree");
|
||||
}
|
||||
|
||||
// Clear worktree info from feature
|
||||
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
||||
|
||||
// Update feature status back to backlog
|
||||
await featureLoader.updateFeatureStatus(featureId, "backlog", projectPath);
|
||||
|
||||
// Delete context file
|
||||
await contextManager.deleteContextFile(projectPath, featureId);
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_complete",
|
||||
featureId: featureId,
|
||||
passes: false,
|
||||
message: "Feature reverted - all changes discarded",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Feature ${featureId} reverted successfully`);
|
||||
return { success: true, removedPath: result.removedPath };
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error reverting feature:", error);
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
error: error.message,
|
||||
featureId: featureId,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge feature worktree changes back to main branch
|
||||
*/
|
||||
async mergeFeature({ projectPath, featureId, options = {}, sendToRenderer }) {
|
||||
console.log(`[AutoMode] Merging feature: ${featureId}`);
|
||||
|
||||
try {
|
||||
// Load feature to get worktree info
|
||||
const features = await featureLoader.loadFeatures(projectPath);
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: featureId,
|
||||
content: "Merging feature branch into main...\n",
|
||||
});
|
||||
}
|
||||
|
||||
// Merge the worktree
|
||||
const result = await worktreeManager.mergeWorktree(projectPath, featureId, {
|
||||
...options,
|
||||
cleanup: true, // Remove worktree after successful merge
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to merge worktree");
|
||||
}
|
||||
|
||||
// Clear worktree info from feature
|
||||
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
||||
|
||||
// Update feature status to verified
|
||||
await featureLoader.updateFeatureStatus(featureId, "verified", projectPath);
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_complete",
|
||||
featureId: featureId,
|
||||
passes: true,
|
||||
message: `Feature merged into ${result.intoBranch}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Feature ${featureId} merged successfully`);
|
||||
return { success: true, mergedBranch: result.mergedBranch };
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error merging feature:", error);
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
error: error.message,
|
||||
featureId: featureId,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
async getWorktreeInfo({ projectPath, featureId }) {
|
||||
return await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree status (changed files, commits, etc.)
|
||||
*/
|
||||
async getWorktreeStatus({ projectPath, featureId }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getWorktreeStatus(worktreeInfo.worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all feature worktrees
|
||||
*/
|
||||
async listWorktrees({ projectPath }) {
|
||||
const worktrees = await worktreeManager.getAllFeatureWorktrees(projectPath);
|
||||
return { success: true, worktrees };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file diffs for a feature worktree
|
||||
*/
|
||||
async getFileDiffs({ projectPath, featureId }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getFileDiffs(worktreeInfo.worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a feature worktree
|
||||
*/
|
||||
async getFileDiff({ projectPath, featureId, filePath }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getFileDiff(worktreeInfo.worktreePath, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -7,6 +7,7 @@ const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
const fs = require("fs/promises");
|
||||
const agentService = require("./agent-service");
|
||||
const autoModeService = require("./auto-mode-service");
|
||||
const worktreeManager = require("./services/worktree-manager");
|
||||
const featureSuggestionsService = require("./services/feature-suggestions-service");
|
||||
const specRegenerationService = require("./services/spec-regeneration-service");
|
||||
|
||||
@@ -61,6 +62,21 @@ app.whenReady().then(async () => {
|
||||
const appDataPath = app.getPath("userData");
|
||||
await agentService.initialize(appDataPath);
|
||||
|
||||
// Pre-load allowed paths from agent history to prevent breaking "Recent Projects"
|
||||
try {
|
||||
const sessions = await agentService.listSessions({ includeArchived: true });
|
||||
sessions.forEach((session) => {
|
||||
if (session.projectPath) {
|
||||
addAllowedPath(session.projectPath);
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
`[Security] Pre-loaded ${allowedPaths.size} allowed paths from history`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions for security whitelist:", error);
|
||||
}
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
@@ -76,6 +92,43 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Track allowed paths for file operations (security)
|
||||
const allowedPaths = new Set();
|
||||
|
||||
/**
|
||||
* Add a path to the allowed list
|
||||
*/
|
||||
function addAllowedPath(pathToAdd) {
|
||||
if (!pathToAdd) return;
|
||||
allowedPaths.add(path.resolve(pathToAdd));
|
||||
console.log(`[Security] Added allowed path: ${pathToAdd}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is allowed (must be within an allowed directory)
|
||||
*/
|
||||
function isPathAllowed(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Allow access to app data directory (for logs, temp images etc)
|
||||
const appDataPath = app.getPath("userData");
|
||||
if (resolvedPath.startsWith(appDataPath)) return true;
|
||||
|
||||
// Check against all allowed project paths
|
||||
for (const allowedPath of allowedPaths) {
|
||||
// Check if path starts with allowed directory
|
||||
// Ensure we don't match "/foo/bar" against "/foo/b"
|
||||
if (
|
||||
resolvedPath === allowedPath ||
|
||||
resolvedPath.startsWith(allowedPath + path.sep)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPC Handlers
|
||||
|
||||
// Dialog handlers
|
||||
@@ -83,6 +136,11 @@ ipcMain.handle("dialog:openDirectory", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
result.filePaths.forEach((p) => addAllowedPath(p));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -91,12 +149,26 @@ ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
// Allow reading the specific file selected
|
||||
result.filePaths.forEach((p) => addAllowedPath(p));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// File system handlers
|
||||
ipcMain.handle("fs:readFile", async (_, filePath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
@@ -106,6 +178,14 @@ ipcMain.handle("fs:readFile", async (_, filePath) => {
|
||||
|
||||
ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -115,6 +195,14 @@ ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
|
||||
|
||||
ipcMain.handle("fs:mkdir", async (_, dirPath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(dirPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -124,6 +212,14 @@ ipcMain.handle("fs:mkdir", async (_, dirPath) => {
|
||||
|
||||
ipcMain.handle("fs:readdir", async (_, dirPath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(dirPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const result = entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
@@ -138,6 +234,11 @@ ipcMain.handle("fs:readdir", async (_, dirPath) => {
|
||||
|
||||
ipcMain.handle("fs:exists", async (_, filePath) => {
|
||||
try {
|
||||
// Exists check is generally safe, but we can restrict it too for strict privacy
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -147,6 +248,14 @@ ipcMain.handle("fs:exists", async (_, filePath) => {
|
||||
|
||||
ipcMain.handle("fs:stat", async (_, filePath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await fs.stat(filePath);
|
||||
return {
|
||||
success: true,
|
||||
@@ -164,6 +273,14 @@ ipcMain.handle("fs:stat", async (_, filePath) => {
|
||||
|
||||
ipcMain.handle("fs:deleteFile", async (_, filePath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.unlink(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -173,6 +290,14 @@ ipcMain.handle("fs:deleteFile", async (_, filePath) => {
|
||||
|
||||
ipcMain.handle("fs:trashItem", async (_, targetPath) => {
|
||||
try {
|
||||
// Security check
|
||||
if (!isPathAllowed(targetPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Access denied: Path is outside allowed project directories",
|
||||
};
|
||||
}
|
||||
|
||||
await shell.trashItem(targetPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -352,6 +477,10 @@ ipcMain.handle(
|
||||
"sessions:create",
|
||||
async (_, { name, projectPath, workingDirectory }) => {
|
||||
try {
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
if (workingDirectory) addAllowedPath(workingDirectory);
|
||||
|
||||
return await agentService.createSession({
|
||||
name,
|
||||
projectPath,
|
||||
@@ -423,6 +552,9 @@ ipcMain.handle(
|
||||
"auto-mode:start",
|
||||
async (_, { projectPath, maxConcurrency }) => {
|
||||
try {
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
@@ -470,7 +602,7 @@ ipcMain.handle("auto-mode:status", () => {
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"auto-mode:run-feature",
|
||||
async (_, { projectPath, featureId }) => {
|
||||
async (_, { projectPath, featureId, useWorktrees = false }) => {
|
||||
try {
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -482,6 +614,7 @@ ipcMain.handle(
|
||||
projectPath,
|
||||
featureId,
|
||||
sendToRenderer,
|
||||
useWorktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] auto-mode:run-feature error:", error);
|
||||
@@ -581,6 +714,9 @@ ipcMain.handle(
|
||||
ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => {
|
||||
console.log("[IPC] auto-mode:analyze-project called with:", { projectPath });
|
||||
try {
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
@@ -672,6 +808,111 @@ ipcMain.handle(
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Claude CLI Detection IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check Claude Code CLI installation status
|
||||
*/
|
||||
ipcMain.handle("claude:check-cli", async () => {
|
||||
try {
|
||||
const claudeCliDetector = require("./services/claude-cli-detector");
|
||||
const info = claudeCliDetector.getInstallationInfo();
|
||||
return { success: true, ...info };
|
||||
} catch (error) {
|
||||
console.error("[IPC] claude:check-cli error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Codex CLI Detection IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check Codex CLI installation status
|
||||
*/
|
||||
ipcMain.handle("codex:check-cli", async () => {
|
||||
try {
|
||||
const codexCliDetector = require("./services/codex-cli-detector");
|
||||
const info = codexCliDetector.getInstallationInfo();
|
||||
return { success: true, ...info };
|
||||
} catch (error) {
|
||||
console.error("[IPC] codex:check-cli error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all available models from all providers
|
||||
*/
|
||||
ipcMain.handle("model:get-available", async () => {
|
||||
try {
|
||||
const { ModelProviderFactory } = require("./services/model-provider");
|
||||
const models = ModelProviderFactory.getAllModels();
|
||||
return { success: true, models };
|
||||
} catch (error) {
|
||||
console.error("[IPC] model:get-available error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Check all provider installation status
|
||||
*/
|
||||
ipcMain.handle("model:check-providers", async () => {
|
||||
try {
|
||||
const { ModelProviderFactory } = require("./services/model-provider");
|
||||
const status = await ModelProviderFactory.checkAllProviders();
|
||||
return { success: true, providers: status };
|
||||
} catch (error) {
|
||||
console.error("[IPC] model:check-providers error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MCP Server IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle MCP server callback for updating feature status
|
||||
* This can be called by the MCP server script via HTTP or other communication mechanism
|
||||
* Note: The MCP server script runs as a separate process, so it can't directly use Electron IPC.
|
||||
* For now, the MCP server calls featureLoader.updateFeatureStatus directly.
|
||||
* This handler is here for future extensibility (e.g., HTTP endpoint bridge).
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"mcp:update-feature-status",
|
||||
async (_, { featureId, status, projectPath, summary }) => {
|
||||
try {
|
||||
const featureLoader = require("./services/feature-loader");
|
||||
await featureLoader.updateFeatureStatus(
|
||||
featureId,
|
||||
status,
|
||||
projectPath,
|
||||
summary
|
||||
);
|
||||
|
||||
// Notify renderer if window is available
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("mcp:feature-status-updated", {
|
||||
featureId,
|
||||
status,
|
||||
projectPath,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[IPC] mcp:update-feature-status error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Feature Suggestions IPC Handlers
|
||||
// ============================================================================
|
||||
@@ -682,53 +923,53 @@ let suggestionsExecution = null;
|
||||
/**
|
||||
* Generate feature suggestions by analyzing the project
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"suggestions:generate",
|
||||
async (_, { projectPath }) => {
|
||||
console.log("[IPC] suggestions:generate called with:", { projectPath });
|
||||
ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
|
||||
console.log("[IPC] suggestions:generate called with:", { projectPath });
|
||||
|
||||
try {
|
||||
// Check if already running
|
||||
if (suggestionsExecution && suggestionsExecution.isActive()) {
|
||||
return { success: false, error: "Suggestions generation is already running" };
|
||||
}
|
||||
|
||||
// Create execution context
|
||||
suggestionsExecution = {
|
||||
abortController: null,
|
||||
query: null,
|
||||
isActive: () => suggestionsExecution !== null,
|
||||
try {
|
||||
// Check if already running
|
||||
if (suggestionsExecution && suggestionsExecution.isActive()) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Suggestions generation is already running",
|
||||
};
|
||||
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("suggestions:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
// Start generating suggestions (runs in background)
|
||||
featureSuggestionsService
|
||||
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
|
||||
.catch((error) => {
|
||||
console.error("[IPC] suggestions:generate background error:", error);
|
||||
sendToRenderer({
|
||||
type: "suggestions_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
suggestionsExecution = null;
|
||||
});
|
||||
|
||||
// Return immediately
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[IPC] suggestions:generate error:", error);
|
||||
suggestionsExecution = null;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
// Create execution context
|
||||
suggestionsExecution = {
|
||||
abortController: null,
|
||||
query: null,
|
||||
isActive: () => suggestionsExecution !== null,
|
||||
};
|
||||
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("suggestions:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
// Start generating suggestions (runs in background)
|
||||
featureSuggestionsService
|
||||
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
|
||||
.catch((error) => {
|
||||
console.error("[IPC] suggestions:generate background error:", error);
|
||||
sendToRenderer({
|
||||
type: "suggestions_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
suggestionsExecution = null;
|
||||
});
|
||||
|
||||
// Return immediately
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[IPC] suggestions:generate error:", error);
|
||||
suggestionsExecution = null;
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stop the current suggestions generation
|
||||
@@ -757,6 +998,79 @@ ipcMain.handle("suggestions:status", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// OpenAI API Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Test OpenAI API connection
|
||||
*/
|
||||
ipcMain.handle("openai:test-connection", async (_, { apiKey }) => {
|
||||
try {
|
||||
// Simple test using fetch to OpenAI API
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
message: `Connected successfully. Found ${
|
||||
data.data?.length || 0
|
||||
} models.`,
|
||||
};
|
||||
} else {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.error?.message || "Failed to connect to OpenAI API",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[IPC] openai:test-connection error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Management IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Revert feature changes by removing the worktree
|
||||
* This effectively discards all changes made by the agent
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:revert-feature",
|
||||
async (_, { projectPath, featureId }) => {
|
||||
console.log("[IPC] worktree:revert-feature called with:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
});
|
||||
try {
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
return await autoModeService.revertFeature({
|
||||
projectPath,
|
||||
featureId,
|
||||
sendToRenderer,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:revert-feature error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Spec Regeneration IPC Handlers
|
||||
// ============================================================================
|
||||
@@ -770,12 +1084,20 @@ let specRegenerationExecution = null;
|
||||
ipcMain.handle(
|
||||
"spec-regeneration:generate",
|
||||
async (_, { projectPath, projectDefinition }) => {
|
||||
console.log("[IPC] spec-regeneration:generate called with:", { projectPath });
|
||||
console.log("[IPC] spec-regeneration:generate called with:", {
|
||||
projectPath,
|
||||
});
|
||||
|
||||
try {
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
// Check if already running
|
||||
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
|
||||
return { success: false, error: "Spec regeneration is already running" };
|
||||
return {
|
||||
success: false,
|
||||
error: "Spec regeneration is already running",
|
||||
};
|
||||
}
|
||||
|
||||
// Create execution context
|
||||
@@ -793,9 +1115,17 @@ ipcMain.handle(
|
||||
|
||||
// Start regenerating spec (runs in background)
|
||||
specRegenerationService
|
||||
.regenerateSpec(projectPath, projectDefinition, sendToRenderer, specRegenerationExecution)
|
||||
.regenerateSpec(
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
sendToRenderer,
|
||||
specRegenerationExecution
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[IPC] spec-regeneration:generate background error:", error);
|
||||
console.error(
|
||||
"[IPC] spec-regeneration:generate background error:",
|
||||
error
|
||||
);
|
||||
sendToRenderer({
|
||||
type: "spec_regeneration_error",
|
||||
error: error.message,
|
||||
@@ -821,7 +1151,10 @@ ipcMain.handle(
|
||||
ipcMain.handle("spec-regeneration:stop", async () => {
|
||||
console.log("[IPC] spec-regeneration:stop called");
|
||||
try {
|
||||
if (specRegenerationExecution && specRegenerationExecution.abortController) {
|
||||
if (
|
||||
specRegenerationExecution &&
|
||||
specRegenerationExecution.abortController
|
||||
) {
|
||||
specRegenerationExecution.abortController.abort();
|
||||
}
|
||||
specRegenerationExecution = null;
|
||||
@@ -838,7 +1171,9 @@ ipcMain.handle("spec-regeneration:stop", async () => {
|
||||
ipcMain.handle("spec-regeneration:status", () => {
|
||||
return {
|
||||
success: true,
|
||||
isRunning: specRegenerationExecution !== null && specRegenerationExecution.isActive(),
|
||||
isRunning:
|
||||
specRegenerationExecution !== null &&
|
||||
specRegenerationExecution.isActive(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -848,9 +1183,15 @@ ipcMain.handle("spec-regeneration:status", () => {
|
||||
ipcMain.handle(
|
||||
"spec-regeneration:create",
|
||||
async (_, { projectPath, projectOverview, generateFeatures = true }) => {
|
||||
console.log("[IPC] spec-regeneration:create called with:", { projectPath, generateFeatures });
|
||||
console.log("[IPC] spec-regeneration:create called with:", {
|
||||
projectPath,
|
||||
generateFeatures,
|
||||
});
|
||||
|
||||
try {
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
// Check if already running
|
||||
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
|
||||
return { success: false, error: "Spec creation is already running" };
|
||||
@@ -871,9 +1212,18 @@ ipcMain.handle(
|
||||
|
||||
// Start creating spec (runs in background)
|
||||
specRegenerationService
|
||||
.createInitialSpec(projectPath, projectOverview, sendToRenderer, specRegenerationExecution, generateFeatures)
|
||||
.createInitialSpec(
|
||||
projectPath,
|
||||
projectOverview,
|
||||
sendToRenderer,
|
||||
specRegenerationExecution,
|
||||
generateFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[IPC] spec-regeneration:create background error:", error);
|
||||
console.error(
|
||||
"[IPC] spec-regeneration:create background error:",
|
||||
error
|
||||
);
|
||||
sendToRenderer({
|
||||
type: "spec_regeneration_error",
|
||||
error: error.message,
|
||||
@@ -892,3 +1242,124 @@ ipcMain.handle(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merge feature worktree changes back to main branch
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:merge-feature",
|
||||
async (_, { projectPath, featureId, options }) => {
|
||||
console.log("[IPC] worktree:merge-feature called with:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
options,
|
||||
});
|
||||
try {
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
return await autoModeService.mergeFeature({
|
||||
projectPath,
|
||||
featureId,
|
||||
options,
|
||||
sendToRenderer,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:merge-feature error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
ipcMain.handle("worktree:get-info", async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getWorktreeInfo({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-info error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get worktree status (changed files, commits)
|
||||
*/
|
||||
ipcMain.handle("worktree:get-status", async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getWorktreeStatus({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-status error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all feature worktrees
|
||||
*/
|
||||
ipcMain.handle("worktree:list", async (_, { projectPath }) => {
|
||||
try {
|
||||
return await autoModeService.listWorktrees({ projectPath });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:list error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get file diffs for a worktree
|
||||
*/
|
||||
ipcMain.handle("worktree:get-diffs", async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getFileDiffs({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-diffs error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a worktree
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:get-file-diff",
|
||||
async (_, { projectPath, featureId, filePath }) => {
|
||||
try {
|
||||
return await autoModeService.getFileDiff({
|
||||
projectPath,
|
||||
featureId,
|
||||
filePath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-file-diff error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get file diffs for the main project (non-worktree)
|
||||
*/
|
||||
ipcMain.handle("git:get-diffs", async (_, { projectPath }) => {
|
||||
try {
|
||||
return await worktreeManager.getFileDiffs(projectPath);
|
||||
} catch (error) {
|
||||
console.error("[IPC] git:get-diffs error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in the main project (non-worktree)
|
||||
*/
|
||||
ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
|
||||
try {
|
||||
return await worktreeManager.getFileDiff(projectPath, filePath);
|
||||
} catch (error) {
|
||||
console.error("[IPC] git:get-file-diff error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,8 +97,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
status: () => ipcRenderer.invoke("auto-mode:status"),
|
||||
|
||||
// Run a specific feature
|
||||
runFeature: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("auto-mode:run-feature", { projectPath, featureId }),
|
||||
runFeature: (projectPath, featureId, useWorktrees) =>
|
||||
ipcRenderer.invoke("auto-mode:run-feature", { projectPath, featureId, useWorktrees }),
|
||||
|
||||
// Verify a specific feature by running its tests
|
||||
verifyFeature: (projectPath, featureId) =>
|
||||
@@ -140,6 +140,67 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
},
|
||||
},
|
||||
|
||||
// Claude CLI Detection API
|
||||
checkClaudeCli: () => ipcRenderer.invoke("claude:check-cli"),
|
||||
|
||||
// Codex CLI Detection API
|
||||
checkCodexCli: () => ipcRenderer.invoke("codex:check-cli"),
|
||||
|
||||
// Model Management APIs
|
||||
model: {
|
||||
// Get all available models from all providers
|
||||
getAvailable: () => ipcRenderer.invoke("model:get-available"),
|
||||
|
||||
// Check all provider installation status
|
||||
checkProviders: () => ipcRenderer.invoke("model:check-providers"),
|
||||
},
|
||||
|
||||
// OpenAI API
|
||||
testOpenAIConnection: (apiKey) =>
|
||||
ipcRenderer.invoke("openai:test-connection", { apiKey }),
|
||||
|
||||
// Worktree Management APIs
|
||||
worktree: {
|
||||
// Revert feature changes by removing the worktree
|
||||
revertFeature: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }),
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
mergeFeature: (projectPath, featureId, options) =>
|
||||
ipcRenderer.invoke("worktree:merge-feature", { projectPath, featureId, options }),
|
||||
|
||||
// Get worktree info for a feature
|
||||
getInfo: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }),
|
||||
|
||||
// Get worktree status (changed files, commits)
|
||||
getStatus: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
|
||||
|
||||
// List all feature worktrees
|
||||
list: (projectPath) =>
|
||||
ipcRenderer.invoke("worktree:list", { projectPath }),
|
||||
|
||||
// Get file diffs for a feature worktree
|
||||
getDiffs: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }),
|
||||
|
||||
// Get diff for a specific file in a worktree
|
||||
getFileDiff: (projectPath, featureId, filePath) =>
|
||||
ipcRenderer.invoke("worktree:get-file-diff", { projectPath, featureId, filePath }),
|
||||
},
|
||||
|
||||
// Git Operations APIs (for non-worktree operations)
|
||||
git: {
|
||||
// Get file diffs for the main project
|
||||
getDiffs: (projectPath) =>
|
||||
ipcRenderer.invoke("git:get-diffs", { projectPath }),
|
||||
|
||||
// Get diff for a specific file in the main project
|
||||
getFileDiff: (projectPath, filePath) =>
|
||||
ipcRenderer.invoke("git:get-file-diff", { projectPath, filePath }),
|
||||
},
|
||||
|
||||
// Feature Suggestions API
|
||||
suggestions: {
|
||||
// Generate feature suggestions
|
||||
|
||||
119
app/electron/services/claude-cli-detector.js
Normal file
119
app/electron/services/claude-cli-detector.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class ClaudeCliDetector {
|
||||
/**
|
||||
* Check if Claude Code CLI is installed and accessible
|
||||
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'sdk'|'none' }
|
||||
*/
|
||||
static detectClaudeInstallation() {
|
||||
try {
|
||||
// Method 1: Check if 'claude' command is in PATH
|
||||
try {
|
||||
const claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
||||
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
|
||||
return {
|
||||
installed: true,
|
||||
path: claudePath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
};
|
||||
} catch (error) {
|
||||
// CLI not in PATH, check local installation
|
||||
}
|
||||
|
||||
// Method 2: Check for local installation
|
||||
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
|
||||
if (fs.existsSync(localClaudePath)) {
|
||||
try {
|
||||
const version = execSync(`${localClaudePath} --version`, { encoding: 'utf-8' }).trim();
|
||||
return {
|
||||
installed: true,
|
||||
path: localClaudePath,
|
||||
version: version,
|
||||
method: 'cli-local'
|
||||
};
|
||||
} catch (error) {
|
||||
// Local CLI exists but may not be executable
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check Windows path
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const claudePath = execSync('where claude', { encoding: 'utf-8' }).trim();
|
||||
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
|
||||
return {
|
||||
installed: true,
|
||||
path: claudePath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
};
|
||||
} catch (error) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: SDK mode (using OAuth token)
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return {
|
||||
installed: true,
|
||||
path: null,
|
||||
version: 'SDK Mode',
|
||||
method: 'sdk'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
method: 'none'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error);
|
||||
return {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
method: 'none',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation recommendations
|
||||
*/
|
||||
static getInstallationInfo() {
|
||||
const detection = this.detectClaudeInstallation();
|
||||
|
||||
if (detection.installed) {
|
||||
return {
|
||||
status: 'installed',
|
||||
method: detection.method,
|
||||
version: detection.version,
|
||||
path: detection.path,
|
||||
recommendation: detection.method === 'cli'
|
||||
? 'Using Claude Code CLI - optimal for long-running tasks'
|
||||
: 'Using SDK mode - works well but CLI may provide better performance'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'not_installed',
|
||||
recommendation: 'Consider installing Claude Code CLI for better performance with ultrathink',
|
||||
installCommands: {
|
||||
macos: 'curl -fsSL claude.ai/install.sh | bash',
|
||||
windows: 'irm https://claude.ai/install.ps1 | iex',
|
||||
linux: 'curl -fsSL claude.ai/install.sh | bash',
|
||||
npm: 'npm install -g @anthropic-ai/claude-code'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClaudeCliDetector;
|
||||
|
||||
229
app/electron/services/codex-cli-detector.js
Normal file
229
app/electron/services/codex-cli-detector.js
Normal file
@@ -0,0 +1,229 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
/**
|
||||
* Codex CLI Detector - Checks if OpenAI Codex CLI is installed
|
||||
*
|
||||
* Codex CLI is OpenAI's agent CLI tool that allows users to use
|
||||
* GPT-5.1 Codex models (gpt-5.1-codex-max, gpt-5.1-codex, etc.)
|
||||
* for code generation and agentic tasks.
|
||||
*/
|
||||
class CodexCliDetector {
|
||||
/**
|
||||
* Check if Codex CLI is installed and accessible
|
||||
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
|
||||
*/
|
||||
static detectCodexInstallation() {
|
||||
try {
|
||||
// Method 1: Check if 'codex' command is in PATH
|
||||
try {
|
||||
const codexPath = execSync('which codex 2>/dev/null', { encoding: 'utf-8' }).trim();
|
||||
if (codexPath) {
|
||||
const version = this.getCodexVersion(codexPath);
|
||||
return {
|
||||
installed: true,
|
||||
path: codexPath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// CLI not in PATH, continue checking other methods
|
||||
}
|
||||
|
||||
// Method 2: Check for npm global installation
|
||||
try {
|
||||
const npmListOutput = execSync('npm list -g @openai/codex --depth=0 2>/dev/null', { encoding: 'utf-8' });
|
||||
if (npmListOutput && npmListOutput.includes('@openai/codex')) {
|
||||
// Get the path from npm bin
|
||||
const npmBinPath = execSync('npm bin -g', { encoding: 'utf-8' }).trim();
|
||||
const codexPath = path.join(npmBinPath, 'codex');
|
||||
const version = this.getCodexVersion(codexPath);
|
||||
return {
|
||||
installed: true,
|
||||
path: codexPath,
|
||||
version: version,
|
||||
method: 'npm'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// npm global not found
|
||||
}
|
||||
|
||||
// Method 3: Check for Homebrew installation on macOS
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const brewList = execSync('brew list --formula 2>/dev/null', { encoding: 'utf-8' });
|
||||
if (brewList.includes('codex')) {
|
||||
const brewPrefixOutput = execSync('brew --prefix codex 2>/dev/null', { encoding: 'utf-8' }).trim();
|
||||
const codexPath = path.join(brewPrefixOutput, 'bin', 'codex');
|
||||
const version = this.getCodexVersion(codexPath);
|
||||
return {
|
||||
installed: true,
|
||||
path: codexPath,
|
||||
version: version,
|
||||
method: 'brew'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Homebrew not found or codex not installed via brew
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Check Windows path
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const codexPath = execSync('where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
|
||||
if (codexPath) {
|
||||
const version = this.getCodexVersion(codexPath);
|
||||
return {
|
||||
installed: true,
|
||||
path: codexPath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Not found on Windows
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Check common installation paths
|
||||
const commonPaths = [
|
||||
path.join(os.homedir(), '.local', 'bin', 'codex'),
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
|
||||
'/usr/local/bin/codex',
|
||||
'/opt/homebrew/bin/codex',
|
||||
];
|
||||
|
||||
for (const checkPath of commonPaths) {
|
||||
if (fs.existsSync(checkPath)) {
|
||||
const version = this.getCodexVersion(checkPath);
|
||||
return {
|
||||
installed: true,
|
||||
path: checkPath,
|
||||
version: version,
|
||||
method: 'cli'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly)
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
method: 'api-key-only',
|
||||
hasApiKey: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
method: 'none'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CodexCliDetector] Error detecting Codex installation:', error);
|
||||
return {
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
method: 'none',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Codex CLI version from executable path
|
||||
* @param {string} codexPath Path to codex executable
|
||||
* @returns {string|null} Version string or null
|
||||
*/
|
||||
static getCodexVersion(codexPath) {
|
||||
try {
|
||||
const version = execSync(`"${codexPath}" --version 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
||||
return version || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation info and recommendations
|
||||
* @returns {Object} Installation status and recommendations
|
||||
*/
|
||||
static getInstallationInfo() {
|
||||
const detection = this.detectCodexInstallation();
|
||||
|
||||
if (detection.installed) {
|
||||
return {
|
||||
status: 'installed',
|
||||
method: detection.method,
|
||||
version: detection.version,
|
||||
path: detection.path,
|
||||
recommendation: detection.method === 'cli'
|
||||
? 'Using Codex CLI - ready for GPT-5.1 Codex models'
|
||||
: `Using Codex CLI via ${detection.method} - ready for GPT-5.1 Codex models`
|
||||
};
|
||||
}
|
||||
|
||||
// Not installed but has API key
|
||||
if (detection.method === 'api-key-only') {
|
||||
return {
|
||||
status: 'api_key_only',
|
||||
method: 'api-key-only',
|
||||
recommendation: 'OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.',
|
||||
installCommands: this.getInstallCommands()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'not_installed',
|
||||
recommendation: 'Install OpenAI Codex CLI to use GPT-5.1 Codex models for agentic tasks',
|
||||
installCommands: this.getInstallCommands()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation commands for different platforms
|
||||
* @returns {Object} Installation commands by platform
|
||||
*/
|
||||
static getInstallCommands() {
|
||||
return {
|
||||
npm: 'npm install -g @openai/codex@latest',
|
||||
macos: 'brew install codex',
|
||||
linux: 'npm install -g @openai/codex@latest',
|
||||
windows: 'npm install -g @openai/codex@latest'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex CLI supports a specific model
|
||||
* @param {string} model Model name to check
|
||||
* @returns {boolean} Whether the model is supported
|
||||
*/
|
||||
static isModelSupported(model) {
|
||||
const supportedModels = [
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.1'
|
||||
];
|
||||
return supportedModels.includes(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for Codex CLI
|
||||
* @returns {string} Default model name
|
||||
*/
|
||||
static getDefaultModel() {
|
||||
return 'gpt-5.1-codex-max';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CodexCliDetector;
|
||||
351
app/electron/services/codex-config-manager.js
Normal file
351
app/electron/services/codex-config-manager.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Codex TOML Configuration Manager
|
||||
*
|
||||
* Manages Codex CLI's TOML configuration file to add/update MCP server settings.
|
||||
* Codex CLI looks for config at:
|
||||
* - ~/.codex/config.toml (user-level)
|
||||
* - .codex/config.toml (project-level, takes precedence)
|
||||
*/
|
||||
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class CodexConfigManager {
|
||||
constructor() {
|
||||
this.userConfigPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
this.projectConfigPath = null; // Will be set per project
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the project path for project-level config
|
||||
*/
|
||||
setProjectPath(projectPath) {
|
||||
this.projectConfigPath = path.join(projectPath, '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective config path (project-level if exists, otherwise user-level)
|
||||
*/
|
||||
async getConfigPath() {
|
||||
if (this.projectConfigPath) {
|
||||
try {
|
||||
await fs.access(this.projectConfigPath);
|
||||
return this.projectConfigPath;
|
||||
} catch (e) {
|
||||
// Project config doesn't exist, fall back to user config
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure user config directory exists
|
||||
const userConfigDir = path.dirname(this.userConfigPath);
|
||||
try {
|
||||
await fs.mkdir(userConfigDir, { recursive: true });
|
||||
} catch (e) {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
return this.userConfigPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing TOML config (simple parser for our needs)
|
||||
*/
|
||||
async readConfig(configPath) {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return this.parseToml(content);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple TOML parser for our specific use case
|
||||
* This is a minimal parser that handles the MCP server config structure
|
||||
*/
|
||||
parseToml(content) {
|
||||
const config = {};
|
||||
let currentSection = null;
|
||||
let currentSubsection = null;
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section header: [section]
|
||||
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const sectionName = sectionMatch[1];
|
||||
const parts = sectionName.split('.');
|
||||
|
||||
if (parts.length === 1) {
|
||||
currentSection = parts[0];
|
||||
currentSubsection = null;
|
||||
if (!config[currentSection]) {
|
||||
config[currentSection] = {};
|
||||
}
|
||||
} else if (parts.length === 2) {
|
||||
currentSection = parts[0];
|
||||
currentSubsection = parts[1];
|
||||
if (!config[currentSection]) {
|
||||
config[currentSection] = {};
|
||||
}
|
||||
if (!config[currentSection][currentSubsection]) {
|
||||
config[currentSection][currentSubsection] = {};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair: key = value
|
||||
const kvMatch = trimmed.match(/^([^=]+)=(.+)$/);
|
||||
if (kvMatch) {
|
||||
const key = kvMatch[1].trim();
|
||||
let value = kvMatch[2].trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Parse boolean
|
||||
if (value === 'true') value = true;
|
||||
else if (value === 'false') value = false;
|
||||
// Parse number
|
||||
else if (/^-?\d+$/.test(value)) value = parseInt(value, 10);
|
||||
else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value);
|
||||
|
||||
if (currentSubsection) {
|
||||
if (!config[currentSection][currentSubsection]) {
|
||||
config[currentSection][currentSubsection] = {};
|
||||
}
|
||||
config[currentSection][currentSubsection][key] = value;
|
||||
} else if (currentSection) {
|
||||
if (!config[currentSection]) {
|
||||
config[currentSection] = {};
|
||||
}
|
||||
config[currentSection][key] = value;
|
||||
} else {
|
||||
config[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert config object back to TOML format
|
||||
*/
|
||||
stringifyToml(config, indent = 0) {
|
||||
const indentStr = ' '.repeat(indent);
|
||||
let result = '';
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Section
|
||||
result += `${indentStr}[${key}]\n`;
|
||||
result += this.stringifyToml(value, indent);
|
||||
} else {
|
||||
// Key-value
|
||||
let valueStr = value;
|
||||
if (typeof value === 'string') {
|
||||
// Escape quotes and wrap in quotes if needed
|
||||
if (value.includes('"') || value.includes("'") || value.includes(' ')) {
|
||||
valueStr = `"${value.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
} else if (typeof value === 'boolean') {
|
||||
valueStr = value.toString();
|
||||
}
|
||||
result += `${indentStr}${key} = ${valueStr}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the automaker-tools MCP server
|
||||
*/
|
||||
async configureMcpServer(projectPath, mcpServerScriptPath) {
|
||||
this.setProjectPath(projectPath);
|
||||
const configPath = await this.getConfigPath();
|
||||
|
||||
// Read existing config
|
||||
const config = await this.readConfig(configPath);
|
||||
|
||||
// Ensure mcp_servers section exists
|
||||
if (!config.mcp_servers) {
|
||||
config.mcp_servers = {};
|
||||
}
|
||||
|
||||
// Configure automaker-tools server
|
||||
config.mcp_servers['automaker-tools'] = {
|
||||
command: 'node',
|
||||
args: [mcpServerScriptPath],
|
||||
env: {
|
||||
AUTOMAKER_PROJECT_PATH: projectPath
|
||||
},
|
||||
startup_timeout_sec: 10,
|
||||
tool_timeout_sec: 60,
|
||||
enabled_tools: ['UpdateFeatureStatus']
|
||||
};
|
||||
|
||||
// Ensure experimental_use_rmcp_client is enabled (if needed)
|
||||
if (!config.experimental_use_rmcp_client) {
|
||||
config.experimental_use_rmcp_client = true;
|
||||
}
|
||||
|
||||
// Write config back
|
||||
await this.writeConfig(configPath, config);
|
||||
|
||||
console.log(`[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write config to TOML file
|
||||
*/
|
||||
async writeConfig(configPath, config) {
|
||||
let content = '';
|
||||
|
||||
// Write top-level keys first (preserve existing non-MCP config)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'mcp_servers' || key === 'experimental_use_rmcp_client') {
|
||||
continue; // Handle these separately
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
content += `${key} = ${this.formatValue(value)}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Write experimental flag if enabled
|
||||
if (config.experimental_use_rmcp_client) {
|
||||
if (content && !content.endsWith('\n\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
content += `experimental_use_rmcp_client = true\n`;
|
||||
}
|
||||
|
||||
// Write mcp_servers section
|
||||
if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) {
|
||||
if (content && !content.endsWith('\n\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
for (const [serverName, serverConfig] of Object.entries(config.mcp_servers)) {
|
||||
content += `\n[mcp_servers.${serverName}]\n`;
|
||||
|
||||
// Write command first
|
||||
if (serverConfig.command) {
|
||||
content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`;
|
||||
}
|
||||
|
||||
// Write args
|
||||
if (serverConfig.args && Array.isArray(serverConfig.args)) {
|
||||
const argsStr = serverConfig.args.map(a => `"${this.escapeTomlString(a)}"`).join(', ');
|
||||
content += `args = [${argsStr}]\n`;
|
||||
}
|
||||
|
||||
// Write timeouts (must be before env subsection)
|
||||
if (serverConfig.startup_timeout_sec !== undefined) {
|
||||
content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`;
|
||||
}
|
||||
|
||||
if (serverConfig.tool_timeout_sec !== undefined) {
|
||||
content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`;
|
||||
}
|
||||
|
||||
// Write enabled_tools (must be before env subsection - at server level, not env level)
|
||||
if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) {
|
||||
const toolsStr = serverConfig.enabled_tools.map(t => `"${this.escapeTomlString(t)}"`).join(', ');
|
||||
content += `enabled_tools = [${toolsStr}]\n`;
|
||||
}
|
||||
|
||||
// Write env section last (as a separate subsection)
|
||||
// IMPORTANT: In TOML, once we start [mcp_servers.server_name.env],
|
||||
// everything after belongs to that subsection until a new section starts
|
||||
if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) {
|
||||
content += `\n[mcp_servers.${serverName}.env]\n`;
|
||||
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
|
||||
content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(configPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in TOML strings
|
||||
*/
|
||||
escapeTomlString(str) {
|
||||
return str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for TOML output
|
||||
*/
|
||||
formatValue(value) {
|
||||
if (typeof value === 'string') {
|
||||
// Escape quotes
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value.toString();
|
||||
} else if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
return `"${String(value)}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove automaker-tools MCP server configuration
|
||||
*/
|
||||
async removeMcpServer(projectPath) {
|
||||
this.setProjectPath(projectPath);
|
||||
const configPath = await this.getConfigPath();
|
||||
|
||||
try {
|
||||
const config = await this.readConfig(configPath);
|
||||
|
||||
if (config.mcp_servers && config.mcp_servers['automaker-tools']) {
|
||||
delete config.mcp_servers['automaker-tools'];
|
||||
|
||||
// If no more MCP servers, remove the section
|
||||
if (Object.keys(config.mcp_servers).length === 0) {
|
||||
delete config.mcp_servers;
|
||||
}
|
||||
|
||||
await this.writeConfig(configPath, config);
|
||||
console.log(`[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[CodexConfigManager] Error removing MCP server config:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CodexConfigManager();
|
||||
610
app/electron/services/codex-executor.js
Normal file
610
app/electron/services/codex-executor.js
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* Codex CLI Execution Wrapper
|
||||
*
|
||||
* This module handles spawning and managing Codex CLI processes
|
||||
* for executing OpenAI model queries.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { EventEmitter } = require('events');
|
||||
const readline = require('readline');
|
||||
const path = require('path');
|
||||
const CodexCliDetector = require('./codex-cli-detector');
|
||||
const codexConfigManager = require('./codex-config-manager');
|
||||
|
||||
/**
|
||||
* Message types from Codex CLI JSON output
|
||||
*/
|
||||
const CODEX_EVENT_TYPES = {
|
||||
THREAD_STARTED: 'thread.started',
|
||||
ITEM_STARTED: 'item.started',
|
||||
ITEM_COMPLETED: 'item.completed',
|
||||
THREAD_COMPLETED: 'thread.completed',
|
||||
ERROR: 'error'
|
||||
};
|
||||
|
||||
/**
|
||||
* Codex Executor - Manages Codex CLI process execution
|
||||
*/
|
||||
class CodexExecutor extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentProcess = null;
|
||||
this.codexPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and cache the Codex CLI path
|
||||
* @returns {string|null} Path to codex executable
|
||||
*/
|
||||
findCodexPath() {
|
||||
if (this.codexPath) {
|
||||
return this.codexPath;
|
||||
}
|
||||
|
||||
const installation = CodexCliDetector.detectCodexInstallation();
|
||||
if (installation.installed && installation.path) {
|
||||
this.codexPath = installation.path;
|
||||
return this.codexPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Codex CLI query
|
||||
* @param {Object} options Execution options
|
||||
* @param {string} options.prompt The prompt to execute
|
||||
* @param {string} options.model Model to use (default: gpt-5.1-codex-max)
|
||||
* @param {string} options.cwd Working directory
|
||||
* @param {string} options.systemPrompt System prompt (optional, will be prepended to prompt)
|
||||
* @param {number} options.maxTurns Not used - Codex CLI doesn't support this parameter
|
||||
* @param {string[]} options.allowedTools Not used - Codex CLI doesn't support this parameter
|
||||
* @param {Object} options.env Environment variables
|
||||
* @param {Object} options.mcpServers MCP servers configuration (for configuring Codex TOML)
|
||||
* @returns {AsyncGenerator} Generator yielding messages
|
||||
*/
|
||||
async *execute(options) {
|
||||
const {
|
||||
prompt,
|
||||
model = 'gpt-5.1-codex-max',
|
||||
cwd = process.cwd(),
|
||||
systemPrompt,
|
||||
maxTurns, // Not used by Codex CLI
|
||||
allowedTools, // Not used by Codex CLI
|
||||
env = {},
|
||||
mcpServers = null
|
||||
} = options;
|
||||
|
||||
const codexPath = this.findCodexPath();
|
||||
if (!codexPath) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: 'Codex CLI not found. Please install it with: npm install -g @openai/codex@latest'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure MCP server if provided
|
||||
if (mcpServers && mcpServers['automaker-tools']) {
|
||||
try {
|
||||
// Get the absolute path to the MCP server script
|
||||
const mcpServerScriptPath = path.resolve(__dirname, 'mcp-server-stdio.js');
|
||||
|
||||
// Verify the script exists
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(mcpServerScriptPath)) {
|
||||
console.warn(`[CodexExecutor] MCP server script not found at ${mcpServerScriptPath}, skipping MCP configuration`);
|
||||
} else {
|
||||
// Configure Codex TOML to use the MCP server
|
||||
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
|
||||
console.log('[CodexExecutor] Configured automaker-tools MCP server for Codex CLI');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CodexExecutor] Failed to configure MCP server:', error);
|
||||
// Continue execution even if MCP config fails - Codex will work without MCP tools
|
||||
}
|
||||
}
|
||||
|
||||
// Combine system prompt with main prompt if provided
|
||||
// Codex CLI doesn't support --system-prompt argument, so we prepend it to the prompt
|
||||
let combinedPrompt = prompt;
|
||||
console.log('[CodexExecutor] Original prompt length:', prompt?.length || 0);
|
||||
if (systemPrompt) {
|
||||
combinedPrompt = `${systemPrompt}\n\n---\n\n${prompt}`;
|
||||
console.log('[CodexExecutor] System prompt prepended to main prompt');
|
||||
console.log('[CodexExecutor] System prompt length:', systemPrompt.length);
|
||||
console.log('[CodexExecutor] Combined prompt length:', combinedPrompt.length);
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
// Note: maxTurns and allowedTools are not supported by Codex CLI
|
||||
console.log('[CodexExecutor] Building command arguments...');
|
||||
const args = this.buildArgs({
|
||||
prompt: combinedPrompt,
|
||||
model
|
||||
});
|
||||
|
||||
console.log('[CodexExecutor] Executing command:', codexPath);
|
||||
console.log('[CodexExecutor] Number of args:', args.length);
|
||||
console.log('[CodexExecutor] Args (without prompt):', args.slice(0, -1).join(' '));
|
||||
console.log('[CodexExecutor] Prompt length in args:', args[args.length - 1]?.length || 0);
|
||||
console.log('[CodexExecutor] Prompt preview (first 200 chars):', args[args.length - 1]?.substring(0, 200));
|
||||
console.log('[CodexExecutor] Working directory:', cwd);
|
||||
|
||||
// Spawn the process
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
// Ensure OPENAI_API_KEY is available
|
||||
OPENAI_API_KEY: env.OPENAI_API_KEY || process.env.OPENAI_API_KEY
|
||||
};
|
||||
|
||||
// Log API key status (without exposing the key)
|
||||
if (processEnv.OPENAI_API_KEY) {
|
||||
console.log('[CodexExecutor] OPENAI_API_KEY is set (length:', processEnv.OPENAI_API_KEY.length, ')');
|
||||
} else {
|
||||
console.warn('[CodexExecutor] WARNING: OPENAI_API_KEY is not set!');
|
||||
}
|
||||
|
||||
console.log('[CodexExecutor] Spawning process...');
|
||||
const proc = spawn(codexPath, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
this.currentProcess = proc;
|
||||
console.log('[CodexExecutor] Process spawned with PID:', proc.pid);
|
||||
|
||||
// Track process events
|
||||
proc.on('error', (error) => {
|
||||
console.error('[CodexExecutor] Process error:', error);
|
||||
});
|
||||
|
||||
proc.on('spawn', () => {
|
||||
console.log('[CodexExecutor] Process spawned successfully');
|
||||
});
|
||||
|
||||
// Collect stderr output as it comes in
|
||||
let stderr = '';
|
||||
let hasOutput = false;
|
||||
let stdoutChunks = [];
|
||||
let stderrChunks = [];
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const errorText = data.toString();
|
||||
stderr += errorText;
|
||||
stderrChunks.push(errorText);
|
||||
hasOutput = true;
|
||||
console.error('[CodexExecutor] stderr chunk received (', data.length, 'bytes):', errorText.substring(0, 200));
|
||||
});
|
||||
|
||||
proc.stderr.on('end', () => {
|
||||
console.log('[CodexExecutor] stderr stream ended. Total chunks:', stderrChunks.length, 'Total length:', stderr.length);
|
||||
});
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
stdoutChunks.push(text);
|
||||
hasOutput = true;
|
||||
console.log('[CodexExecutor] stdout chunk received (', data.length, 'bytes):', text.substring(0, 200));
|
||||
});
|
||||
|
||||
proc.stdout.on('end', () => {
|
||||
console.log('[CodexExecutor] stdout stream ended. Total chunks:', stdoutChunks.length);
|
||||
});
|
||||
|
||||
// Create readline interface for parsing JSONL output
|
||||
console.log('[CodexExecutor] Creating readline interface...');
|
||||
const rl = readline.createInterface({
|
||||
input: proc.stdout,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// Track accumulated content for converting to Claude format
|
||||
let accumulatedText = '';
|
||||
let toolUses = [];
|
||||
let lastOutputTime = Date.now();
|
||||
const OUTPUT_TIMEOUT = 30000; // 30 seconds timeout for no output
|
||||
let lineCount = 0;
|
||||
let jsonParseErrors = 0;
|
||||
|
||||
// Set up timeout check
|
||||
const checkTimeout = setInterval(() => {
|
||||
const timeSinceLastOutput = Date.now() - lastOutputTime;
|
||||
if (timeSinceLastOutput > OUTPUT_TIMEOUT && !hasOutput) {
|
||||
console.warn('[CodexExecutor] No output received for', timeSinceLastOutput, 'ms. Process still alive:', !proc.killed);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
console.log('[CodexExecutor] Starting to read lines from stdout...');
|
||||
|
||||
// Process stdout line by line (JSONL format)
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
hasOutput = true;
|
||||
lastOutputTime = Date.now();
|
||||
lineCount++;
|
||||
|
||||
console.log('[CodexExecutor] Line', lineCount, 'received (length:', line.length, '):', line.substring(0, 100));
|
||||
|
||||
if (!line.trim()) {
|
||||
console.log('[CodexExecutor] Skipping empty line');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
console.log('[CodexExecutor] Successfully parsed JSON event. Type:', event.type, 'Keys:', Object.keys(event));
|
||||
|
||||
const convertedMsg = this.convertToClaudeFormat(event);
|
||||
console.log('[CodexExecutor] Converted message:', convertedMsg ? { type: convertedMsg.type } : 'null');
|
||||
|
||||
if (convertedMsg) {
|
||||
// Accumulate text content
|
||||
if (convertedMsg.type === 'assistant' && convertedMsg.message?.content) {
|
||||
for (const block of convertedMsg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
accumulatedText += block.text;
|
||||
console.log('[CodexExecutor] Accumulated text block (total length:', accumulatedText.length, ')');
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolUses.push(block);
|
||||
console.log('[CodexExecutor] Tool use detected:', block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[CodexExecutor] Yielding message of type:', convertedMsg.type);
|
||||
yield convertedMsg;
|
||||
} else {
|
||||
console.log('[CodexExecutor] Converted message is null, skipping');
|
||||
}
|
||||
} catch (parseError) {
|
||||
jsonParseErrors++;
|
||||
// Non-JSON output, yield as text
|
||||
console.log('[CodexExecutor] JSON parse error (', jsonParseErrors, 'total):', parseError.message);
|
||||
console.log('[CodexExecutor] Non-JSON line content:', line.substring(0, 200));
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: line + '\n' }]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[CodexExecutor] Finished reading all lines. Total lines:', lineCount, 'JSON errors:', jsonParseErrors);
|
||||
} catch (readError) {
|
||||
console.error('[CodexExecutor] Error reading from readline:', readError);
|
||||
throw readError;
|
||||
} finally {
|
||||
clearInterval(checkTimeout);
|
||||
console.log('[CodexExecutor] Cleaned up timeout checker');
|
||||
}
|
||||
|
||||
// Handle process completion
|
||||
console.log('[CodexExecutor] Waiting for process to close...');
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
proc.on('close', (code, signal) => {
|
||||
console.log('[CodexExecutor] Process closed with code:', code, 'signal:', signal);
|
||||
resolve(code);
|
||||
});
|
||||
});
|
||||
|
||||
this.currentProcess = null;
|
||||
console.log('[CodexExecutor] Process completed. Exit code:', exitCode, 'Has output:', hasOutput, 'Stderr length:', stderr.length);
|
||||
|
||||
// Wait a bit for any remaining stderr data to be collected
|
||||
console.log('[CodexExecutor] Waiting 200ms for any remaining stderr data...');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
console.log('[CodexExecutor] Final stderr length:', stderr.length, 'Final stdout chunks:', stdoutChunks.length);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const errorMessage = stderr.trim()
|
||||
? `Codex CLI exited with code ${exitCode}.\n\nError output:\n${stderr}`
|
||||
: `Codex CLI exited with code ${exitCode}. No error output captured.`;
|
||||
|
||||
console.error('[CodexExecutor] Process failed with exit code', exitCode);
|
||||
console.error('[CodexExecutor] Error message:', errorMessage);
|
||||
console.error('[CodexExecutor] Stderr chunks:', stderrChunks.length, 'Stdout chunks:', stdoutChunks.length);
|
||||
|
||||
yield {
|
||||
type: 'error',
|
||||
error: errorMessage
|
||||
};
|
||||
} else if (!hasOutput && !stderr) {
|
||||
// Process exited successfully but produced no output - might be API key issue
|
||||
const warningMessage = 'Codex CLI completed but produced no output. This might indicate:\n' +
|
||||
'- Missing or invalid OPENAI_API_KEY\n' +
|
||||
'- Codex CLI configuration issue\n' +
|
||||
'- The process completed without generating any response\n\n' +
|
||||
`Debug info: Exit code ${exitCode}, stdout chunks: ${stdoutChunks.length}, stderr chunks: ${stderrChunks.length}, lines read: ${lineCount}`;
|
||||
|
||||
console.warn('[CodexExecutor] No output detected:', warningMessage);
|
||||
console.warn('[CodexExecutor] Stdout chunks:', stdoutChunks);
|
||||
console.warn('[CodexExecutor] Stderr chunks:', stderrChunks);
|
||||
|
||||
yield {
|
||||
type: 'error',
|
||||
error: warningMessage
|
||||
};
|
||||
} else {
|
||||
console.log('[CodexExecutor] Process completed successfully. Exit code:', exitCode, 'Lines processed:', lineCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command arguments for Codex CLI
|
||||
* Only includes supported arguments based on Codex CLI help:
|
||||
* - --model: Model to use
|
||||
* - --json: JSON output format
|
||||
* - --full-auto: Non-interactive automatic execution
|
||||
*
|
||||
* Note: Codex CLI does NOT support:
|
||||
* - --system-prompt (system prompt is prepended to main prompt)
|
||||
* - --max-turns (not available in CLI)
|
||||
* - --tools (not available in CLI)
|
||||
*
|
||||
* @param {Object} options Options
|
||||
* @returns {string[]} Command arguments
|
||||
*/
|
||||
buildArgs(options) {
|
||||
const { prompt, model } = options;
|
||||
|
||||
console.log('[CodexExecutor] buildArgs called with model:', model, 'prompt length:', prompt?.length || 0);
|
||||
|
||||
const args = ['exec'];
|
||||
|
||||
// Add model (required for most use cases)
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
console.log('[CodexExecutor] Added model argument:', model);
|
||||
}
|
||||
|
||||
// Add JSON output flag for structured parsing
|
||||
args.push('--json');
|
||||
console.log('[CodexExecutor] Added --json flag');
|
||||
|
||||
// Add full-auto mode (non-interactive)
|
||||
// This enables automatic execution with workspace-write sandbox
|
||||
args.push('--full-auto');
|
||||
console.log('[CodexExecutor] Added --full-auto flag');
|
||||
|
||||
// Add the prompt at the end
|
||||
args.push(prompt);
|
||||
console.log('[CodexExecutor] Added prompt (length:', prompt?.length || 0, ')');
|
||||
|
||||
console.log('[CodexExecutor] Final args count:', args.length);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Claude tool names to Codex tool names
|
||||
* @param {string[]} tools Array of tool names
|
||||
* @returns {string[]} Mapped tool names
|
||||
*/
|
||||
mapToolsToCodex(tools) {
|
||||
const toolMap = {
|
||||
'Read': 'read',
|
||||
'Write': 'write',
|
||||
'Edit': 'edit',
|
||||
'Bash': 'bash',
|
||||
'Glob': 'glob',
|
||||
'Grep': 'grep',
|
||||
'WebSearch': 'web-search',
|
||||
'WebFetch': 'web-fetch'
|
||||
};
|
||||
|
||||
return tools
|
||||
.map(tool => toolMap[tool] || tool.toLowerCase())
|
||||
.filter(tool => tool); // Remove undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Codex JSONL event to Claude SDK message format
|
||||
* @param {Object} event Codex event object
|
||||
* @returns {Object|null} Claude-format message or null
|
||||
*/
|
||||
convertToClaudeFormat(event) {
|
||||
console.log('[CodexExecutor] Converting event:', JSON.stringify(event).substring(0, 200));
|
||||
const { type, data, item, thread_id } = event;
|
||||
|
||||
switch (type) {
|
||||
case CODEX_EVENT_TYPES.THREAD_STARTED:
|
||||
case 'thread.started':
|
||||
// Session initialization
|
||||
return {
|
||||
type: 'session_start',
|
||||
sessionId: thread_id || data?.thread_id || event.thread_id
|
||||
};
|
||||
|
||||
case CODEX_EVENT_TYPES.ITEM_COMPLETED:
|
||||
case 'item.completed':
|
||||
// Codex uses 'item' field, not 'data'
|
||||
return this.convertItemCompleted(item || data);
|
||||
|
||||
case CODEX_EVENT_TYPES.ITEM_STARTED:
|
||||
case 'item.started':
|
||||
// Convert item.started events - these indicate tool/command usage
|
||||
const startedItem = item || data;
|
||||
if (startedItem?.type === 'command_execution' && startedItem?.command) {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
name: 'bash',
|
||||
input: { command: startedItem.command }
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
// For other item.started types, return null (we'll show the completed version)
|
||||
return null;
|
||||
|
||||
case CODEX_EVENT_TYPES.THREAD_COMPLETED:
|
||||
case 'thread.completed':
|
||||
return {
|
||||
type: 'complete',
|
||||
sessionId: thread_id || data?.thread_id || event.thread_id
|
||||
};
|
||||
|
||||
case CODEX_EVENT_TYPES.ERROR:
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
error: data?.message || item?.message || event.message || 'Unknown error from Codex CLI'
|
||||
};
|
||||
|
||||
case 'turn.started':
|
||||
// Turn started - just a marker, no need to convert
|
||||
return null;
|
||||
|
||||
default:
|
||||
// Pass through other events
|
||||
console.log('[CodexExecutor] Unhandled event type:', type);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert item.completed event to Claude format
|
||||
* @param {Object} item Event item data
|
||||
* @returns {Object|null} Claude-format message
|
||||
*/
|
||||
convertItemCompleted(item) {
|
||||
if (!item) {
|
||||
console.log('[CodexExecutor] convertItemCompleted: item is null/undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemType = item.type || item.item_type;
|
||||
console.log('[CodexExecutor] convertItemCompleted: itemType =', itemType, 'item keys:', Object.keys(item));
|
||||
|
||||
switch (itemType) {
|
||||
case 'reasoning':
|
||||
// Thinking/reasoning output - Codex uses 'text' field
|
||||
const reasoningText = item.text || item.content || '';
|
||||
console.log('[CodexExecutor] Converting reasoning, text length:', reasoningText.length);
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'thinking',
|
||||
thinking: reasoningText
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
case 'agent_message':
|
||||
case 'message':
|
||||
// Assistant text message
|
||||
const messageText = item.content || item.text || '';
|
||||
console.log('[CodexExecutor] Converting message, text length:', messageText.length);
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: messageText
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
case 'command_execution':
|
||||
// Command execution - show both the command and its output
|
||||
const command = item.command || '';
|
||||
const output = item.aggregated_output || item.output || '';
|
||||
console.log('[CodexExecutor] Converting command_execution, command:', command.substring(0, 50), 'output length:', output.length);
|
||||
|
||||
// Return as text message showing the command and output
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
case 'tool_use':
|
||||
// Tool use
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
name: item.tool || item.command || 'unknown',
|
||||
input: item.input || item.args || {}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
case 'tool_result':
|
||||
// Tool result
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: item.tool_use_id,
|
||||
content: item.output || item.result
|
||||
};
|
||||
|
||||
case 'todo_list':
|
||||
// Todo list - convert to text format
|
||||
const todos = item.items || [];
|
||||
const todoText = todos.map((t, i) => `${i + 1}. ${t.text || t}`).join('\n');
|
||||
console.log('[CodexExecutor] Converting todo_list, items:', todos.length);
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `**Todo List:**\n${todoText}`
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
// Generic text output
|
||||
const text = item.text || item.content || item.aggregated_output;
|
||||
if (text) {
|
||||
console.log('[CodexExecutor] Converting default item type, text length:', text.length);
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(text)
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
console.log('[CodexExecutor] convertItemCompleted: No text content found, returning null');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort current execution
|
||||
*/
|
||||
abort() {
|
||||
if (this.currentProcess) {
|
||||
console.log('[CodexExecutor] Aborting current process');
|
||||
this.currentProcess.kill('SIGTERM');
|
||||
this.currentProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if execution is in progress
|
||||
* @returns {boolean} Whether execution is in progress
|
||||
*/
|
||||
isRunning() {
|
||||
return this.currentProcess !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const codexExecutor = new CodexExecutor();
|
||||
|
||||
module.exports = codexExecutor;
|
||||
@@ -3,11 +3,176 @@ const promptBuilder = require("./prompt-builder");
|
||||
const contextManager = require("./context-manager");
|
||||
const featureLoader = require("./feature-loader");
|
||||
const mcpServerFactory = require("./mcp-server-factory");
|
||||
const { ModelRegistry } = require("./model-registry");
|
||||
const { ModelProviderFactory } = require("./model-provider");
|
||||
|
||||
// Model name mappings for Claude (legacy - kept for backwards compatibility)
|
||||
const MODEL_MAP = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
};
|
||||
|
||||
// Thinking level to budget_tokens mapping
|
||||
// These values control how much "thinking time" the model gets for extended thinking
|
||||
const THINKING_BUDGET_MAP = {
|
||||
none: null, // No extended thinking
|
||||
low: 4096, // Light thinking
|
||||
medium: 16384, // Moderate thinking
|
||||
high: 65536, // Deep thinking
|
||||
ultrathink: 262144, // Ultra-deep thinking (maximum reasoning)
|
||||
};
|
||||
|
||||
/**
|
||||
* Feature Executor - Handles feature implementation using Claude Agent SDK
|
||||
* Now supports multiple model providers (Claude, Codex/OpenAI)
|
||||
*/
|
||||
class FeatureExecutor {
|
||||
/**
|
||||
* Get the model string based on feature's model setting
|
||||
* Supports both Claude and Codex/OpenAI models
|
||||
*/
|
||||
getModelString(feature) {
|
||||
const modelKey = feature.model || "opus"; // Default to opus
|
||||
|
||||
// First check if this is a Codex model - they use the model key directly as the string
|
||||
if (ModelRegistry.isCodexModel(modelKey)) {
|
||||
const model = ModelRegistry.getModel(modelKey);
|
||||
if (model && model.modelString) {
|
||||
console.log(
|
||||
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${model.modelString} (Codex model)`
|
||||
);
|
||||
return model.modelString;
|
||||
}
|
||||
// If model exists in registry but somehow no modelString, use the key itself
|
||||
console.log(
|
||||
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${modelKey} (Codex fallback)`
|
||||
);
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// For Claude models, use the registry lookup
|
||||
let modelString = ModelRegistry.getModelString(modelKey);
|
||||
|
||||
// Fallback to MODEL_MAP if registry doesn't have it (legacy support)
|
||||
if (!modelString) {
|
||||
modelString = MODEL_MAP[modelKey];
|
||||
}
|
||||
|
||||
// Final fallback to opus for Claude models only
|
||||
if (!modelString) {
|
||||
modelString = MODEL_MAP.opus;
|
||||
}
|
||||
|
||||
// Validate model string format - ensure it's not incorrectly constructed
|
||||
// Prevent incorrect formats like "claude-haiku-4-20250514" (mixing haiku with sonnet date)
|
||||
if (modelString.includes("haiku") && modelString.includes("20250514")) {
|
||||
console.error(
|
||||
`[FeatureExecutor] Invalid model string detected: ${modelString}, using correct format`
|
||||
);
|
||||
modelString = MODEL_MAP.haiku || "claude-haiku-4-5";
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${modelString}`
|
||||
);
|
||||
return modelString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the feature uses a Codex/OpenAI model
|
||||
*/
|
||||
isCodexModel(feature) {
|
||||
const modelKey = feature.model || "opus";
|
||||
return ModelRegistry.isCodexModel(modelKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate provider for the feature's model
|
||||
*/
|
||||
getProvider(feature) {
|
||||
const modelKey = feature.model || "opus";
|
||||
return ModelProviderFactory.getProviderForModel(modelKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thinking configuration based on feature's thinkingLevel
|
||||
*/
|
||||
getThinkingConfig(feature) {
|
||||
const modelId = feature.model || "opus";
|
||||
// Skip thinking config for models that don't support it (e.g., Codex CLI)
|
||||
if (!ModelRegistry.modelSupportsThinking(modelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const level = feature.thinkingLevel || "none";
|
||||
const budgetTokens = THINKING_BUDGET_MAP[level];
|
||||
|
||||
if (budgetTokens === null) {
|
||||
return null; // No extended thinking
|
||||
}
|
||||
|
||||
return {
|
||||
type: "enabled",
|
||||
budget_tokens: budgetTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for ultrathink execution - validate and warn
|
||||
*/
|
||||
prepareForUltrathink(feature, thinkingConfig) {
|
||||
if (feature.thinkingLevel !== "ultrathink") {
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
const recommendations = [];
|
||||
|
||||
// Check CLI installation
|
||||
const claudeCliDetector = require("./claude-cli-detector");
|
||||
const cliInfo = claudeCliDetector.getInstallationInfo();
|
||||
|
||||
if (cliInfo.status === "not_installed") {
|
||||
warnings.push(
|
||||
"Claude Code CLI not detected - ultrathink may have timeout issues"
|
||||
);
|
||||
recommendations.push(
|
||||
"Install Claude Code CLI for optimal ultrathink performance"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate budget tokens
|
||||
if (thinkingConfig && thinkingConfig.budget_tokens > 32000) {
|
||||
warnings.push(
|
||||
`Ultrathink budget (${thinkingConfig.budget_tokens} tokens) exceeds recommended 32K - may cause long-running requests`
|
||||
);
|
||||
recommendations.push(
|
||||
"Consider using batch processing for budgets above 32K"
|
||||
);
|
||||
}
|
||||
|
||||
// Cost estimate (rough)
|
||||
const estimatedCost = ((thinkingConfig?.budget_tokens || 0) / 1000) * 0.015; // Rough estimate
|
||||
if (estimatedCost > 1.0) {
|
||||
warnings.push(
|
||||
`Estimated cost: ~$${estimatedCost.toFixed(2)} per execution`
|
||||
);
|
||||
}
|
||||
|
||||
// Time estimate
|
||||
warnings.push("Ultrathink tasks typically take 45-180 seconds");
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
warnings,
|
||||
recommendations,
|
||||
estimatedCost,
|
||||
estimatedTime: "45-180 seconds",
|
||||
cliInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
@@ -22,6 +187,11 @@ class FeatureExecutor {
|
||||
async implementFeature(feature, projectPath, sendToRenderer, execution) {
|
||||
console.log(`[FeatureExecutor] Implementing: ${feature.description}`);
|
||||
|
||||
// Declare variables outside try block so they're available in catch
|
||||
let modelString;
|
||||
let providerName;
|
||||
let isCodex;
|
||||
|
||||
try {
|
||||
// ========================================
|
||||
// PHASE 1: PLANNING
|
||||
@@ -52,13 +222,59 @@ class FeatureExecutor {
|
||||
projectPath
|
||||
);
|
||||
|
||||
// Determine if we're in TDD mode (skipTests=false means TDD mode)
|
||||
const isTDD = !feature.skipTests;
|
||||
// Ensure feature has a model set (for backward compatibility with old features)
|
||||
if (!feature.model) {
|
||||
console.warn(
|
||||
`[FeatureExecutor] Feature ${feature.id} missing model property, defaulting to 'opus'`
|
||||
);
|
||||
feature.model = "opus";
|
||||
}
|
||||
|
||||
// Get model and thinking configuration from feature settings
|
||||
const modelString = this.getModelString(feature);
|
||||
const thinkingConfig = this.getThinkingConfig(feature);
|
||||
|
||||
// Prepare for ultrathink if needed
|
||||
if (feature.thinkingLevel === "ultrathink") {
|
||||
const preparation = this.prepareForUltrathink(feature, thinkingConfig);
|
||||
|
||||
console.log(`[FeatureExecutor] Ultrathink preparation:`, preparation);
|
||||
|
||||
// Log warnings
|
||||
if (preparation.warnings && preparation.warnings.length > 0) {
|
||||
preparation.warnings.forEach((warning) => {
|
||||
console.warn(`[FeatureExecutor] ⚠️ ${warning}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Send preparation info to renderer
|
||||
sendToRenderer({
|
||||
type: "auto_mode_ultrathink_preparation",
|
||||
featureId: feature.id,
|
||||
warnings: preparation.warnings || [],
|
||||
recommendations: preparation.recommendations || [],
|
||||
estimatedCost: preparation.estimatedCost,
|
||||
estimatedTime: preparation.estimatedTime,
|
||||
});
|
||||
}
|
||||
|
||||
providerName = this.isCodexModel(feature) ? "Codex/OpenAI" : "Claude";
|
||||
console.log(
|
||||
`[FeatureExecutor] Using provider: ${providerName}, model: ${modelString}, thinking: ${
|
||||
feature.thinkingLevel || "none"
|
||||
}`
|
||||
);
|
||||
|
||||
// Note: Claude Agent SDK handles authentication automatically - it can use:
|
||||
// 1. CLAUDE_CODE_OAUTH_TOKEN env var (for SDK mode)
|
||||
// 2. Claude CLI's own authentication (if CLI is installed)
|
||||
// 3. ANTHROPIC_API_KEY (fallback)
|
||||
// We don't need to validate here - let the SDK/CLI handle auth errors
|
||||
|
||||
// Configure options for the SDK query
|
||||
const options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: await promptBuilder.getCodingPrompt(projectPath, isTDD),
|
||||
model: modelString,
|
||||
systemPrompt: promptBuilder.getCodingPrompt(),
|
||||
maxTurns: 1000,
|
||||
cwd: projectPath,
|
||||
mcpServers: {
|
||||
@@ -83,6 +299,11 @@ class FeatureExecutor {
|
||||
abortController: abortController,
|
||||
};
|
||||
|
||||
// Add thinking configuration if enabled
|
||||
if (thinkingConfig) {
|
||||
options.thinking = thinkingConfig;
|
||||
}
|
||||
|
||||
// Build the prompt for this specific feature
|
||||
let prompt = await promptBuilder.buildFeaturePrompt(feature, projectPath);
|
||||
|
||||
@@ -135,8 +356,18 @@ class FeatureExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Use content blocks instead of plain text
|
||||
prompt = contentBlocks;
|
||||
// Wrap content blocks in async generator for SDK (required format for multimodal prompts)
|
||||
prompt = (async function* () {
|
||||
yield {
|
||||
type: "user",
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user",
|
||||
content: contentBlocks,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
// Planning: Analyze the codebase and create implementation plan
|
||||
@@ -168,8 +399,85 @@ class FeatureExecutor {
|
||||
});
|
||||
console.log(`[FeatureExecutor] Phase: ACTION for ${feature.description}`);
|
||||
|
||||
// Send query
|
||||
const currentQuery = query({ prompt, options });
|
||||
// Send query - use appropriate provider based on model
|
||||
let currentQuery;
|
||||
isCodex = this.isCodexModel(feature);
|
||||
|
||||
// Ensure provider auth is available (especially for Claude SDK)
|
||||
const provider = this.getProvider(feature);
|
||||
if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) {
|
||||
// Check if CLI is installed to provide better error message
|
||||
let authMsg =
|
||||
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
|
||||
try {
|
||||
const claudeCliDetector = require("./claude-cli-detector");
|
||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||
if (detection.installed && detection.method === "cli") {
|
||||
authMsg =
|
||||
"Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
|
||||
} else {
|
||||
authMsg =
|
||||
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.";
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to default message
|
||||
}
|
||||
console.error(`[FeatureExecutor] ${authMsg}`);
|
||||
throw new Error(authMsg);
|
||||
}
|
||||
|
||||
// Validate that model string matches the provider
|
||||
if (isCodex) {
|
||||
// Ensure model string is actually a Codex model, not a Claude model
|
||||
if (modelString.startsWith("claude-")) {
|
||||
console.error(
|
||||
`[FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: ${modelString}`
|
||||
);
|
||||
console.error(
|
||||
`[FeatureExecutor] Feature model: ${
|
||||
feature.model || "not set"
|
||||
}, modelString: ${modelString}`
|
||||
);
|
||||
throw new Error(
|
||||
`Invalid model configuration: Codex provider cannot use Claude model '${modelString}'. Please check feature model setting.`
|
||||
);
|
||||
}
|
||||
|
||||
// Use Codex provider for OpenAI models
|
||||
console.log(
|
||||
`[FeatureExecutor] Using Codex provider for model: ${modelString}`
|
||||
);
|
||||
// Pass MCP server config to Codex provider so it can configure Codex CLI TOML
|
||||
currentQuery = provider.executeQuery({
|
||||
prompt,
|
||||
model: modelString,
|
||||
cwd: projectPath,
|
||||
systemPrompt: promptBuilder.getCodingPrompt(),
|
||||
maxTurns: 20, // Codex CLI typically uses fewer turns
|
||||
allowedTools: options.allowedTools,
|
||||
mcpServers: {
|
||||
"automaker-tools": featureToolsServer,
|
||||
},
|
||||
abortController: abortController,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Ensure model string is actually a Claude model, not a Codex model
|
||||
if (
|
||||
!modelString.startsWith("claude-") &&
|
||||
!modelString.match(/^(gpt-|o\d)/)
|
||||
) {
|
||||
console.warn(
|
||||
`[FeatureExecutor] WARNING: Claude provider selected but unexpected model string: ${modelString}`
|
||||
);
|
||||
}
|
||||
|
||||
// Use Claude SDK (original implementation)
|
||||
currentQuery = query({ prompt, options });
|
||||
}
|
||||
|
||||
execution.query = currentQuery;
|
||||
|
||||
// Stream responses
|
||||
@@ -179,6 +487,22 @@ class FeatureExecutor {
|
||||
// Check if this specific feature was aborted
|
||||
if (!execution.isActive()) break;
|
||||
|
||||
// Handle error messages
|
||||
if (msg.type === "error") {
|
||||
const errorMsg = `\n❌ Error: ${msg.error}\n`;
|
||||
await contextManager.writeToContextFile(
|
||||
projectPath,
|
||||
feature.id,
|
||||
errorMsg
|
||||
);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
featureId: feature.id,
|
||||
error: msg.error,
|
||||
});
|
||||
throw new Error(msg.error);
|
||||
}
|
||||
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
@@ -197,6 +521,22 @@ class FeatureExecutor {
|
||||
featureId: feature.id,
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "thinking") {
|
||||
// Handle thinking output from Codex O-series models
|
||||
const thinkingMsg = `\n💭 Thinking: ${block.thinking?.substring(
|
||||
0,
|
||||
200
|
||||
)}...\n`;
|
||||
await contextManager.writeToContextFile(
|
||||
projectPath,
|
||||
feature.id,
|
||||
thinkingMsg
|
||||
);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: thinkingMsg,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
// First tool use indicates we're actively implementing
|
||||
if (!hasStartedToolUse) {
|
||||
@@ -314,6 +654,54 @@ class FeatureExecutor {
|
||||
|
||||
console.error("[FeatureExecutor] Error implementing feature:", error);
|
||||
|
||||
// Safely get model info for error logging (may not be set if error occurred early)
|
||||
const modelInfo = modelString
|
||||
? {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
model: modelString,
|
||||
provider: providerName || "unknown",
|
||||
isCodex: isCodex !== undefined ? isCodex : "unknown",
|
||||
}
|
||||
: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
model: "not initialized",
|
||||
provider: "unknown",
|
||||
isCodex: "unknown",
|
||||
};
|
||||
|
||||
console.error("[FeatureExecutor] Error details:", modelInfo);
|
||||
|
||||
// Check if this is a Claude CLI process error
|
||||
if (error.message && error.message.includes("process exited with code")) {
|
||||
const modelDisplay = modelString
|
||||
? `Model: ${modelString}`
|
||||
: "Model: not initialized";
|
||||
const errorMsg =
|
||||
`Claude Code CLI failed with exit code 1. This might be due to:\n` +
|
||||
`- Invalid or unsupported model (${modelDisplay})\n` +
|
||||
`- Missing or invalid CLAUDE_CODE_OAUTH_TOKEN\n` +
|
||||
`- Claude CLI configuration issue\n` +
|
||||
`- Model not available in your Claude account\n\n` +
|
||||
`Original error: ${error.message}`;
|
||||
|
||||
await contextManager.writeToContextFile(
|
||||
projectPath,
|
||||
feature.id,
|
||||
`\n❌ ${errorMsg}\n`
|
||||
);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
featureId: feature.id,
|
||||
error: errorMsg,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (execution) {
|
||||
execution.abortController = null;
|
||||
@@ -365,9 +753,53 @@ class FeatureExecutor {
|
||||
projectPath
|
||||
);
|
||||
|
||||
// Ensure feature has a model set (for backward compatibility with old features)
|
||||
if (!feature.model) {
|
||||
console.warn(
|
||||
`[FeatureExecutor] Feature ${feature.id} missing model property, defaulting to 'opus'`
|
||||
);
|
||||
feature.model = "opus";
|
||||
}
|
||||
|
||||
// Get model and thinking configuration from feature settings
|
||||
const modelString = this.getModelString(feature);
|
||||
const thinkingConfig = this.getThinkingConfig(feature);
|
||||
|
||||
// Prepare for ultrathink if needed
|
||||
if (feature.thinkingLevel === "ultrathink") {
|
||||
const preparation = this.prepareForUltrathink(feature, thinkingConfig);
|
||||
|
||||
console.log(`[FeatureExecutor] Ultrathink preparation:`, preparation);
|
||||
|
||||
// Log warnings
|
||||
if (preparation.warnings && preparation.warnings.length > 0) {
|
||||
preparation.warnings.forEach((warning) => {
|
||||
console.warn(`[FeatureExecutor] ⚠️ ${warning}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Send preparation info to renderer
|
||||
sendToRenderer({
|
||||
type: "auto_mode_ultrathink_preparation",
|
||||
featureId: feature.id,
|
||||
warnings: preparation.warnings || [],
|
||||
recommendations: preparation.recommendations || [],
|
||||
estimatedCost: preparation.estimatedCost,
|
||||
estimatedTime: preparation.estimatedTime,
|
||||
});
|
||||
}
|
||||
|
||||
const isCodex = this.isCodexModel(feature);
|
||||
const providerName = isCodex ? "Codex/OpenAI" : "Claude";
|
||||
console.log(
|
||||
`[FeatureExecutor] Resuming with provider: ${providerName}, model: ${modelString}, thinking: ${
|
||||
feature.thinkingLevel || "none"
|
||||
}`
|
||||
);
|
||||
|
||||
const options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: await promptBuilder.getVerificationPrompt(projectPath, isTDD),
|
||||
model: modelString,
|
||||
systemPrompt: promptBuilder.getVerificationPrompt(),
|
||||
maxTurns: 1000,
|
||||
cwd: projectPath,
|
||||
mcpServers: {
|
||||
@@ -392,6 +824,11 @@ class FeatureExecutor {
|
||||
abortController: abortController,
|
||||
};
|
||||
|
||||
// Add thinking configuration if enabled
|
||||
if (thinkingConfig) {
|
||||
options.thinking = thinkingConfig;
|
||||
}
|
||||
|
||||
// Build prompt with previous context
|
||||
let prompt = await promptBuilder.buildResumePrompt(
|
||||
feature,
|
||||
@@ -459,11 +896,53 @@ class FeatureExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Use content blocks instead of plain text
|
||||
prompt = contentBlocks;
|
||||
// Wrap content blocks in async generator for SDK (required format for multimodal prompts)
|
||||
prompt = (async function* () {
|
||||
yield {
|
||||
type: "user",
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user",
|
||||
content: contentBlocks,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
const currentQuery = query({ prompt, options });
|
||||
// Use appropriate provider based on model type
|
||||
let currentQuery;
|
||||
if (isCodex) {
|
||||
// Validate that model string is actually a Codex model
|
||||
if (modelString.startsWith("claude-")) {
|
||||
console.error(
|
||||
`[FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: ${modelString}`
|
||||
);
|
||||
throw new Error(
|
||||
`Invalid model configuration: Codex provider cannot use Claude model '${modelString}'. Please check feature model setting.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[FeatureExecutor] Using Codex provider for resume with model: ${modelString}`
|
||||
);
|
||||
const provider = this.getProvider(feature);
|
||||
currentQuery = provider.executeQuery({
|
||||
prompt,
|
||||
model: modelString,
|
||||
cwd: projectPath,
|
||||
systemPrompt: promptBuilder.getVerificationPrompt(),
|
||||
maxTurns: 20,
|
||||
allowedTools: options.allowedTools,
|
||||
abortController: abortController,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Use Claude SDK
|
||||
currentQuery = query({ prompt, options });
|
||||
}
|
||||
execution.query = currentQuery;
|
||||
|
||||
let responseText = "";
|
||||
|
||||
@@ -132,9 +132,22 @@ class FeatureLoader {
|
||||
if (f.summary !== undefined) {
|
||||
featureData.summary = f.summary;
|
||||
}
|
||||
if (f.model !== undefined) {
|
||||
featureData.model = f.model;
|
||||
}
|
||||
if (f.thinkingLevel !== undefined) {
|
||||
featureData.thinkingLevel = f.thinkingLevel;
|
||||
}
|
||||
if (f.error !== undefined) {
|
||||
featureData.error = f.error;
|
||||
}
|
||||
// Preserve worktree info
|
||||
if (f.worktreePath !== undefined) {
|
||||
featureData.worktreePath = f.worktreePath;
|
||||
}
|
||||
if (f.branchName !== undefined) {
|
||||
featureData.branchName = f.branchName;
|
||||
}
|
||||
return featureData;
|
||||
});
|
||||
|
||||
@@ -157,6 +170,69 @@ class FeatureLoader {
|
||||
// Skip verified and waiting_approval (which needs user input)
|
||||
return features.find((f) => f.status !== "verified" && f.status !== "waiting_approval");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update worktree info for a feature
|
||||
* @param {string} featureId - The ID of the feature to update
|
||||
* @param {string} projectPath - Path to the project
|
||||
* @param {string|null} worktreePath - Path to the worktree (null to clear)
|
||||
* @param {string|null} branchName - Name of the feature branch (null to clear)
|
||||
*/
|
||||
async updateFeatureWorktree(featureId, projectPath, worktreePath, branchName) {
|
||||
const featuresPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"feature_list.json"
|
||||
);
|
||||
|
||||
const features = await this.loadFeatures(projectPath);
|
||||
|
||||
if (!Array.isArray(features) || features.length === 0) {
|
||||
console.error("[FeatureLoader] Cannot update worktree: feature list is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
console.error(`[FeatureLoader] Feature ${featureId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update or clear worktree info
|
||||
if (worktreePath) {
|
||||
feature.worktreePath = worktreePath;
|
||||
feature.branchName = branchName;
|
||||
} else {
|
||||
delete feature.worktreePath;
|
||||
delete feature.branchName;
|
||||
}
|
||||
|
||||
// Save back to file (reuse the same mapping logic)
|
||||
const toSave = features.map((f) => {
|
||||
const featureData = {
|
||||
id: f.id,
|
||||
category: f.category,
|
||||
description: f.description,
|
||||
steps: f.steps,
|
||||
status: f.status,
|
||||
};
|
||||
if (f.skipTests !== undefined) featureData.skipTests = f.skipTests;
|
||||
if (f.images !== undefined) featureData.images = f.images;
|
||||
if (f.imagePaths !== undefined) featureData.imagePaths = f.imagePaths;
|
||||
if (f.startedAt !== undefined) featureData.startedAt = f.startedAt;
|
||||
if (f.summary !== undefined) featureData.summary = f.summary;
|
||||
if (f.model !== undefined) featureData.model = f.model;
|
||||
if (f.thinkingLevel !== undefined) featureData.thinkingLevel = f.thinkingLevel;
|
||||
if (f.error !== undefined) featureData.error = f.error;
|
||||
if (f.worktreePath !== undefined) featureData.worktreePath = f.worktreePath;
|
||||
if (f.branchName !== undefined) featureData.branchName = f.branchName;
|
||||
return featureData;
|
||||
});
|
||||
|
||||
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
console.log(`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeatureLoader();
|
||||
|
||||
347
app/electron/services/mcp-server-stdio.js
Normal file
347
app/electron/services/mcp-server-stdio.js
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone STDIO MCP Server for Automaker Tools
|
||||
*
|
||||
* This script runs as a standalone process and communicates via JSON-RPC 2.0
|
||||
* over stdin/stdout. It implements the MCP protocol to expose the UpdateFeatureStatus
|
||||
* tool to Codex CLI.
|
||||
*
|
||||
* Environment variables:
|
||||
* - AUTOMAKER_PROJECT_PATH: Path to the project directory
|
||||
* - AUTOMAKER_IPC_CHANNEL: IPC channel name for callback communication (optional, uses default)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const path = require('path');
|
||||
|
||||
// Redirect all console.log output to stderr to avoid polluting MCP stdout
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = (...args) => {
|
||||
console.error(...args);
|
||||
};
|
||||
|
||||
// Set up readline interface for line-by-line JSON-RPC input
|
||||
// IMPORTANT: Use a separate output stream for readline to avoid interfering with JSON-RPC stdout
|
||||
// We'll write JSON-RPC responses directly to stdout, not through readline
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: null, // Don't use stdout for readline output
|
||||
terminal: false
|
||||
});
|
||||
|
||||
let initialized = false;
|
||||
let projectPath = null;
|
||||
let ipcChannel = null;
|
||||
|
||||
// Get configuration from environment
|
||||
projectPath = process.env.AUTOMAKER_PROJECT_PATH || process.cwd();
|
||||
ipcChannel = process.env.AUTOMAKER_IPC_CHANNEL || 'mcp:update-feature-status';
|
||||
|
||||
// Load dependencies (these will be available in the Electron app context)
|
||||
let featureLoader;
|
||||
let electron;
|
||||
|
||||
// Try to load Electron IPC if available (when running from Electron app)
|
||||
try {
|
||||
// In Electron, we can use IPC directly
|
||||
if (typeof require !== 'undefined') {
|
||||
// Check if we're in Electron context
|
||||
const electronModule = require('electron');
|
||||
if (electronModule && electronModule.ipcMain) {
|
||||
electron = electronModule;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not in Electron context, will use alternative method
|
||||
}
|
||||
|
||||
// Load feature loader
|
||||
// Try multiple paths since this script might be run from different contexts
|
||||
try {
|
||||
// First try relative path (when run from electron/services/)
|
||||
featureLoader = require('./feature-loader');
|
||||
} catch (e) {
|
||||
try {
|
||||
// Try absolute path resolution
|
||||
const featureLoaderPath = path.resolve(__dirname, 'feature-loader.js');
|
||||
delete require.cache[require.resolve(featureLoaderPath)];
|
||||
featureLoader = require(featureLoaderPath);
|
||||
} catch (e2) {
|
||||
// If still fails, try from parent directory
|
||||
try {
|
||||
featureLoader = require(path.join(__dirname, '..', 'services', 'feature-loader'));
|
||||
} catch (e3) {
|
||||
console.error('[McpServerStdio] Error loading feature-loader:', e3.message);
|
||||
console.error('[McpServerStdio] Tried paths:', [
|
||||
'./feature-loader',
|
||||
path.resolve(__dirname, 'feature-loader.js'),
|
||||
path.join(__dirname, '..', 'services', 'feature-loader')
|
||||
]);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON-RPC response
|
||||
* CRITICAL: Must write directly to stdout, not via console.log
|
||||
* MCP protocol requires ONLY JSON-RPC messages on stdout
|
||||
*/
|
||||
function sendResponse(id, result, error = null) {
|
||||
const response = {
|
||||
jsonrpc: '2.0',
|
||||
id
|
||||
};
|
||||
|
||||
if (error) {
|
||||
response.error = error;
|
||||
} else {
|
||||
response.result = result;
|
||||
}
|
||||
|
||||
// Write directly to stdout with newline (MCP uses line-delimited JSON)
|
||||
process.stdout.write(JSON.stringify(response) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON-RPC notification
|
||||
* CRITICAL: Must write directly to stdout, not via console.log
|
||||
*/
|
||||
function sendNotification(method, params) {
|
||||
const notification = {
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params
|
||||
};
|
||||
|
||||
// Write directly to stdout with newline (MCP uses line-delimited JSON)
|
||||
process.stdout.write(JSON.stringify(notification) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP initialize request
|
||||
*/
|
||||
async function handleInitialize(params, id) {
|
||||
initialized = true;
|
||||
|
||||
sendResponse(id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'automaker-tools',
|
||||
version: '1.0.0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/list request
|
||||
*/
|
||||
async function handleToolsList(params, id) {
|
||||
sendResponse(id, {
|
||||
tools: [
|
||||
{
|
||||
name: 'UpdateFeatureStatus',
|
||||
description: 'Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
featureId: {
|
||||
type: 'string',
|
||||
description: 'The ID of the feature to update'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['backlog', 'in_progress', 'verified'],
|
||||
description: 'The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically.'
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
description: 'A brief summary of what was implemented/changed. This will be displayed on the Kanban card. Example: "Added dark mode toggle. Modified: settings.tsx, theme-provider.tsx"'
|
||||
}
|
||||
},
|
||||
required: ['featureId', 'status']
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/call request
|
||||
*/
|
||||
async function handleToolsCall(params, id) {
|
||||
const { name, arguments: args } = params;
|
||||
|
||||
if (name !== 'UpdateFeatureStatus') {
|
||||
sendResponse(id, null, {
|
||||
code: -32601,
|
||||
message: `Unknown tool: ${name}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { featureId, status, summary } = args;
|
||||
|
||||
if (!featureId || !status) {
|
||||
sendResponse(id, null, {
|
||||
code: -32602,
|
||||
message: 'Missing required parameters: featureId and status are required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the feature to check skipTests flag
|
||||
const features = await featureLoader.loadFeatures(projectPath);
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
sendResponse(id, null, {
|
||||
code: -32602,
|
||||
message: `Feature ${featureId} not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If agent tries to mark as verified but feature has skipTests=true, convert to waiting_approval
|
||||
let finalStatus = status;
|
||||
if (status === 'verified' && feature.skipTests === true) {
|
||||
finalStatus = 'waiting_approval';
|
||||
}
|
||||
|
||||
// Call the update callback via IPC or direct call
|
||||
// Since we're in a separate process, we need to use IPC to communicate back
|
||||
// For now, we'll call the feature loader directly since it has the update method
|
||||
await featureLoader.updateFeatureStatus(featureId, finalStatus, projectPath, summary);
|
||||
|
||||
const statusMessage = finalStatus !== status
|
||||
? `Successfully updated feature ${featureId} to status "${finalStatus}" (converted from "${status}" because skipTests=true)${summary ? ` with summary: "${summary}"` : ''}`
|
||||
: `Successfully updated feature ${featureId} to status "${finalStatus}"${summary ? ` with summary: "${summary}"` : ''}`;
|
||||
|
||||
sendResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: statusMessage
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[McpServerStdio] UpdateFeatureStatus error:', error);
|
||||
sendResponse(id, null, {
|
||||
code: -32603,
|
||||
message: `Failed to update feature status: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle JSON-RPC request
|
||||
*/
|
||||
async function handleRequest(line) {
|
||||
let request;
|
||||
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch (e) {
|
||||
sendResponse(null, null, {
|
||||
code: -32700,
|
||||
message: 'Parse error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON-RPC 2.0 structure
|
||||
if (request.jsonrpc !== '2.0') {
|
||||
sendResponse(request.id || null, null, {
|
||||
code: -32600,
|
||||
message: 'Invalid Request'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { method, params, id } = request;
|
||||
|
||||
// Handle notifications (no id)
|
||||
if (id === undefined) {
|
||||
// Handle notifications if needed
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle requests
|
||||
try {
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
await handleInitialize(params, id);
|
||||
break;
|
||||
|
||||
case 'tools/list':
|
||||
if (!initialized) {
|
||||
sendResponse(id, null, {
|
||||
code: -32002,
|
||||
message: 'Server not initialized'
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleToolsList(params, id);
|
||||
break;
|
||||
|
||||
case 'tools/call':
|
||||
if (!initialized) {
|
||||
sendResponse(id, null, {
|
||||
code: -32002,
|
||||
message: 'Server not initialized'
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleToolsCall(params, id);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendResponse(id, null, {
|
||||
code: -32601,
|
||||
message: `Method not found: ${method}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[McpServerStdio] Error handling request:', error);
|
||||
sendResponse(id, null, {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process stdin line by line
|
||||
rl.on('line', async (line) => {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleRequest(line);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
rl.on('error', (error) => {
|
||||
console.error('[McpServerStdio] Readline error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGTERM', () => {
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Log startup
|
||||
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
|
||||
console.error(`[McpServerStdio] Project path: ${projectPath}`);
|
||||
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);
|
||||
477
app/electron/services/model-provider.js
Normal file
477
app/electron/services/model-provider.js
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Model Provider Abstraction Layer
|
||||
*
|
||||
* This module provides an abstract interface for model providers (Claude, Codex, etc.)
|
||||
* allowing the application to use different AI models through a unified API.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for model providers
|
||||
* Concrete implementations should extend this class
|
||||
*/
|
||||
class ModelProvider {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
this.name = 'base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider name
|
||||
* @returns {string} Provider name
|
||||
*/
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with the model provider
|
||||
* @param {Object} options Query options
|
||||
* @param {string} options.prompt The prompt to send
|
||||
* @param {string} options.model The model to use
|
||||
* @param {string} options.systemPrompt System prompt
|
||||
* @param {string} options.cwd Working directory
|
||||
* @param {number} options.maxTurns Maximum turns
|
||||
* @param {string[]} options.allowedTools Allowed tools
|
||||
* @param {Object} options.mcpServers MCP servers configuration
|
||||
* @param {AbortController} options.abortController Abort controller
|
||||
* @param {Object} options.thinking Thinking configuration
|
||||
* @returns {AsyncGenerator} Async generator yielding messages
|
||||
*/
|
||||
async *executeQuery(options) {
|
||||
throw new Error('executeQuery must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if this provider's CLI/SDK is installed
|
||||
* @returns {Promise<Object>} Installation status
|
||||
*/
|
||||
async detectInstallation() {
|
||||
throw new Error('detectInstallation must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available models for this provider
|
||||
* @returns {Array<Object>} Array of model definitions
|
||||
*/
|
||||
getAvailableModels() {
|
||||
throw new Error('getAvailableModels must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate provider configuration
|
||||
* @returns {Object} Validation result { valid: boolean, errors: string[] }
|
||||
*/
|
||||
validateConfig() {
|
||||
throw new Error('validateConfig must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full model string for a model key
|
||||
* @param {string} modelKey Short model key (e.g., 'opus', 'gpt-5.1-codex')
|
||||
* @returns {string} Full model string
|
||||
*/
|
||||
getModelString(modelKey) {
|
||||
throw new Error('getModelString must be implemented by subclass');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider supports a specific feature
|
||||
* @param {string} feature Feature name (e.g., 'thinking', 'tools', 'streaming')
|
||||
* @returns {boolean} Whether the feature is supported
|
||||
*/
|
||||
supportsFeature(feature) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Provider - Uses Anthropic Claude Agent SDK
|
||||
*/
|
||||
class ClaudeProvider extends ModelProvider {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.name = 'claude';
|
||||
this.sdk = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
|
||||
* Returns the token string or null if not found.
|
||||
*/
|
||||
loadTokenFromCliConfig() {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const configPath = path.join(require('os').homedir(), '.claude', 'config.json');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
// CLI config stores token as oauth_token (newer) or token (older)
|
||||
return parsed.oauth_token || parsed.token || null;
|
||||
} catch (err) {
|
||||
console.warn('[ClaudeProvider] Failed to read CLI config token:', err?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ensureAuthEnv() {
|
||||
// If API key or token already present, keep as-is.
|
||||
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
console.log('[ClaudeProvider] Auth already present in environment');
|
||||
return true;
|
||||
}
|
||||
// Try to hydrate from CLI login config
|
||||
const token = this.loadTokenFromCliConfig();
|
||||
if (token) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
||||
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/config.json');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if CLI is installed but not logged in
|
||||
try {
|
||||
const claudeCliDetector = require('./claude-cli-detector');
|
||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||
if (detection.installed && detection.method === 'cli') {
|
||||
console.error('[ClaudeProvider] Claude CLI is installed but not logged in. Run `claude login` to authenticate.');
|
||||
} else {
|
||||
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load the Claude SDK
|
||||
*/
|
||||
loadSdk() {
|
||||
if (!this.sdk) {
|
||||
this.sdk = require('@anthropic-ai/claude-agent-sdk');
|
||||
}
|
||||
return this.sdk;
|
||||
}
|
||||
|
||||
async *executeQuery(options) {
|
||||
// Ensure we have auth; fall back to CLI login token if available.
|
||||
if (!this.ensureAuthEnv()) {
|
||||
// Check if CLI is installed to provide better error message
|
||||
let msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
|
||||
try {
|
||||
const claudeCliDetector = require('./claude-cli-detector');
|
||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||
if (detection.installed && detection.method === 'cli') {
|
||||
msg = 'Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
|
||||
} else {
|
||||
msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.';
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to default message
|
||||
}
|
||||
console.error(`[ClaudeProvider] ${msg}`);
|
||||
yield { type: 'error', error: msg };
|
||||
return;
|
||||
}
|
||||
|
||||
const { query } = this.loadSdk();
|
||||
|
||||
const sdkOptions = {
|
||||
model: options.model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
maxTurns: options.maxTurns || 1000,
|
||||
cwd: options.cwd,
|
||||
mcpServers: options.mcpServers,
|
||||
allowedTools: options.allowedTools,
|
||||
permissionMode: options.permissionMode || 'acceptEdits',
|
||||
sandbox: options.sandbox,
|
||||
abortController: options.abortController,
|
||||
};
|
||||
|
||||
// Add thinking configuration if enabled
|
||||
if (options.thinking) {
|
||||
sdkOptions.thinking = options.thinking;
|
||||
}
|
||||
|
||||
const currentQuery = query({ prompt: options.prompt, options: sdkOptions });
|
||||
|
||||
for await (const msg of currentQuery) {
|
||||
yield msg;
|
||||
}
|
||||
}
|
||||
|
||||
async detectInstallation() {
|
||||
const claudeCliDetector = require('./claude-cli-detector');
|
||||
return claudeCliDetector.getInstallationInfo();
|
||||
}
|
||||
|
||||
getAvailableModels() {
|
||||
return [
|
||||
{
|
||||
id: 'haiku',
|
||||
name: 'Claude Haiku',
|
||||
modelString: 'claude-haiku-4-5',
|
||||
provider: 'claude',
|
||||
description: 'Fast and efficient for simple tasks',
|
||||
tier: 'basic'
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
name: 'Claude Sonnet',
|
||||
modelString: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
description: 'Balanced performance and capabilities',
|
||||
tier: 'standard'
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
provider: 'claude',
|
||||
description: 'Most capable model for complex tasks',
|
||||
tier: 'premium'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
|
||||
// Ensure auth is available (try to auto-load from CLI config)
|
||||
this.ensureAuthEnv();
|
||||
|
||||
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) {
|
||||
errors.push('No Claude authentication found. Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, or run `claude login` to populate ~/.claude/config.json.');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
getModelString(modelKey) {
|
||||
const modelMap = {
|
||||
haiku: 'claude-haiku-4-5',
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
opus: 'claude-opus-4-5-20251101'
|
||||
};
|
||||
return modelMap[modelKey] || modelMap.opus;
|
||||
}
|
||||
|
||||
supportsFeature(feature) {
|
||||
const supportedFeatures = ['thinking', 'tools', 'streaming', 'mcp'];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Provider - Uses OpenAI Codex CLI
|
||||
*/
|
||||
class CodexProvider extends ModelProvider {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.name = 'codex';
|
||||
}
|
||||
|
||||
async *executeQuery(options) {
|
||||
const codexExecutor = require('./codex-executor');
|
||||
|
||||
// Validate that we're not receiving a Claude model string
|
||||
if (options.model && options.model.startsWith('claude-')) {
|
||||
const errorMsg = `Codex provider cannot use Claude model '${options.model}'. Codex only supports OpenAI models (gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1).`;
|
||||
console.error(`[CodexProvider] ${errorMsg}`);
|
||||
yield {
|
||||
type: 'error',
|
||||
error: errorMsg
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const executeOptions = {
|
||||
prompt: options.prompt,
|
||||
model: options.model,
|
||||
cwd: options.cwd,
|
||||
systemPrompt: options.systemPrompt,
|
||||
maxTurns: options.maxTurns || 20,
|
||||
allowedTools: options.allowedTools,
|
||||
mcpServers: options.mcpServers, // Pass MCP servers config to executor
|
||||
env: {
|
||||
...process.env,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY
|
||||
}
|
||||
};
|
||||
|
||||
// Execute and yield results
|
||||
const generator = codexExecutor.execute(executeOptions);
|
||||
for await (const msg of generator) {
|
||||
yield msg;
|
||||
}
|
||||
}
|
||||
|
||||
async detectInstallation() {
|
||||
const codexCliDetector = require('./codex-cli-detector');
|
||||
return codexCliDetector.getInstallationInfo();
|
||||
}
|
||||
|
||||
getAvailableModels() {
|
||||
return [
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
modelString: 'gpt-5.1-codex-max',
|
||||
provider: 'codex',
|
||||
description: 'Latest flagship - deep and fast reasoning for coding',
|
||||
tier: 'premium',
|
||||
default: true
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex',
|
||||
name: 'GPT-5.1 Codex',
|
||||
modelString: 'gpt-5.1-codex',
|
||||
provider: 'codex',
|
||||
description: 'Optimized for code generation',
|
||||
tier: 'standard'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
modelString: 'gpt-5.1-codex-mini',
|
||||
provider: 'codex',
|
||||
description: 'Faster and cheaper option',
|
||||
tier: 'basic'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
modelString: 'gpt-5.1',
|
||||
provider: 'codex',
|
||||
description: 'Broad world knowledge with strong reasoning',
|
||||
tier: 'standard'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
const errors = [];
|
||||
const codexCliDetector = require('./codex-cli-detector');
|
||||
const installation = codexCliDetector.detectCodexInstallation();
|
||||
|
||||
if (!installation.installed && !process.env.OPENAI_API_KEY) {
|
||||
errors.push('Codex CLI not installed and no OPENAI_API_KEY found.');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
getModelString(modelKey) {
|
||||
// Codex models use the key directly as the model string
|
||||
const modelMap = {
|
||||
'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex': 'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-mini': 'gpt-5.1-codex-mini',
|
||||
'gpt-5.1': 'gpt-5.1'
|
||||
};
|
||||
return modelMap[modelKey] || 'gpt-5.1-codex-max';
|
||||
}
|
||||
|
||||
supportsFeature(feature) {
|
||||
const supportedFeatures = ['tools', 'streaming'];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model Provider Factory
|
||||
* Creates the appropriate provider based on model or provider name
|
||||
*/
|
||||
class ModelProviderFactory {
|
||||
static providers = {
|
||||
claude: ClaudeProvider,
|
||||
codex: CodexProvider
|
||||
};
|
||||
|
||||
/**
|
||||
* Get provider for a specific model
|
||||
* @param {string} modelId Model ID (e.g., 'opus', 'gpt-5.1-codex')
|
||||
* @returns {ModelProvider} Provider instance
|
||||
*/
|
||||
static getProviderForModel(modelId) {
|
||||
// Check if it's a Claude model
|
||||
const claudeModels = ['haiku', 'sonnet', 'opus'];
|
||||
if (claudeModels.includes(modelId)) {
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
// Check if it's a Codex/OpenAI model
|
||||
const codexModels = [
|
||||
'gpt-5.1-codex-max', 'gpt-5.1-codex', 'gpt-5.1-codex-mini', 'gpt-5.1'
|
||||
];
|
||||
if (codexModels.includes(modelId)) {
|
||||
return new CodexProvider();
|
||||
}
|
||||
|
||||
// Default to Claude
|
||||
return new ClaudeProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name
|
||||
* @param {string} providerName Provider name ('claude' or 'codex')
|
||||
* @returns {ModelProvider} Provider instance
|
||||
*/
|
||||
static getProvider(providerName) {
|
||||
const ProviderClass = this.providers[providerName];
|
||||
if (!ProviderClass) {
|
||||
throw new Error(`Unknown provider: ${providerName}`);
|
||||
}
|
||||
return new ProviderClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
* @returns {string[]} List of provider names
|
||||
*/
|
||||
static getAvailableProviders() {
|
||||
return Object.keys(this.providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models across all providers
|
||||
* @returns {Array<Object>} All available models
|
||||
*/
|
||||
static getAllModels() {
|
||||
const allModels = [];
|
||||
for (const providerName of this.getAvailableProviders()) {
|
||||
const provider = this.getProvider(providerName);
|
||||
const models = provider.getAvailableModels();
|
||||
allModels.push(...models);
|
||||
}
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check installation status for all providers
|
||||
* @returns {Promise<Object>} Installation status for each provider
|
||||
*/
|
||||
static async checkAllProviders() {
|
||||
const status = {};
|
||||
for (const providerName of this.getAvailableProviders()) {
|
||||
const provider = this.getProvider(providerName);
|
||||
status[providerName] = await provider.detectInstallation();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ModelProvider,
|
||||
ClaudeProvider,
|
||||
CodexProvider,
|
||||
ModelProviderFactory
|
||||
};
|
||||
320
app/electron/services/model-registry.js
Normal file
320
app/electron/services/model-registry.js
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Model Registry - Centralized model definitions and metadata
|
||||
*
|
||||
* This module provides a central registry of all available models
|
||||
* across different providers (Claude, Codex/OpenAI).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model Categories
|
||||
*/
|
||||
const MODEL_CATEGORIES = {
|
||||
CLAUDE: 'claude',
|
||||
OPENAI: 'openai',
|
||||
CODEX: 'codex'
|
||||
};
|
||||
|
||||
/**
|
||||
* Model Tiers (capability levels)
|
||||
*/
|
||||
const MODEL_TIERS = {
|
||||
BASIC: 'basic', // Fast, cheap, simple tasks
|
||||
STANDARD: 'standard', // Balanced performance
|
||||
PREMIUM: 'premium' // Most capable, complex tasks
|
||||
};
|
||||
|
||||
const CODEX_MODEL_IDS = [
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.1'
|
||||
];
|
||||
|
||||
/**
|
||||
* All available models with full metadata
|
||||
*/
|
||||
const MODELS = {
|
||||
// Claude Models
|
||||
haiku: {
|
||||
id: 'haiku',
|
||||
name: 'Claude Haiku',
|
||||
modelString: 'claude-haiku-4-5',
|
||||
provider: 'claude',
|
||||
category: MODEL_CATEGORIES.CLAUDE,
|
||||
tier: MODEL_TIERS.BASIC,
|
||||
description: 'Fast and efficient for simple tasks',
|
||||
capabilities: ['code', 'text', 'tools'],
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsThinking: true,
|
||||
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
|
||||
},
|
||||
sonnet: {
|
||||
id: 'sonnet',
|
||||
name: 'Claude Sonnet',
|
||||
modelString: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
category: MODEL_CATEGORIES.CLAUDE,
|
||||
tier: MODEL_TIERS.STANDARD,
|
||||
description: 'Balanced performance and capabilities',
|
||||
capabilities: ['code', 'text', 'tools', 'analysis'],
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsThinking: true,
|
||||
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
|
||||
},
|
||||
opus: {
|
||||
id: 'opus',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
provider: 'claude',
|
||||
category: MODEL_CATEGORIES.CLAUDE,
|
||||
tier: MODEL_TIERS.PREMIUM,
|
||||
description: 'Most capable model for complex tasks',
|
||||
capabilities: ['code', 'text', 'tools', 'analysis', 'reasoning'],
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsThinking: true,
|
||||
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
default: true
|
||||
},
|
||||
|
||||
// OpenAI GPT-5.1 Codex Models
|
||||
'gpt-5.1-codex-max': {
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
modelString: 'gpt-5.1-codex-max',
|
||||
provider: 'codex',
|
||||
category: MODEL_CATEGORIES.OPENAI,
|
||||
tier: MODEL_TIERS.PREMIUM,
|
||||
description: 'Latest flagship - deep and fast reasoning for coding',
|
||||
capabilities: ['code', 'text', 'tools', 'reasoning'],
|
||||
maxTokens: 32768,
|
||||
contextWindow: 128000,
|
||||
supportsThinking: false,
|
||||
requiresAuth: 'OPENAI_API_KEY',
|
||||
codexDefault: true
|
||||
},
|
||||
'gpt-5.1-codex': {
|
||||
id: 'gpt-5.1-codex',
|
||||
name: 'GPT-5.1 Codex',
|
||||
modelString: 'gpt-5.1-codex',
|
||||
provider: 'codex',
|
||||
category: MODEL_CATEGORIES.OPENAI,
|
||||
tier: MODEL_TIERS.STANDARD,
|
||||
description: 'Optimized for code generation',
|
||||
capabilities: ['code', 'text', 'tools'],
|
||||
maxTokens: 32768,
|
||||
contextWindow: 128000,
|
||||
supportsThinking: false,
|
||||
requiresAuth: 'OPENAI_API_KEY'
|
||||
},
|
||||
'gpt-5.1-codex-mini': {
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
modelString: 'gpt-5.1-codex-mini',
|
||||
provider: 'codex',
|
||||
category: MODEL_CATEGORIES.OPENAI,
|
||||
tier: MODEL_TIERS.BASIC,
|
||||
description: 'Faster and cheaper option',
|
||||
capabilities: ['code', 'text'],
|
||||
maxTokens: 16384,
|
||||
contextWindow: 128000,
|
||||
supportsThinking: false,
|
||||
requiresAuth: 'OPENAI_API_KEY'
|
||||
},
|
||||
'gpt-5.1': {
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
modelString: 'gpt-5.1',
|
||||
provider: 'codex',
|
||||
category: MODEL_CATEGORIES.OPENAI,
|
||||
tier: MODEL_TIERS.STANDARD,
|
||||
description: 'Broad world knowledge with strong reasoning',
|
||||
capabilities: ['code', 'text', 'reasoning'],
|
||||
maxTokens: 32768,
|
||||
contextWindow: 128000,
|
||||
supportsThinking: false,
|
||||
requiresAuth: 'OPENAI_API_KEY'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Model Registry class for querying and managing models
|
||||
*/
|
||||
class ModelRegistry {
|
||||
/**
|
||||
* Get all registered models
|
||||
* @returns {Object} All models
|
||||
*/
|
||||
static getAllModels() {
|
||||
return MODELS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model by ID
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {Object|null} Model definition or null
|
||||
*/
|
||||
static getModel(modelId) {
|
||||
return MODELS[modelId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models by provider
|
||||
* @param {string} provider Provider name ('claude' or 'codex')
|
||||
* @returns {Object[]} Array of models for the provider
|
||||
*/
|
||||
static getModelsByProvider(provider) {
|
||||
return Object.values(MODELS).filter(m => m.provider === provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models by category
|
||||
* @param {string} category Category name
|
||||
* @returns {Object[]} Array of models in the category
|
||||
*/
|
||||
static getModelsByCategory(category) {
|
||||
return Object.values(MODELS).filter(m => m.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models by tier
|
||||
* @param {string} tier Tier name
|
||||
* @returns {Object[]} Array of models in the tier
|
||||
*/
|
||||
static getModelsByTier(tier) {
|
||||
return Object.values(MODELS).filter(m => m.tier === tier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a provider
|
||||
* @param {string} provider Provider name
|
||||
* @returns {Object|null} Default model or null
|
||||
*/
|
||||
static getDefaultModel(provider = 'claude') {
|
||||
const models = this.getModelsByProvider(provider);
|
||||
if (provider === 'claude') {
|
||||
return models.find(m => m.default) || models[0];
|
||||
}
|
||||
if (provider === 'codex') {
|
||||
return models.find(m => m.codexDefault) || models[0];
|
||||
}
|
||||
return models[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model string (full model name) for a model ID
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {string} Full model string
|
||||
*/
|
||||
static getModelString(modelId) {
|
||||
const model = this.getModel(modelId);
|
||||
return model ? model.modelString : modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine provider for a model ID
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {string} Provider name ('claude' or 'codex')
|
||||
*/
|
||||
static getProviderForModel(modelId) {
|
||||
const model = this.getModel(modelId);
|
||||
if (model) {
|
||||
return model.provider;
|
||||
}
|
||||
|
||||
// Fallback detection for models not explicitly registered (keeps legacy Codex IDs working)
|
||||
if (CODEX_MODEL_IDS.includes(modelId)) {
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Claude model
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {boolean} Whether it's a Claude model
|
||||
*/
|
||||
static isClaudeModel(modelId) {
|
||||
return this.getProviderForModel(modelId) === 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Codex/OpenAI model
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {boolean} Whether it's a Codex model
|
||||
*/
|
||||
static isCodexModel(modelId) {
|
||||
return this.getProviderForModel(modelId) === 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models grouped by provider for UI display
|
||||
* @returns {Object} Models grouped by provider
|
||||
*/
|
||||
static getModelsGroupedByProvider() {
|
||||
return {
|
||||
claude: this.getModelsByProvider('claude'),
|
||||
codex: this.getModelsByProvider('codex')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all model IDs as an array
|
||||
* @returns {string[]} Array of model IDs
|
||||
*/
|
||||
static getAllModelIds() {
|
||||
return Object.keys(MODELS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model supports a specific capability
|
||||
* @param {string} modelId Model ID
|
||||
* @param {string} capability Capability name
|
||||
* @returns {boolean} Whether the model supports the capability
|
||||
*/
|
||||
static modelSupportsCapability(modelId, capability) {
|
||||
const model = this.getModel(modelId);
|
||||
return model ? model.capabilities.includes(capability) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model supports extended thinking
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {boolean} Whether the model supports thinking
|
||||
*/
|
||||
static modelSupportsThinking(modelId) {
|
||||
const model = this.getModel(modelId);
|
||||
return model ? model.supportsThinking : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required authentication for a model
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {string|null} Required auth env variable name
|
||||
*/
|
||||
static getRequiredAuth(modelId) {
|
||||
const model = this.getModel(modelId);
|
||||
return model ? model.requiresAuth : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is available for a model
|
||||
* @param {string} modelId Model ID
|
||||
* @returns {boolean} Whether auth is available
|
||||
*/
|
||||
static hasAuthForModel(modelId) {
|
||||
const authVar = this.getRequiredAuth(modelId);
|
||||
if (!authVar) return false;
|
||||
return !!process.env[authVar];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MODEL_CATEGORIES,
|
||||
MODEL_TIERS,
|
||||
MODELS,
|
||||
ModelRegistry
|
||||
};
|
||||
576
app/electron/services/worktree-manager.js
Normal file
576
app/electron/services/worktree-manager.js
Normal file
@@ -0,0 +1,576 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
const { exec, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Worktree Manager - Handles git worktrees for feature isolation
|
||||
*
|
||||
* This service creates isolated git worktrees for each feature, allowing:
|
||||
* - Features to be worked on in isolation without affecting the main branch
|
||||
* - Easy rollback/revert by simply deleting the worktree
|
||||
* - Checkpointing - user can see changes in the worktree before merging
|
||||
*/
|
||||
class WorktreeManager {
|
||||
constructor() {
|
||||
// Cache for worktree info
|
||||
this.worktreeCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base worktree directory path
|
||||
*/
|
||||
getWorktreeBasePath(projectPath) {
|
||||
return path.join(projectPath, ".automaker", "worktrees");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a safe branch name from feature description
|
||||
*/
|
||||
generateBranchName(feature) {
|
||||
// Create a slug from the description
|
||||
const slug = feature.description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.substring(0, 40); // Limit length
|
||||
|
||||
// Add feature ID for uniqueness
|
||||
const shortId = feature.id.replace("feature-", "").substring(0, 12);
|
||||
return `feature/${shortId}-${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project is a git repository
|
||||
*/
|
||||
async isGitRepo(projectPath) {
|
||||
try {
|
||||
await execAsync("git rev-parse --is-inside-work-tree", { cwd: projectPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current branch name
|
||||
*/
|
||||
async getCurrentBranch(projectPath) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get current branch:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch exists (local or remote)
|
||||
*/
|
||||
async branchExists(projectPath, branchName) {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all existing worktrees
|
||||
*/
|
||||
async listWorktrees(projectPath) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath });
|
||||
const worktrees = [];
|
||||
const lines = stdout.split("\n");
|
||||
|
||||
let currentWorktree = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
if (currentWorktree) {
|
||||
worktrees.push(currentWorktree);
|
||||
}
|
||||
currentWorktree = { path: line.replace("worktree ", "") };
|
||||
} else if (line.startsWith("branch ") && currentWorktree) {
|
||||
currentWorktree.branch = line.replace("branch refs/heads/", "");
|
||||
} else if (line.startsWith("HEAD ") && currentWorktree) {
|
||||
currentWorktree.head = line.replace("HEAD ", "");
|
||||
}
|
||||
}
|
||||
if (currentWorktree) {
|
||||
worktrees.push(currentWorktree);
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to list worktrees:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree for a feature
|
||||
* @param {string} projectPath - Path to the main project
|
||||
* @param {object} feature - Feature object with id and description
|
||||
* @returns {object} - { success, worktreePath, branchName, error }
|
||||
*/
|
||||
async createWorktree(projectPath, feature) {
|
||||
console.log(`[WorktreeManager] Creating worktree for feature: ${feature.id}`);
|
||||
|
||||
// Check if project is a git repo
|
||||
if (!await this.isGitRepo(projectPath)) {
|
||||
return { success: false, error: "Project is not a git repository" };
|
||||
}
|
||||
|
||||
const branchName = this.generateBranchName(feature);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
const worktreePath = path.join(worktreeBasePath, branchName.replace("feature/", ""));
|
||||
|
||||
try {
|
||||
// Ensure worktree directory exists
|
||||
await fs.mkdir(worktreeBasePath, { recursive: true });
|
||||
|
||||
// Check if worktree already exists
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const existingWorktree = worktrees.find(
|
||||
w => w.path === worktreePath || w.branch === branchName
|
||||
);
|
||||
|
||||
if (existingWorktree) {
|
||||
console.log(`[WorktreeManager] Worktree already exists for feature: ${feature.id}`);
|
||||
return {
|
||||
success: true,
|
||||
worktreePath: existingWorktree.path,
|
||||
branchName: existingWorktree.branch,
|
||||
existed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get current branch to base the new branch on
|
||||
const baseBranch = await this.getCurrentBranch(projectPath);
|
||||
if (!baseBranch) {
|
||||
return { success: false, error: "Could not determine current branch" };
|
||||
}
|
||||
|
||||
// Check if branch already exists
|
||||
const branchExists = await this.branchExists(projectPath, branchName);
|
||||
|
||||
if (branchExists) {
|
||||
// Use existing branch
|
||||
console.log(`[WorktreeManager] Using existing branch: ${branchName}`);
|
||||
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectPath });
|
||||
} else {
|
||||
// Create new worktree with new branch
|
||||
console.log(`[WorktreeManager] Creating new branch: ${branchName} based on ${baseBranch}`);
|
||||
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
// Copy .automaker directory to worktree (except worktrees directory itself to avoid recursion)
|
||||
const automakerSrc = path.join(projectPath, ".automaker");
|
||||
const automakerDst = path.join(worktreePath, ".automaker");
|
||||
|
||||
try {
|
||||
await fs.mkdir(automakerDst, { recursive: true });
|
||||
|
||||
// Copy feature_list.json
|
||||
const featureListSrc = path.join(automakerSrc, "feature_list.json");
|
||||
const featureListDst = path.join(automakerDst, "feature_list.json");
|
||||
try {
|
||||
const content = await fs.readFile(featureListSrc, "utf-8");
|
||||
await fs.writeFile(featureListDst, content, "utf-8");
|
||||
} catch {
|
||||
// Feature list might not exist yet
|
||||
}
|
||||
|
||||
// Copy app_spec.txt if it exists
|
||||
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");
|
||||
const appSpecDst = path.join(automakerDst, "app_spec.txt");
|
||||
try {
|
||||
const content = await fs.readFile(appSpecSrc, "utf-8");
|
||||
await fs.writeFile(appSpecDst, content, "utf-8");
|
||||
} catch {
|
||||
// App spec might not exist yet
|
||||
}
|
||||
|
||||
// Copy categories.json if it exists
|
||||
const categoriesSrc = path.join(automakerSrc, "categories.json");
|
||||
const categoriesDst = path.join(automakerDst, "categories.json");
|
||||
try {
|
||||
const content = await fs.readFile(categoriesSrc, "utf-8");
|
||||
await fs.writeFile(categoriesDst, content, "utf-8");
|
||||
} catch {
|
||||
// Categories might not exist yet
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[WorktreeManager] Failed to copy .automaker directory:", error);
|
||||
}
|
||||
|
||||
// Store worktree info in cache
|
||||
this.worktreeCache.set(feature.id, {
|
||||
worktreePath,
|
||||
branchName,
|
||||
createdAt: new Date().toISOString(),
|
||||
baseBranch,
|
||||
});
|
||||
|
||||
console.log(`[WorktreeManager] Worktree created at: ${worktreePath}`);
|
||||
return {
|
||||
success: true,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
existed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to create worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
async getWorktreeInfo(projectPath, featureId) {
|
||||
// Check cache first
|
||||
if (this.worktreeCache.has(featureId)) {
|
||||
return { success: true, ...this.worktreeCache.get(featureId) };
|
||||
}
|
||||
|
||||
// Scan worktrees to find matching one
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
// Check if this worktree is in our worktree directory
|
||||
if (worktree.path.startsWith(worktreeBasePath)) {
|
||||
// Check if the feature ID is in the branch name
|
||||
const shortId = featureId.replace("feature-", "").substring(0, 12);
|
||||
if (worktree.branch && worktree.branch.includes(shortId)) {
|
||||
const info = {
|
||||
worktreePath: worktree.path,
|
||||
branchName: worktree.branch,
|
||||
head: worktree.head,
|
||||
};
|
||||
this.worktreeCache.set(featureId, info);
|
||||
return { success: true, ...info };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree for a feature
|
||||
* This effectively reverts all changes made by the agent
|
||||
*/
|
||||
async removeWorktree(projectPath, featureId, deleteBranch = false) {
|
||||
console.log(`[WorktreeManager] Removing worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
console.log(`[WorktreeManager] No worktree found for feature: ${featureId}`);
|
||||
return { success: true, message: "No worktree to remove" };
|
||||
}
|
||||
|
||||
const { worktreePath, branchName } = worktreeInfo;
|
||||
|
||||
try {
|
||||
// Remove the worktree
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
|
||||
console.log(`[WorktreeManager] Worktree removed: ${worktreePath}`);
|
||||
|
||||
// Optionally delete the branch too
|
||||
if (deleteBranch && branchName) {
|
||||
try {
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
console.log(`[WorktreeManager] Branch deleted: ${branchName}`);
|
||||
} catch (error) {
|
||||
console.warn(`[WorktreeManager] Could not delete branch ${branchName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
this.worktreeCache.delete(featureId);
|
||||
|
||||
return { success: true, removedPath: worktreePath, removedBranch: deleteBranch ? branchName : null };
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to remove worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of changes in a worktree
|
||||
*/
|
||||
async getWorktreeStatus(worktreePath) {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
const { stdout: diffStat } = await execAsync("git diff --stat", { cwd: worktreePath });
|
||||
const { stdout: commitLog } = await execAsync("git log --oneline -10", { cwd: worktreePath });
|
||||
|
||||
const files = statusOutput.trim().split("\n").filter(Boolean);
|
||||
const commits = commitLog.trim().split("\n").filter(Boolean);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modifiedFiles: files.length,
|
||||
files: files.slice(0, 20), // Limit to 20 files
|
||||
diffStat: diffStat.trim(),
|
||||
recentCommits: commits.slice(0, 5), // Last 5 commits
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get worktree status:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed file diff content for a worktree
|
||||
* Returns unified diff format for all changes
|
||||
*/
|
||||
async getFileDiffs(worktreePath) {
|
||||
try {
|
||||
// Get both staged and unstaged diffs
|
||||
const { stdout: unstagedDiff } = await execAsync("git diff --no-color", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large diffs
|
||||
});
|
||||
const { stdout: stagedDiff } = await execAsync("git diff --cached --no-color", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
});
|
||||
|
||||
// Get list of files with their status
|
||||
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
const files = statusOutput.trim().split("\n").filter(Boolean);
|
||||
|
||||
// Parse file statuses
|
||||
const fileStatuses = files.map(line => {
|
||||
const status = line.substring(0, 2);
|
||||
const filePath = line.substring(3);
|
||||
return {
|
||||
status: status.trim() || 'M',
|
||||
path: filePath,
|
||||
statusText: this.getStatusText(status)
|
||||
};
|
||||
});
|
||||
|
||||
// Combine diffs
|
||||
const combinedDiff = [stagedDiff, unstagedDiff].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
diff: combinedDiff,
|
||||
files: fileStatuses,
|
||||
hasChanges: files.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get file diffs:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status text from git status code
|
||||
*/
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'M': 'Modified',
|
||||
'A': 'Added',
|
||||
'D': 'Deleted',
|
||||
'R': 'Renamed',
|
||||
'C': 'Copied',
|
||||
'U': 'Updated',
|
||||
'?': 'Untracked',
|
||||
'!': 'Ignored'
|
||||
};
|
||||
const firstChar = status.charAt(0);
|
||||
const secondChar = status.charAt(1);
|
||||
return statusMap[firstChar] || statusMap[secondChar] || 'Changed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a worktree
|
||||
*/
|
||||
async getFileDiff(worktreePath, filePath) {
|
||||
try {
|
||||
// Try to get unstaged diff first, then staged if no unstaged changes
|
||||
let diff = '';
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --no-color -- "${filePath}"`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 5 * 1024 * 1024
|
||||
});
|
||||
diff = stdout;
|
||||
} catch {
|
||||
// File might be staged
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --cached --no-color -- "${filePath}"`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 5 * 1024 * 1024
|
||||
});
|
||||
diff = stdout;
|
||||
} catch {
|
||||
// File might be untracked, show the content
|
||||
}
|
||||
}
|
||||
|
||||
// If still no diff, might be an untracked file - show the content
|
||||
if (!diff) {
|
||||
try {
|
||||
const fullPath = path.join(worktreePath, filePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
diff = `+++ ${filePath} (new file)\n${content.split('\n').map(l => '+' + l).join('\n')}`;
|
||||
} catch {
|
||||
diff = '(Unable to read file content)';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
diff,
|
||||
filePath
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WorktreeManager] Failed to get diff for ${filePath}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge worktree changes back to the main branch
|
||||
*/
|
||||
async mergeWorktree(projectPath, featureId, options = {}) {
|
||||
console.log(`[WorktreeManager] Merging worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
const { branchName, worktreePath } = worktreeInfo;
|
||||
const baseBranch = await this.getCurrentBranch(projectPath);
|
||||
|
||||
try {
|
||||
// First commit any uncommitted changes in the worktree
|
||||
const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
if (status.trim()) {
|
||||
// There are uncommitted changes - commit them
|
||||
await execAsync("git add -A", { cwd: worktreePath });
|
||||
const commitMsg = options.commitMessage || `feat: complete ${featureId}`;
|
||||
await execAsync(`git commit -m "${commitMsg}"`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Merge the feature branch into the current branch in the main repo
|
||||
if (options.squash) {
|
||||
await execAsync(`git merge --squash ${branchName}`, { cwd: projectPath });
|
||||
const squashMsg = options.squashMessage || `feat: ${featureId} - squashed merge`;
|
||||
await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath });
|
||||
} else {
|
||||
await execAsync(`git merge ${branchName} --no-ff -m "Merge ${branchName}"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
console.log(`[WorktreeManager] Successfully merged ${branchName} into ${baseBranch}`);
|
||||
|
||||
// Optionally cleanup worktree after merge
|
||||
if (options.cleanup) {
|
||||
await this.removeWorktree(projectPath, featureId, true);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mergedBranch: branchName,
|
||||
intoBranch: baseBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to merge worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync changes from main branch to worktree (rebase or merge)
|
||||
*/
|
||||
async syncWorktree(projectPath, featureId, method = "rebase") {
|
||||
console.log(`[WorktreeManager] Syncing worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
const { worktreePath, baseBranch } = worktreeInfo;
|
||||
|
||||
try {
|
||||
if (method === "rebase") {
|
||||
await execAsync(`git rebase ${baseBranch}`, { cwd: worktreePath });
|
||||
} else {
|
||||
await execAsync(`git merge ${baseBranch}`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
return { success: true, method };
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to sync worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all feature worktrees
|
||||
*/
|
||||
async getAllFeatureWorktrees(projectPath) {
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
|
||||
return worktrees.filter(w =>
|
||||
w.path.startsWith(worktreeBasePath) &&
|
||||
w.branch &&
|
||||
w.branch.startsWith("feature/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup orphaned worktrees (worktrees without matching features)
|
||||
*/
|
||||
async cleanupOrphanedWorktrees(projectPath, activeFeatureIds) {
|
||||
console.log("[WorktreeManager] Cleaning up orphaned worktrees...");
|
||||
|
||||
const worktrees = await this.getAllFeatureWorktrees(projectPath);
|
||||
const cleaned = [];
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
// Extract feature ID from branch name
|
||||
const branchParts = worktree.branch.replace("feature/", "").split("-");
|
||||
const shortId = branchParts[0];
|
||||
|
||||
// Check if any active feature has this short ID
|
||||
const hasMatchingFeature = activeFeatureIds.some(id => {
|
||||
const featureShortId = id.replace("feature-", "").substring(0, 12);
|
||||
return featureShortId === shortId;
|
||||
});
|
||||
|
||||
if (!hasMatchingFeature) {
|
||||
console.log(`[WorktreeManager] Removing orphaned worktree: ${worktree.path}`);
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktree.path}" --force`, { cwd: projectPath });
|
||||
await execAsync(`git branch -D ${worktree.branch}`, { cwd: projectPath });
|
||||
cleaned.push(worktree.path);
|
||||
} catch (error) {
|
||||
console.warn(`[WorktreeManager] Failed to cleanup worktree ${worktree.path}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, cleaned };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorktreeManager();
|
||||
@@ -1,417 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Sparkles,
|
||||
Wand2,
|
||||
LayoutGrid,
|
||||
Layers,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
List,
|
||||
Cpu,
|
||||
Search,
|
||||
Share2,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Settings,
|
||||
PanelLeftClose,
|
||||
PanelLeft,
|
||||
Home,
|
||||
LogOut,
|
||||
User,
|
||||
CreditCard,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AppSidebarProps {
|
||||
user: any;
|
||||
creditsBalance: number | null;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
icon: any;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export function AppSidebar({ user, creditsBalance }: AppSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
userMenuRef.current &&
|
||||
!userMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setUserMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (userMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [userMenuOpen]);
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{ href: "/generate", icon: Home, label: "Overview" },
|
||||
{ href: "/generate/canvas", icon: Wand2, label: "Canvas" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Content",
|
||||
items: [
|
||||
{ href: "/generate/gallery", icon: LayoutGrid, label: "Gallery" },
|
||||
{ href: "/generate/collections", icon: Layers, label: "Collections" },
|
||||
{ href: "/generate/projects", icon: FolderOpen, label: "Projects" },
|
||||
{ href: "/generate/prompts", icon: FileText, label: "Prompts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
items: [
|
||||
{ href: "/generate/batch", icon: List, label: "Batch" },
|
||||
{ href: "/generate/models", icon: Cpu, label: "Models" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Manage",
|
||||
items: [
|
||||
{ href: "/generate/shared", icon: Share2, label: "Shared" },
|
||||
{ href: "/generate/trash", icon: Trash2, label: "Trash" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const isActiveRoute = (href: string) => {
|
||||
if (href === "/generate") {
|
||||
return pathname === "/generate";
|
||||
}
|
||||
return pathname?.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${
|
||||
sidebarCollapsed ? "w-16" : "w-16 lg:w-60"
|
||||
} flex-shrink-0 border-r border-white/10 bg-zinc-950/50 backdrop-blur-md flex flex-col z-30 transition-all duration-300 relative`}
|
||||
data-testid="left-sidebar"
|
||||
data-collapsed={sidebarCollapsed}
|
||||
>
|
||||
{/* Floating Collapse Toggle Button */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="hidden lg:flex absolute top-20 -right-3 z-50 items-center justify-center w-6 h-6 rounded-full bg-zinc-800 border border-white/10 text-zinc-400 hover:text-white hover:bg-zinc-700 hover:border-white/20 transition-all shadow-lg"
|
||||
data-testid="sidebar-collapse-button"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<PanelLeftClose className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className={`h-16 flex items-center border-b border-zinc-800 flex-shrink-0 ${
|
||||
sidebarCollapsed ? "justify-center" : "justify-center lg:justify-start lg:px-6"
|
||||
}`}>
|
||||
<Link href="/generate" className="flex items-center">
|
||||
<div className="relative flex items-center justify-center w-8 h-8 bg-gradient-to-br from-brand-500 to-purple-600 rounded-lg shadow-lg shadow-brand-500/20 group cursor-pointer">
|
||||
<Sparkles className="text-white w-5 h-5 group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
<span
|
||||
className={`ml-3 font-bold text-white text-base tracking-tight ${
|
||||
sidebarCollapsed ? "hidden" : "hidden lg:block"
|
||||
}`}
|
||||
>
|
||||
Image<span className="text-brand-500">Studio</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav Items - Scrollable */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 mt-2 pb-2">
|
||||
{navSections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
|
||||
{/* Section Label */}
|
||||
{section.label && !sidebarCollapsed && (
|
||||
<div className="hidden lg:block px-4 mb-2">
|
||||
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
{section.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.label && sidebarCollapsed && (
|
||||
<div className="h-px bg-zinc-800 mx-2 mb-2"></div>
|
||||
)}
|
||||
|
||||
{/* Nav Items */}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
|
||||
isActive
|
||||
? "bg-white/5 text-white border border-white/10"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
||||
)}
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 transition-colors ${
|
||||
isActive
|
||||
? "text-brand-500"
|
||||
: "group-hover:text-brand-400"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`ml-2.5 font-medium text-sm ${
|
||||
sidebarCollapsed ? "hidden" : "hidden lg:block"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Tooltip for collapsed state */}
|
||||
{sidebarCollapsed && (
|
||||
<span
|
||||
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
|
||||
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section - User / Settings */}
|
||||
<div className="border-t border-zinc-800 bg-zinc-900/50 flex-shrink-0">
|
||||
{/* Usage & Settings Links */}
|
||||
<div className="p-2 space-y-1">
|
||||
<Link
|
||||
href="/generate/usage"
|
||||
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
|
||||
isActiveRoute("/generate/usage")
|
||||
? "bg-white/5 text-white border border-white/10"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
title={sidebarCollapsed ? "Usage" : undefined}
|
||||
>
|
||||
{isActiveRoute("/generate/usage") && (
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
||||
)}
|
||||
<BarChart3
|
||||
className={`w-4 h-4 flex-shrink-0 transition-colors ${
|
||||
isActiveRoute("/generate/usage")
|
||||
? "text-brand-500"
|
||||
: "group-hover:text-brand-400"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`ml-2.5 font-medium text-sm ${
|
||||
sidebarCollapsed ? "hidden" : "hidden lg:block"
|
||||
}`}
|
||||
>
|
||||
Usage
|
||||
</span>
|
||||
{sidebarCollapsed && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
|
||||
Usage
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/generate/settings"
|
||||
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
|
||||
isActiveRoute("/generate/settings")
|
||||
? "bg-white/5 text-white border border-white/10"
|
||||
: "text-zinc-400 hover:text-white hover:bg-white/5"
|
||||
}`}
|
||||
title={sidebarCollapsed ? "Settings" : undefined}
|
||||
>
|
||||
{isActiveRoute("/generate/settings") && (
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
||||
)}
|
||||
<Settings
|
||||
className={`w-4 h-4 flex-shrink-0 transition-colors ${
|
||||
isActiveRoute("/generate/settings")
|
||||
? "text-brand-500"
|
||||
: "group-hover:text-brand-400"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`ml-2.5 font-medium text-sm ${
|
||||
sidebarCollapsed ? "hidden" : "hidden lg:block"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
{sidebarCollapsed && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
|
||||
Settings
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Credits Display */}
|
||||
{!sidebarCollapsed && (
|
||||
<Link href="/generate/usage" className="hidden lg:block mx-3 mb-3">
|
||||
<div className="p-2.5 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer">
|
||||
<div className="flex justify-between text-[11px] font-medium text-zinc-400 mb-1.5">
|
||||
<span>Credits</span>
|
||||
<span className="text-white" data-testid="credits-sidebar-balance">
|
||||
{creditsBalance !== null ? creditsBalance : "..."} / 1000
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 rounded-full h-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-brand-500 to-purple-500 h-1 rounded-full"
|
||||
style={{
|
||||
width: `${
|
||||
creditsBalance !== null
|
||||
? Math.min((creditsBalance / 1000) * 100, 100)
|
||||
: 30
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="p-3 border-t border-zinc-800" ref={userMenuRef}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className={`flex items-center p-1.5 rounded-lg transition-colors group relative w-full hover:bg-white/5 ${
|
||||
sidebarCollapsed ? "justify-center" : "lg:space-x-2.5"
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={
|
||||
user?.avatarUrl ||
|
||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100&q=80"
|
||||
}
|
||||
alt="User"
|
||||
className="w-8 h-8 rounded-full border border-zinc-600"
|
||||
/>
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 border-2 border-zinc-900 rounded-full"></div>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-hidden ${
|
||||
sidebarCollapsed ? "hidden" : "hidden lg:block"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-medium text-white truncate">
|
||||
{user ? user.name : "Guest"}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-500 truncate">
|
||||
{user ? "Pro Account" : "Guest"}
|
||||
</p>
|
||||
</div>
|
||||
{/* Tooltip for user when collapsed */}
|
||||
{sidebarCollapsed && (
|
||||
<span
|
||||
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
|
||||
data-testid="sidebar-tooltip-user"
|
||||
>
|
||||
{user ? user.name : "Guest"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{userMenuOpen && (
|
||||
<div
|
||||
className={`absolute bottom-full mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-lg overflow-hidden z-50 ${
|
||||
sidebarCollapsed ? "left-0" : "left-0 right-0"
|
||||
}`}
|
||||
>
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/generate/settings"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-3" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/generate/usage"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 mr-3" />
|
||||
<span>Usage</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
|
||||
>
|
||||
<User className="w-4 h-4 mr-3" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/billing"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-3" />
|
||||
<span>Billing</span>
|
||||
</Link>
|
||||
<div className="border-t border-zinc-700 my-2"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
// Add logout logic here
|
||||
window.location.href = "/api/auth/logout";
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-red-400 hover:bg-zinc-700/50 hover:text-red-300 transition-colors w-full text-left"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-3" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
2590
app/example/page.tsx
2590
app/example/page.tsx
File diff suppressed because it is too large
Load Diff
76
app/package-lock.json
generated
76
app/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -3186,6 +3187,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -3322,6 +3375,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -72,7 +73,9 @@
|
||||
{
|
||||
"from": ".env",
|
||||
"to": ".env",
|
||||
"filter": ["**/*"]
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -80,11 +83,17 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/logo.png"
|
||||
@@ -93,7 +102,9 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/logo.png"
|
||||
@@ -102,11 +113,15 @@
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64"]
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "Development",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey } = await request.json();
|
||||
@@ -15,31 +20,60 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Create Anthropic client with the provided key
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: effectiveApiKey,
|
||||
// Send a simple test prompt to the Anthropic API
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": effectiveApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Send a simple test prompt
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Respond with exactly: 'Claude SDK connection successful!' and nothing else.",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as AnthropicResponse;
|
||||
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnthropicResponse;
|
||||
|
||||
// Check if we got a valid response
|
||||
if (response.content && response.content.length > 0) {
|
||||
const textContent = response.content.find((block) => block.type === "text");
|
||||
if (textContent && textContent.type === "text") {
|
||||
if (data.content && data.content.length > 0) {
|
||||
const textContent = data.content.find((block) => block.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${textContent.text}"`,
|
||||
model: response.model,
|
||||
model: data.model,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,33 +81,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Claude responded.",
|
||||
model: response.model,
|
||||
model: data.model,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Claude API test error:", error);
|
||||
|
||||
// Handle specific Anthropic API errors
|
||||
if (error instanceof Anthropic.AuthenticationError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Anthropic.RateLimitError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Anthropic.APIError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${error.message}` },
|
||||
{ status: error.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Claude API";
|
||||
|
||||
|
||||
@@ -1609,6 +1609,39 @@
|
||||
box-shadow: 0 0 8px #f97e72;
|
||||
}
|
||||
|
||||
/* Line clamp utilities for text overflow prevention */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Kanban card improvements to prevent text overflow */
|
||||
.kanban-card-content {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Ensure proper column layout in double-width kanban columns */
|
||||
.kanban-columns-layout > * {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Electron title bar drag region */
|
||||
.titlebar-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SettingsView } from "@/components/views/settings-view";
|
||||
import { AgentToolsView } from "@/components/views/agent-tools-view";
|
||||
import { InterviewView } from "@/components/views/interview-view";
|
||||
import { ContextView } from "@/components/views/context-view";
|
||||
import { ProfilesView } from "@/components/views/profiles-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
|
||||
@@ -109,6 +110,8 @@ export default function Home() {
|
||||
return <InterviewView />;
|
||||
case "context":
|
||||
return <ContextView />;
|
||||
case "profiles":
|
||||
return <ProfilesView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Undo2,
|
||||
UserCircle,
|
||||
MoreVertical,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -487,6 +488,12 @@ export function Sidebar() {
|
||||
icon: Wrench,
|
||||
shortcut: NAV_SHORTCUTS.tools,
|
||||
},
|
||||
{
|
||||
id: "profiles",
|
||||
label: "AI Profiles",
|
||||
icon: UserCircle,
|
||||
shortcut: NAV_SHORTCUTS.profiles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
36
app/src/components/ui/badge.tsx
Normal file
36
app/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -14,6 +14,9 @@ export interface FeatureImagePath {
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||
export type ImagePreviewMap = Map<string, string>;
|
||||
|
||||
interface DescriptionImageDropZoneProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -24,6 +27,9 @@ interface DescriptionImageDropZoneProps {
|
||||
disabled?: boolean;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // in bytes, default 10MB
|
||||
// Optional: pass preview map from parent to persist across tab switches
|
||||
previewMap?: ImagePreviewMap;
|
||||
onPreviewMapChange?: (map: ImagePreviewMap) => void;
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
@@ -45,12 +51,31 @@ export function DescriptionImageDropZone({
|
||||
disabled = false,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
previewMap,
|
||||
onPreviewMapChange,
|
||||
}: DescriptionImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [previewImages, setPreviewImages] = useState<Map<string, string>>(
|
||||
new Map()
|
||||
// Use parent-provided preview map if available, otherwise use local state
|
||||
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
|
||||
// Determine which preview map to use - prefer parent-controlled state
|
||||
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
||||
if (onPreviewMapChange) {
|
||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
||||
onPreviewMapChange(newMap);
|
||||
} else {
|
||||
setLocalPreviewImages((prev) => {
|
||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [onPreviewMapChange, previewMap, localPreviewImages]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
|
||||
@@ -50,9 +50,11 @@ function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
compact = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
@@ -60,7 +62,8 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
|
||||
compact ? "max-w-2xl p-4" : "sm:max-w-2xl p-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -69,7 +72,10 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
compact ? "top-2 right-2" : "top-4 right-4"
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
628
app/src/components/ui/git-diff-panel.tsx
Normal file
628
app/src/components/ui/git-diff-panel.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import type { FileStatus } from "@/types/electron";
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
className?: string;
|
||||
/** Whether to show the panel in a compact/minimized state initially */
|
||||
compact?: boolean;
|
||||
/** Whether worktrees are enabled - if false, shows diffs from main project */
|
||||
useWorktrees?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return <FilePlus className="w-4 h-4 text-green-500" />;
|
||||
case "D":
|
||||
return <FileX className="w-4 h-4 text-red-500" />;
|
||||
case "M":
|
||||
case "U":
|
||||
return <FilePen className="w-4 h-4 text-amber-500" />;
|
||||
case "R":
|
||||
case "C":
|
||||
return <File className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "D":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "M":
|
||||
case "U":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "R":
|
||||
case "C":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDisplayName = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
return "Added";
|
||||
case "?":
|
||||
return "Untracked";
|
||||
case "D":
|
||||
return "Deleted";
|
||||
case "M":
|
||||
return "Modified";
|
||||
case "U":
|
||||
return "Updated";
|
||||
case "R":
|
||||
return "Renamed";
|
||||
case "C":
|
||||
return "Copied";
|
||||
default:
|
||||
return "Changed";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split("\n");
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// New file diff
|
||||
if (line.startsWith("diff --git")) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
// Extract file path from diff header
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : "unknown",
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file indicator
|
||||
if (line.startsWith("new file mode")) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file indicator
|
||||
if (line.startsWith("deleted file mode")) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Renamed file indicator
|
||||
if (line.startsWith("rename from") || line.startsWith("rename to")) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip index, ---/+++ lines
|
||||
if (
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith("@@")) {
|
||||
if (currentHunk && currentFile) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
// Parse line numbers from @@ -old,count +new,count @@
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: "header", content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diff content lines
|
||||
if (currentHunk) {
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({
|
||||
type: "addition",
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({
|
||||
type: "deletion",
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
currentHunk.lines.push({
|
||||
type: "context",
|
||||
content: line.substring(1) || "",
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last file and hunk
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: "bg-transparent",
|
||||
addition: "bg-green-500/10",
|
||||
deletion: "bg-red-500/10",
|
||||
header: "bg-blue-500/10",
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: "text-foreground-secondary",
|
||||
addition: "text-green-400",
|
||||
deletion: "text-red-400",
|
||||
header: "text-blue-400",
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: " ",
|
||||
addition: "+",
|
||||
deletion: "-",
|
||||
header: "",
|
||||
};
|
||||
|
||||
if (type === "header") {
|
||||
return (
|
||||
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex font-mono text-xs", bgClass[type])}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.old ?? ""}
|
||||
</span>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.new ?? ""}
|
||||
</span>
|
||||
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
|
||||
{content || "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
fileDiff: ParsedFileDiff;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
);
|
||||
const deletions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
||||
{fileDiff.filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{fileDiff.isNew && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isDeleted && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
deleted
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isRenamed && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
|
||||
renamed
|
||||
</span>
|
||||
)}
|
||||
{additions > 0 && (
|
||||
<span className="text-xs text-green-400">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-xs text-red-400">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitDiffPanel({
|
||||
projectPath,
|
||||
featureId,
|
||||
className,
|
||||
compact = true,
|
||||
useWorktrees = false,
|
||||
}: GitDiffPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState<string>("");
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadDiffs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Use worktree API if worktrees are enabled, otherwise use git API for main project
|
||||
if (useWorktrees) {
|
||||
if (!api?.worktree?.getDiffs) {
|
||||
throw new Error("Worktree API not available");
|
||||
}
|
||||
const result = await api.worktree.getDiffs(projectPath, featureId);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
} else {
|
||||
// Use git API for main project diffs
|
||||
if (!api?.git?.getDiffs) {
|
||||
throw new Error("Git API not available");
|
||||
}
|
||||
const result = await api.git.getDiffs(projectPath);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load diffs");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, featureId, useWorktrees]);
|
||||
|
||||
// Load diffs when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
loadDiffs();
|
||||
}
|
||||
}, [isExpanded, loadDiffs]);
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAllFiles = () => {
|
||||
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
|
||||
};
|
||||
|
||||
const collapseAllFiles = () => {
|
||||
setExpandedFiles(new Set());
|
||||
};
|
||||
|
||||
// Total stats
|
||||
const totalAdditions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
const totalDeletions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
data-testid="git-diff-panel"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left"
|
||||
data-testid="git-diff-panel-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
<span className="font-medium text-sm text-foreground">Git Changes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{!isExpanded && files.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">+{totalAdditions}</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<span className="text-sm">No changes detected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{(() => {
|
||||
// Group files by status
|
||||
const statusGroups = files.reduce((acc, file) => {
|
||||
const status = file.status;
|
||||
if (!acc[status]) {
|
||||
acc[status] = {
|
||||
count: 0,
|
||||
statusText: getStatusDisplayName(status),
|
||||
files: []
|
||||
};
|
||||
}
|
||||
acc[status].count += 1;
|
||||
acc[status].files.push(file.path);
|
||||
return acc;
|
||||
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
|
||||
|
||||
return Object.entries(statusGroups).map(([status, group]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center gap-1.5"
|
||||
title={group.files.join('\n')}
|
||||
data-testid={`git-status-group-${status.toLowerCase()}`}
|
||||
>
|
||||
{getFileIcon(status)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||
getStatusBadgeColor(status)
|
||||
)}
|
||||
>
|
||||
{group.count} {group.statusText}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={expandAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={collapseAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"} changed
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">
|
||||
+{totalAdditions} additions
|
||||
</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">
|
||||
-{totalDeletions} deletions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File diffs */}
|
||||
<div className="space-y-3">
|
||||
{parsedDiffs.map((fileDiff) => (
|
||||
<FileDiffSection
|
||||
key={fileDiff.filePath}
|
||||
fileDiff={fileDiff}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Bug,
|
||||
Info,
|
||||
FileOutput,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ const getLogIcon = (type: LogEntryType) => {
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "thinking":
|
||||
return <Brain className="w-4 h-4" />;
|
||||
case "debug":
|
||||
return <Bug className="w-4 h-4" />;
|
||||
default:
|
||||
|
||||
32
app/src/components/ui/tooltip.tsx
Normal file
32
app/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, List, FileText } from "lucide-react";
|
||||
import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
open: boolean;
|
||||
@@ -21,7 +24,7 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = "parsed" | "raw";
|
||||
type ViewMode = "parsed" | "raw" | "changes";
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
@@ -33,9 +36,11 @@ export function AgentOutputModal({
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
||||
const [projectPath, setProjectPath] = useState<string>("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
@@ -63,6 +68,7 @@ export function AgentOutputModal({
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
setProjectPath(currentProject.path);
|
||||
|
||||
// Ensure context directory exists
|
||||
const contextDir = `${currentProject.path}/.automaker/agents-context`;
|
||||
@@ -113,44 +119,78 @@ export function AgentOutputModal({
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Filter events for this specific feature only
|
||||
if (event.featureId !== featureId) {
|
||||
// Filter events for this specific feature only (skip events without featureId)
|
||||
if ("featureId" in event && event.featureId !== featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
|
||||
if (event.type === "auto_mode_progress") {
|
||||
newContent = event.content || "";
|
||||
} else if (event.type === "auto_mode_tool") {
|
||||
const toolName = event.tool || "Unknown Tool";
|
||||
const toolInput = event.input
|
||||
? JSON.stringify(event.input, null, 2)
|
||||
: "";
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||
toolInput ? `Input: ${toolInput}` : ""
|
||||
}`;
|
||||
} else if (event.type === "auto_mode_phase") {
|
||||
const phaseEmoji =
|
||||
event.phase === "planning"
|
||||
? "📋"
|
||||
: event.phase === "action"
|
||||
? "⚡"
|
||||
: "✅";
|
||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||
} else if (event.type === "auto_mode_error") {
|
||||
newContent = `\n❌ Error: ${event.error}\n`;
|
||||
} else if (event.type === "auto_mode_feature_complete") {
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
switch (event.type) {
|
||||
case "auto_mode_progress":
|
||||
newContent = event.content || "";
|
||||
break;
|
||||
case "auto_mode_tool":
|
||||
const toolName = event.tool || "Unknown Tool";
|
||||
const toolInput = event.input
|
||||
? JSON.stringify(event.input, null, 2)
|
||||
: "";
|
||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
||||
toolInput ? `Input: ${toolInput}` : ""
|
||||
}`;
|
||||
break;
|
||||
case "auto_mode_phase":
|
||||
const phaseEmoji =
|
||||
event.phase === "planning"
|
||||
? "📋"
|
||||
: event.phase === "action"
|
||||
? "⚡"
|
||||
: "✅";
|
||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||
break;
|
||||
case "auto_mode_error":
|
||||
newContent = `\n❌ Error: ${event.error}\n`;
|
||||
break;
|
||||
case "auto_mode_ultrathink_preparation":
|
||||
// Format thinking level preparation information
|
||||
let prepContent = `\n🧠 Ultrathink Preparation\n`;
|
||||
|
||||
if (event.warnings && event.warnings.length > 0) {
|
||||
prepContent += `\n⚠️ Warnings:\n`;
|
||||
event.warnings.forEach((warning: string) => {
|
||||
prepContent += ` • ${warning}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.recommendations && event.recommendations.length > 0) {
|
||||
prepContent += `\n💡 Recommendations:\n`;
|
||||
event.recommendations.forEach((rec: string) => {
|
||||
prepContent += ` • ${rec}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.estimatedCost !== undefined) {
|
||||
prepContent += `\n💰 Estimated Cost: ~$${event.estimatedCost.toFixed(2)} per execution\n`;
|
||||
}
|
||||
|
||||
if (event.estimatedTime) {
|
||||
prepContent += `\n⏱️ Estimated Time: ${event.estimatedTime}\n`;
|
||||
}
|
||||
|
||||
newContent = prepContent;
|
||||
break;
|
||||
case "auto_mode_feature_complete":
|
||||
const emoji = event.passes ? "✅" : "⚠️";
|
||||
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||
|
||||
// Close the modal when the feature is verified (passes = true)
|
||||
if (event.passes) {
|
||||
// Small delay to show the completion message before closing
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
}
|
||||
// Close the modal when the feature is verified (passes = true)
|
||||
if (event.passes) {
|
||||
// Small delay to show the completion message before closing
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
@@ -211,25 +251,37 @@ export function AgentOutputModal({
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1 bg-zinc-900/50 rounded-lg p-1">
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("parsed")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "parsed"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
Parsed
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("changes")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "changes"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-changes"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("raw")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "raw"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-raw"
|
||||
>
|
||||
@@ -246,34 +298,55 @@ export function AgentOutputModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
{viewMode === "changes" ? (
|
||||
<div className="flex-1 overflow-y-auto min-h-[400px] max-h-[60vh]">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
featureId={featureId}
|
||||
compact={false}
|
||||
useWorktrees={useWorktrees}
|
||||
className="border-0 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -326,8 +326,8 @@ export function AnalysisView() {
|
||||
const analyzeStructure = () => {
|
||||
const structure: string[] = [];
|
||||
const topLevelDirs = projectAnalysis.fileTree
|
||||
.filter((n) => n.isDirectory)
|
||||
.map((n) => n.name);
|
||||
.filter((n: FileTreeNode) => n.isDirectory)
|
||||
.map((n: FileTreeNode) => n.name);
|
||||
|
||||
for (const dir of topLevelDirs) {
|
||||
structure.push(` <directory name="${dir}" />`);
|
||||
@@ -350,14 +350,14 @@ export function AnalysisView() {
|
||||
<technology_stack>
|
||||
<languages>
|
||||
${Object.entries(projectAnalysis.filesByExtension)
|
||||
.filter(([ext]) =>
|
||||
.filter(([ext]: [string, number]) =>
|
||||
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
|
||||
ext
|
||||
)
|
||||
)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([ext, count]) => ` <language ext=".${ext}" count="${count}" />`)
|
||||
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
|
||||
.join("\n")}
|
||||
</languages>
|
||||
<frameworks>
|
||||
@@ -375,10 +375,10 @@ ${analyzeStructure()}
|
||||
|
||||
<file_breakdown>
|
||||
${Object.entries(projectAnalysis.filesByExtension)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
([ext, count]) =>
|
||||
([ext, count]: [string, number]) =>
|
||||
` <extension type="${
|
||||
ext.startsWith("(") ? ext : "." + ext
|
||||
}" count="${count}" />`
|
||||
@@ -465,11 +465,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
const detectFeatures = () => {
|
||||
const extensions = projectAnalysis.filesByExtension;
|
||||
const topLevelDirs = projectAnalysis.fileTree
|
||||
.filter((n) => n.isDirectory)
|
||||
.map((n) => n.name.toLowerCase());
|
||||
.filter((n: FileTreeNode) => n.isDirectory)
|
||||
.map((n: FileTreeNode) => n.name.toLowerCase());
|
||||
const topLevelFiles = projectAnalysis.fileTree
|
||||
.filter((n) => !n.isDirectory)
|
||||
.map((n) => n.name.toLowerCase());
|
||||
.filter((n: FileTreeNode) => !n.isDirectory)
|
||||
.map((n: FileTreeNode) => n.name.toLowerCase());
|
||||
|
||||
// Check for test directories and files
|
||||
const hasTests =
|
||||
@@ -840,7 +840,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -964,9 +964,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(projectAnalysis.filesByExtension)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||
.slice(0, 15)
|
||||
.map(([ext, count]) => (
|
||||
.map(([ext, count]: [string, number]) => (
|
||||
<div key={ext} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{ext.startsWith("(") ? ext : `.${ext}`}
|
||||
@@ -1107,7 +1107,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
data-testid="analysis-file-tree"
|
||||
>
|
||||
<div className="p-2">
|
||||
{projectAnalysis.fileTree.map((node) => renderNode(node))}
|
||||
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,9 @@ import {
|
||||
FileText,
|
||||
MoreVertical,
|
||||
AlertCircle,
|
||||
GitBranch,
|
||||
Undo2,
|
||||
GitMerge,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
@@ -59,6 +62,12 @@ import {
|
||||
DEFAULT_MODEL,
|
||||
} from "@/lib/agent-context-parser";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
@@ -72,6 +81,8 @@ interface KanbanCardProps {
|
||||
onMoveBackToInProgress?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onCommit?: () => void;
|
||||
onRevert?: () => void;
|
||||
onMerge?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -93,6 +104,8 @@ export function KanbanCard({
|
||||
onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onCommit,
|
||||
onRevert,
|
||||
onMerge,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -101,9 +114,13 @@ export function KanbanCard({
|
||||
}: KanbanCardProps) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
|
||||
// Check if feature has worktree
|
||||
const hasWorktree = !!feature.branchName;
|
||||
|
||||
// Helper functions to check what should be shown based on detail level
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
@@ -196,7 +213,7 @@ export function KanbanCard({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative",
|
||||
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
|
||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
||||
isCurrentAutoTask &&
|
||||
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
|
||||
@@ -246,7 +263,43 @@ export function KanbanCard({
|
||||
<span>Errored</span>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="p-3 pb-2">
|
||||
{/* Branch badge - show when feature has a worktree */}
|
||||
{hasWorktree && !isCurrentAutoTask && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||
// Position below error badge if present, otherwise use normal position
|
||||
feature.error || feature.skipTests
|
||||
? "top-8 left-2"
|
||||
: shortcutKey
|
||||
? "top-2 left-10"
|
||||
: "top-2 left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||
<p className="font-mono text-xs break-all">{feature.branchName}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<CardHeader
|
||||
className={cn(
|
||||
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
||||
// Add extra top padding when badges are present to prevent text overlap
|
||||
(feature.skipTests || feature.error || shortcutKey) && "pt-10",
|
||||
// Add even more top padding when both badges and branch are shown
|
||||
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
|
||||
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
|
||||
@@ -320,11 +373,11 @@ export function KanbanCard({
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm leading-tight">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<CardTitle className="text-sm leading-tight break-words hyphens-auto line-clamp-3 overflow-hidden">
|
||||
{feature.description}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
<CardDescription className="text-xs mt-1 truncate">
|
||||
{feature.category}
|
||||
</CardDescription>
|
||||
</div>
|
||||
@@ -344,7 +397,7 @@ export function KanbanCard({
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{step}</span>
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
@@ -358,13 +411,13 @@ export function KanbanCard({
|
||||
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
|
||||
{/* Detailed mode: Show all agent info */}
|
||||
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
{/* Model & Phase */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
<div className="flex items-center gap-1 text-cyan-400">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
{formatModelName(DEFAULT_MODEL)}
|
||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||
</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
@@ -408,15 +461,15 @@ export function KanbanCard({
|
||||
) : todo.status === "in_progress" ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-zinc-500 shrink-0" />
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
"break-words hyphens-auto line-clamp-2 leading-relaxed",
|
||||
todo.status === "completed" &&
|
||||
"text-zinc-500 line-through",
|
||||
"text-muted-foreground line-through",
|
||||
todo.status === "in_progress" && "text-amber-400",
|
||||
todo.status === "pending" && "text-zinc-400"
|
||||
todo.status === "pending" && "text-foreground-secondary"
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
@@ -437,25 +490,25 @@ export function KanbanCard({
|
||||
feature.status === "verified") && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
<div className="space-y-1 pt-1 border-t border-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-[10px] text-green-400">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>Summary</span>
|
||||
<div className="space-y-1 pt-1 border-t border-border-glass overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-green-400 min-w-0">
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">Summary</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
className="p-0.5 rounded hover:bg-white/10 transition-colors text-zinc-500 hover:text-zinc-300"
|
||||
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
|
||||
title="View full summary"
|
||||
data-testid={`expand-summary-${feature.id}`}
|
||||
>
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 line-clamp-3">
|
||||
<p className="text-[10px] text-foreground-secondary line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
@@ -465,7 +518,7 @@ export function KanbanCard({
|
||||
!summary &&
|
||||
!agentInfo.summary &&
|
||||
agentInfo.toolCallCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-white/5">
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border-glass">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
@@ -609,24 +662,65 @@ export function KanbanCard({
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||
<>
|
||||
{/* Revert button - only show when worktree exists (icon only to save space) */}
|
||||
{hasWorktree && onRevert && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRevertDialogOpen(true);
|
||||
}}
|
||||
data-testid={`revert-${feature.id}`}
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Revert changes</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Follow-up prompt button */}
|
||||
{onFollowUp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
className="flex-1 h-7 text-xs min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFollowUp();
|
||||
}}
|
||||
data-testid={`follow-up-${feature.id}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
Follow-up
|
||||
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Follow-up</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Commit and verify button */}
|
||||
{onCommit && (
|
||||
{/* Merge button - only show when worktree exists */}
|
||||
{hasWorktree && onMerge && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700 min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMerge();
|
||||
}}
|
||||
data-testid={`merge-${feature.id}`}
|
||||
title="Merge changes into main branch"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Merge</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Commit and verify button - show when no worktree */}
|
||||
{!hasWorktree && onCommit && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -711,7 +805,7 @@ export function KanbanCard({
|
||||
: feature.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-zinc-900/50 rounded-lg border border-white/10">
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border">
|
||||
<Markdown>
|
||||
{feature.summary ||
|
||||
summary ||
|
||||
@@ -730,6 +824,49 @@ export function KanbanCard({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revert Confirmation Dialog */}
|
||||
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
|
||||
<DialogContent data-testid="revert-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-400">
|
||||
<Undo2 className="w-5 h-5" />
|
||||
Revert Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will discard all changes made by the agent and move the feature back to the backlog.
|
||||
{feature.branchName && (
|
||||
<span className="block mt-2 font-medium">
|
||||
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
|
||||
</span>
|
||||
)}
|
||||
<span className="block mt-2 text-red-400 font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsRevertDialogOpen(false)}
|
||||
data-testid="cancel-revert-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setIsRevertDialogOpen(false);
|
||||
onRevert?.();
|
||||
}}
|
||||
data-testid="confirm-revert-button"
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
Revert Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
691
app/src/components/views/profiles-view.tsx
Normal file
691
app/src/components/views/profiles-view.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
ACTION_SHORTCUTS,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
UserCircle,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
GripVertical,
|
||||
Lock,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// Icon mapping for profiles
|
||||
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
};
|
||||
|
||||
// Available icons for selection
|
||||
const ICON_OPTIONS = [
|
||||
{ name: "Brain", icon: Brain },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Scale", icon: Scale },
|
||||
{ name: "Cpu", icon: Cpu },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
];
|
||||
|
||||
// Model options for the form
|
||||
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "haiku", label: "Claude Haiku" },
|
||||
{ id: "sonnet", label: "Claude Sonnet" },
|
||||
{ id: "opus", label: "Claude Opus" },
|
||||
];
|
||||
|
||||
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.1", label: "GPT-5.1" },
|
||||
];
|
||||
|
||||
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "ultrathink", label: "Ultrathink" },
|
||||
];
|
||||
|
||||
// Helper to determine provider from model
|
||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
||||
if (model.startsWith("gpt")) {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
}
|
||||
|
||||
// Sortable Profile Card Component
|
||||
function SortableProfileCard({
|
||||
profile,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: AIProfile;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: profile.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
||||
const isCodex = profile.provider === "codex";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
|
||||
isDragging && "shadow-lg",
|
||||
profile.isBuiltIn
|
||||
? "border-border/50"
|
||||
: "border-border hover:border-primary/50 hover:shadow-sm"
|
||||
)}
|
||||
data-testid={`profile-card-${profile.id}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
|
||||
data-testid={`profile-drag-handle-${profile.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"w-5 h-5",
|
||||
isCodex ? "text-emerald-500" : "text-primary"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-foreground">{profile.name}</h3>
|
||||
{profile.isBuiltIn && (
|
||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
Built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{profile.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border",
|
||||
isCodex
|
||||
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "border-primary/30 text-primary bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{profile.model}
|
||||
</span>
|
||||
{profile.thinkingLevel !== "none" && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
|
||||
{profile.thinkingLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!profile.isBuiltIn && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid={`edit-profile-${profile.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
data-testid={`delete-profile-${profile.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile Form Component
|
||||
function ProfileForm({
|
||||
profile,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
}: {
|
||||
profile: Partial<AIProfile>;
|
||||
onSave: (profile: Omit<AIProfile, "id">) => void;
|
||||
onCancel: () => void;
|
||||
isEditing: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: profile.name || "",
|
||||
description: profile.description || "",
|
||||
model: profile.model || ("opus" as AgentModel),
|
||||
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
|
||||
icon: profile.icon || "Brain",
|
||||
});
|
||||
|
||||
const provider = getProviderFromModel(formData.model);
|
||||
const supportsThinking = modelSupportsThinking(formData.model);
|
||||
|
||||
const handleModelChange = (model: AgentModel) => {
|
||||
const newProvider = getProviderFromModel(model);
|
||||
setFormData({
|
||||
...formData,
|
||||
model,
|
||||
// Reset thinking level when switching to Codex (doesn't support thinking)
|
||||
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error("Please enter a profile name");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
model: formData.model,
|
||||
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
|
||||
provider,
|
||||
isBuiltIn: false,
|
||||
icon: formData.icon,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Heavy Task, Quick Fix"
|
||||
data-testid="profile-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-description">Description</Label>
|
||||
<Textarea
|
||||
id="profile-description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Describe when to use this profile..."
|
||||
rows={2}
|
||||
data-testid="profile-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Icon</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, icon: name })}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
|
||||
formData.icon === name
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`icon-select-${name}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Claude */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" />
|
||||
Claude Models
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleModelChange(id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.model === id
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`model-select-${id}`}
|
||||
>
|
||||
{label.replace("Claude ", "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - Codex */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-emerald-500" />
|
||||
Codex Models
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{CODEX_MODELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => handleModelChange(id)}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.model === id
|
||||
? "bg-emerald-600 text-white border-emerald-500"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`model-select-${id}`}
|
||||
>
|
||||
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level - Only for Claude models */}
|
||||
{supportsThinking && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-amber-500" />
|
||||
Thinking Level
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{THINKING_LEVELS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, thinkingLevel: id });
|
||||
if (id === "ultrathink") {
|
||||
toast.warning("Ultrathink uses extensive reasoning", {
|
||||
description:
|
||||
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||
formData.thinkingLevel === id
|
||||
? "bg-amber-500 text-white border-amber-400"
|
||||
: "bg-background hover:bg-accent border-input"
|
||||
)}
|
||||
data-testid={`thinking-select-${id}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher levels give more time to reason through complex problems.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<DialogFooter className="pt-4">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} data-testid="save-profile-button">
|
||||
{isEditing ? "Save Changes" : "Create Profile"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilesView() {
|
||||
const { aiProfiles, addAIProfile, updateAIProfile, removeAIProfile, reorderAIProfiles } =
|
||||
useAppStore();
|
||||
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
||||
|
||||
// Sensors for drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Separate built-in and custom profiles
|
||||
const builtInProfiles = useMemo(
|
||||
() => aiProfiles.filter((p) => p.isBuiltIn),
|
||||
[aiProfiles]
|
||||
);
|
||||
const customProfiles = useMemo(
|
||||
() => aiProfiles.filter((p) => !p.isBuiltIn),
|
||||
[aiProfiles]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
|
||||
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
reorderAIProfiles(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
[aiProfiles, reorderAIProfiles]
|
||||
);
|
||||
|
||||
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
|
||||
addAIProfile(profile);
|
||||
setShowAddDialog(false);
|
||||
toast.success("Profile created", {
|
||||
description: `Created "${profile.name}" profile`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
|
||||
if (editingProfile) {
|
||||
updateAIProfile(editingProfile.id, profile);
|
||||
setEditingProfile(null);
|
||||
toast.success("Profile updated", {
|
||||
description: `Updated "${profile.name}" profile`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProfile = (profile: AIProfile) => {
|
||||
if (profile.isBuiltIn) return;
|
||||
|
||||
removeAIProfile(profile.id);
|
||||
toast.success("Profile deleted", {
|
||||
description: `Deleted "${profile.name}" profile`,
|
||||
});
|
||||
};
|
||||
|
||||
// Build keyboard shortcuts for profiles view
|
||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||
const shortcuts: KeyboardShortcut[] = [];
|
||||
|
||||
// Add profile shortcut - when in profiles view
|
||||
shortcuts.push({
|
||||
key: ACTION_SHORTCUTS.addProfile,
|
||||
action: () => setShowAddDialog(true),
|
||||
description: "Create new profile",
|
||||
});
|
||||
|
||||
return shortcuts;
|
||||
}, []);
|
||||
|
||||
// Register keyboard shortcuts for profiles view
|
||||
useKeyboardShortcuts(profilesShortcuts);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="profiles-view"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||
<UserCircle className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
AI Profiles
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create and manage model configuration presets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)} data-testid="add-profile-button" className="relative">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Profile
|
||||
<span className="hidden lg:flex items-center justify-center ml-2 px-2 py-0.5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500">
|
||||
{ACTION_SHORTCUTS.addProfile}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Custom Profiles Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Custom Profiles
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{customProfiles.length}
|
||||
</span>
|
||||
</div>
|
||||
{customProfiles.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
No custom profiles yet. Create one to get started!
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={customProfiles.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{customProfiles.map((profile) => (
|
||||
<SortableProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => setEditingProfile(profile)}
|
||||
onDelete={() => handleDeleteProfile(profile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Built-in Profiles Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Built-in Profiles
|
||||
</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{builtInProfiles.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-configured profiles for common use cases. These cannot be
|
||||
edited or deleted.
|
||||
</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={builtInProfiles.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{builtInProfiles.map((profile) => (
|
||||
<SortableProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent data-testid="add-profile-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a reusable model configuration preset.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProfileForm
|
||||
profile={{}}
|
||||
onSave={handleAddProfile}
|
||||
onCancel={() => setShowAddDialog(false)}
|
||||
isEditing={false}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Profile Dialog */}
|
||||
<Dialog
|
||||
open={!!editingProfile}
|
||||
onOpenChange={() => setEditingProfile(null)}
|
||||
>
|
||||
<DialogContent data-testid="edit-profile-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Modify your profile settings.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingProfile && (
|
||||
<ProfileForm
|
||||
profile={editingProfile}
|
||||
onSave={handleUpdateProfile}
|
||||
onCancel={() => setEditingProfile(null)}
|
||||
isEditing={true}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,7 +275,8 @@ export function useElectronAgent({
|
||||
setIsProcessing(false);
|
||||
setError(event.error);
|
||||
if (event.message) {
|
||||
setMessages((prev) => [...prev, event.message]);
|
||||
const errorMessage = event.message;
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -409,5 +410,8 @@ export function useElectronAgent({
|
||||
stopExecution,
|
||||
clearHistory,
|
||||
error,
|
||||
queuedMessages,
|
||||
isQueueProcessing: isProcessingQueue,
|
||||
clearMessageQueue: clearQueue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const NAV_SHORTCUTS: Record<string, string> = {
|
||||
context: "C", // C for Context
|
||||
tools: "T", // T for Tools
|
||||
settings: "S", // S for Settings
|
||||
profiles: "M", // M for Models/profiles
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -127,4 +128,5 @@ export const ACTION_SHORTCUTS: Record<string, string> = {
|
||||
projectPicker: "P", // P for Project picker
|
||||
cyclePrevProject: "Q", // Q for previous project (cycle back through MRU)
|
||||
cycleNextProject: "E", // E for next project (cycle forward through MRU)
|
||||
addProfile: "N", // N for New profile (when in profiles view)
|
||||
};
|
||||
|
||||
54
app/src/hooks/use-window-state.ts
Normal file
54
app/src/hooks/use-window-state.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface WindowState {
|
||||
isMaximized: boolean;
|
||||
windowWidth: number;
|
||||
windowHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track window state (dimensions and maximized status)
|
||||
* For Electron apps, considers window maximized if width > 1400px
|
||||
* Also listens for window resize events to update state
|
||||
*/
|
||||
export function useWindowState(): WindowState {
|
||||
const [windowState, setWindowState] = useState<WindowState>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return { isMaximized: false, windowWidth: 0, windowHeight: 0 };
|
||||
}
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
return {
|
||||
isMaximized: width > 1400,
|
||||
windowWidth: width,
|
||||
windowHeight: height,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const updateWindowState = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
setWindowState({
|
||||
isMaximized: width > 1400,
|
||||
windowWidth: width,
|
||||
windowHeight: height,
|
||||
});
|
||||
};
|
||||
|
||||
// Set initial state
|
||||
updateWindowState();
|
||||
|
||||
// Listen for resize events
|
||||
window.addEventListener("resize", updateWindowState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWindowState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return windowState;
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export const DEFAULT_MODEL = "claude-opus-4-5-20251101";
|
||||
*/
|
||||
export function formatModelName(model: string): string {
|
||||
if (model.includes("opus")) return "Opus 4.5";
|
||||
if (model.includes("sonnet")) return "Sonnet 4";
|
||||
if (model.includes("haiku")) return "Haiku 3.5";
|
||||
if (model.includes("sonnet")) return "Sonnet 4.5";
|
||||
if (model.includes("haiku")) return "Haiku 4.5";
|
||||
return model.split("-").slice(1, 3).join(" ");
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,8 @@ export interface StatResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Auto Mode types
|
||||
export type AutoModePhase = "planning" | "action" | "verification";
|
||||
|
||||
export interface AutoModeEvent {
|
||||
type: "auto_mode_feature_start" | "auto_mode_progress" | "auto_mode_tool" | "auto_mode_feature_complete" | "auto_mode_error" | "auto_mode_complete" | "auto_mode_phase";
|
||||
featureId?: string;
|
||||
projectId?: string;
|
||||
feature?: object;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
input?: unknown;
|
||||
passes?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
phase?: AutoModePhase;
|
||||
}
|
||||
// Auto Mode types - Import from electron.d.ts to avoid duplication
|
||||
import type { AutoModeEvent, ModelDefinition, ProviderStatus, WorktreeAPI, GitAPI, WorktreeInfo, WorktreeStatus, FileDiffsResult, FileDiffResult, FileStatus } from "@/types/electron";
|
||||
|
||||
// Feature Suggestions types
|
||||
export interface FeatureSuggestion {
|
||||
@@ -104,7 +90,7 @@ export interface AutoModeAPI {
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
status: () => Promise<{ success: boolean; isRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; error?: string }>;
|
||||
runFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||
verifyFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||
resumeFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
|
||||
contextExists: (projectPath: string, featureId: string) => Promise<{ success: boolean; exists?: boolean; error?: string }>;
|
||||
@@ -133,19 +119,63 @@ export interface ElectronAPI {
|
||||
deleteFile: (filePath: string) => Promise<WriteResult>;
|
||||
trashItem?: (filePath: string) => Promise<WriteResult>;
|
||||
getPath: (name: string) => Promise<string>;
|
||||
saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>;
|
||||
autoMode?: AutoModeAPI;
|
||||
saveImageToTemp?: (data: string, filename: string, mimeType: string, projectPath?: string) => Promise<SaveImageResult>;
|
||||
checkClaudeCli?: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
checkCodexCli?: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
model?: {
|
||||
getAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
error?: string;
|
||||
}>;
|
||||
checkProviders: () => Promise<{
|
||||
success: boolean;
|
||||
providers?: Record<string, ProviderStatus>;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
testOpenAIConnection?: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
worktree?: WorktreeAPI;
|
||||
git?: GitAPI;
|
||||
suggestions?: SuggestionsAPI;
|
||||
specRegeneration?: SpecRegenerationAPI;
|
||||
}
|
||||
|
||||
// Augment global Window interface
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI | undefined;
|
||||
isElectron: boolean | undefined;
|
||||
}
|
||||
}
|
||||
// Note: Window interface is declared in @/types/electron.d.ts
|
||||
// Do not redeclare here to avoid type conflicts
|
||||
|
||||
// Mock data for web development
|
||||
const mockFeatures = [
|
||||
@@ -394,12 +424,13 @@ export const getElectronAPI = (): ElectronAPI => {
|
||||
},
|
||||
|
||||
// Save image to temp directory
|
||||
saveImageToTemp: async (data: string, filename: string, mimeType: string) => {
|
||||
// Generate a mock temp file path
|
||||
saveImageToTemp: async (data: string, filename: string, mimeType: string, projectPath?: string) => {
|
||||
// Generate a mock temp file path - use projectPath if provided
|
||||
const timestamp = Date.now();
|
||||
const ext = mimeType.split("/")[1] || "png";
|
||||
const safeName = filename.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const tempFilePath = `/tmp/automaker-images/${timestamp}_${safeName}`;
|
||||
const tempFilePath = projectPath
|
||||
? `${projectPath}/.automaker/images/${timestamp}_${safeName}`
|
||||
: `/tmp/automaker-images/${timestamp}_${safeName}`;
|
||||
|
||||
// Store the image data in mock file system for testing
|
||||
mockFileSystem[tempFilePath] = data;
|
||||
@@ -408,9 +439,37 @@ export const getElectronAPI = (): ElectronAPI => {
|
||||
return { success: true, path: tempFilePath };
|
||||
},
|
||||
|
||||
checkClaudeCli: async () => ({
|
||||
success: false,
|
||||
status: "not_installed",
|
||||
recommendation: "Claude CLI checks are unavailable in the web preview.",
|
||||
}),
|
||||
|
||||
checkCodexCli: async () => ({
|
||||
success: false,
|
||||
status: "not_installed",
|
||||
recommendation: "Codex CLI checks are unavailable in the web preview.",
|
||||
}),
|
||||
|
||||
model: {
|
||||
getAvailable: async () => ({ success: true, models: [] }),
|
||||
checkProviders: async () => ({ success: true, providers: {} }),
|
||||
},
|
||||
|
||||
testOpenAIConnection: async () => ({
|
||||
success: false,
|
||||
error: "OpenAI connection test is only available in the Electron app.",
|
||||
}),
|
||||
|
||||
// Mock Auto Mode API
|
||||
autoMode: createMockAutoModeAPI(),
|
||||
|
||||
// Mock Worktree API
|
||||
worktree: createMockWorktreeAPI(),
|
||||
|
||||
// Mock Git API (for non-worktree operations)
|
||||
git: createMockGitAPI(),
|
||||
|
||||
// Mock Suggestions API
|
||||
suggestions: createMockSuggestionsAPI(),
|
||||
|
||||
@@ -419,6 +478,99 @@ export const getElectronAPI = (): ElectronAPI => {
|
||||
};
|
||||
};
|
||||
|
||||
// Mock Worktree API implementation
|
||||
function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return {
|
||||
revertFeature: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Reverting feature:", { projectPath, featureId });
|
||||
return { success: true, removedPath: `/mock/worktree/${featureId}` };
|
||||
},
|
||||
|
||||
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
|
||||
console.log("[Mock] Merging feature:", { projectPath, featureId, options });
|
||||
return { success: true, mergedBranch: `feature/${featureId}` };
|
||||
},
|
||||
|
||||
getInfo: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting worktree info:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
worktreePath: `/mock/worktrees/${featureId}`,
|
||||
branchName: `feature/${featureId}`,
|
||||
head: "abc1234",
|
||||
};
|
||||
},
|
||||
|
||||
getStatus: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting worktree status:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
modifiedFiles: 3,
|
||||
files: ["src/feature.ts", "tests/feature.spec.ts", "README.md"],
|
||||
diffStat: " 3 files changed, 50 insertions(+), 10 deletions(-)",
|
||||
recentCommits: [
|
||||
"abc1234 feat: implement feature",
|
||||
"def5678 test: add tests for feature",
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
list: async (projectPath: string) => {
|
||||
console.log("[Mock] Listing worktrees:", { projectPath });
|
||||
return { success: true, worktrees: [] };
|
||||
},
|
||||
|
||||
getDiffs: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting file diffs:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
|
||||
files: [
|
||||
{ status: "A", path: "src/feature.ts", statusText: "Added" },
|
||||
{ status: "M", path: "README.md", statusText: "Modified" },
|
||||
],
|
||||
hasChanges: true,
|
||||
};
|
||||
},
|
||||
|
||||
getFileDiff: async (projectPath: string, featureId: string, filePath: string) => {
|
||||
console.log("[Mock] Getting file diff:", { projectPath, featureId, filePath });
|
||||
return {
|
||||
success: true,
|
||||
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
|
||||
filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Git API implementation (for non-worktree operations)
|
||||
function createMockGitAPI(): GitAPI {
|
||||
return {
|
||||
getDiffs: async (projectPath: string) => {
|
||||
console.log("[Mock] Getting git diffs for project:", { projectPath });
|
||||
return {
|
||||
success: true,
|
||||
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
|
||||
files: [
|
||||
{ status: "A", path: "src/feature.ts", statusText: "Added" },
|
||||
{ status: "M", path: "README.md", statusText: "Modified" },
|
||||
],
|
||||
hasChanges: true,
|
||||
};
|
||||
},
|
||||
|
||||
getFileDiff: async (projectPath: string, filePath: string) => {
|
||||
console.log("[Mock] Getting git file diff:", { projectPath, filePath });
|
||||
return {
|
||||
success: true,
|
||||
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
|
||||
filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Auto Mode state and implementation
|
||||
let mockAutoModeRunning = false;
|
||||
let mockRunningFeatures = new Set<string>(); // Track multiple concurrent feature verifications
|
||||
@@ -487,11 +639,12 @@ function createMockAutoModeAPI(): AutoModeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
runFeature: async (projectPath: string, featureId: string) => {
|
||||
runFeature: async (projectPath: string, featureId: string, useWorktrees?: boolean) => {
|
||||
if (mockRunningFeatures.has(featureId)) {
|
||||
return { success: false, error: `Feature ${featureId} is already running` };
|
||||
}
|
||||
|
||||
console.log(`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}`);
|
||||
mockRunningFeatures.add(featureId);
|
||||
simulateAutoModeLoop(projectPath, featureId);
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ export type LogEntryType =
|
||||
| "success"
|
||||
| "info"
|
||||
| "debug"
|
||||
| "warning";
|
||||
| "warning"
|
||||
| "thinking";
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
@@ -28,7 +29,27 @@ export interface LogEntry {
|
||||
};
|
||||
}
|
||||
|
||||
const generateId = () => Math.random().toString(36).substring(2, 9);
|
||||
/**
|
||||
* Generates a deterministic ID based on content and position
|
||||
* This ensures the same log entry always gets the same ID,
|
||||
* preserving expanded/collapsed state when new logs stream in
|
||||
*
|
||||
* Uses only the first 200 characters of content to ensure stability
|
||||
* even when entries are merged (which appends content at the end)
|
||||
*/
|
||||
const generateDeterministicId = (content: string, lineIndex: number): string => {
|
||||
// Use first 200 chars to ensure stability when entries are merged
|
||||
const stableContent = content.slice(0, 200);
|
||||
// Simple hash function for the content
|
||||
let hash = 0;
|
||||
const str = stableContent + '|' + lineIndex.toString();
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return 'log_' + Math.abs(hash).toString(36);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the type of log entry based on content patterns
|
||||
@@ -75,6 +96,18 @@ function detectEntryType(content: string): LogEntryType {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
// Thinking/Preparation info
|
||||
if (
|
||||
trimmed.toLowerCase().includes("ultrathink") ||
|
||||
trimmed.toLowerCase().includes("thinking level") ||
|
||||
trimmed.toLowerCase().includes("estimated cost") ||
|
||||
trimmed.toLowerCase().includes("estimated time") ||
|
||||
trimmed.toLowerCase().includes("budget tokens") ||
|
||||
trimmed.match(/thinking.*preparation/i)
|
||||
) {
|
||||
return "thinking";
|
||||
}
|
||||
|
||||
// Debug info (JSON, stack traces, etc.)
|
||||
if (
|
||||
trimmed.startsWith("{") ||
|
||||
@@ -130,6 +163,8 @@ function generateTitle(type: LogEntryType, content: string): string {
|
||||
return "Success";
|
||||
case "warning":
|
||||
return "Warning";
|
||||
case "thinking":
|
||||
return "Thinking Level";
|
||||
case "debug":
|
||||
return "Debug Info";
|
||||
case "prompt":
|
||||
@@ -150,24 +185,32 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
const entries: LogEntry[] = [];
|
||||
const lines = rawOutput.split("\n");
|
||||
|
||||
let currentEntry: LogEntry | null = null;
|
||||
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
|
||||
let currentContent: string[] = [];
|
||||
let entryStartLine = 0; // Track the starting line for deterministic ID generation
|
||||
|
||||
const finalizeEntry = () => {
|
||||
if (currentEntry && currentContent.length > 0) {
|
||||
currentEntry.content = currentContent.join("\n").trim();
|
||||
if (currentEntry.content) {
|
||||
entries.push(currentEntry);
|
||||
// Generate deterministic ID based on content and position
|
||||
const entryWithId: LogEntry = {
|
||||
...currentEntry as Omit<LogEntry, 'id'>,
|
||||
id: generateDeterministicId(currentEntry.content, entryStartLine),
|
||||
};
|
||||
entries.push(entryWithId);
|
||||
}
|
||||
}
|
||||
currentContent = [];
|
||||
};
|
||||
|
||||
let lineIndex = 0;
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines at the beginning
|
||||
if (!trimmedLine && !currentEntry) {
|
||||
lineIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -180,15 +223,20 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
trimmedLine.startsWith("✅") ||
|
||||
trimmedLine.startsWith("❌") ||
|
||||
trimmedLine.startsWith("⚠️") ||
|
||||
trimmedLine.startsWith("🧠") ||
|
||||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
|
||||
trimmedLine.toLowerCase().includes("thinking level") ||
|
||||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
|
||||
|
||||
if (isNewEntry) {
|
||||
// Finalize previous entry
|
||||
finalizeEntry();
|
||||
|
||||
// Start new entry
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// Start new entry (ID will be generated when finalizing)
|
||||
currentEntry = {
|
||||
id: generateId(),
|
||||
type: lineType,
|
||||
title: generateTitle(lineType, trimmedLine),
|
||||
content: "",
|
||||
@@ -202,15 +250,18 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
|
||||
// Continue current entry
|
||||
currentContent.push(line);
|
||||
} else {
|
||||
// Track starting line for deterministic ID
|
||||
entryStartLine = lineIndex;
|
||||
|
||||
// No current entry, create a default info entry
|
||||
currentEntry = {
|
||||
id: generateId(),
|
||||
type: "info",
|
||||
title: "Info",
|
||||
content: "",
|
||||
};
|
||||
currentContent.push(line);
|
||||
}
|
||||
lineIndex++;
|
||||
}
|
||||
|
||||
// Finalize last entry
|
||||
@@ -230,6 +281,7 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
|
||||
const merged: LogEntry[] = [];
|
||||
let current: LogEntry | null = null;
|
||||
let mergeIndex = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
@@ -237,13 +289,15 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
|
||||
(current.type === "debug" || current.type === "info") &&
|
||||
current.type === entry.type
|
||||
) {
|
||||
// Merge into current
|
||||
// Merge into current - regenerate ID based on merged content
|
||||
current.content += "\n\n" + entry.content;
|
||||
current.id = generateDeterministicId(current.content, mergeIndex);
|
||||
} else {
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
}
|
||||
current = { ...entry };
|
||||
mergeIndex = merged.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +375,14 @@ export function getLogTypeColors(type: LogEntryType): {
|
||||
icon: "text-orange-400",
|
||||
badge: "bg-orange-500/20 text-orange-300",
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
bg: "bg-indigo-500/10",
|
||||
border: "border-l-indigo-500",
|
||||
text: "text-indigo-300",
|
||||
icon: "text-indigo-400",
|
||||
badge: "bg-indigo-500/20 text-indigo-300",
|
||||
};
|
||||
case "debug":
|
||||
return {
|
||||
bg: "bg-primary/10",
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { AgentModel } from "@/store/app-store"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
|
||||
*/
|
||||
export function isCodexModel(model?: AgentModel | string): boolean {
|
||||
if (!model) return false;
|
||||
const codexModels: string[] = [
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1",
|
||||
];
|
||||
return codexModels.includes(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current model supports extended thinking controls
|
||||
*/
|
||||
export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
||||
if (!model) return true;
|
||||
return !isCodexModel(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
*/
|
||||
export function getModelDisplayName(model: AgentModel | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
haiku: "Claude Haiku",
|
||||
sonnet: "Claude Sonnet",
|
||||
opus: "Claude Opus",
|
||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||
"gpt-5.1-codex": "GPT-5.1 Codex",
|
||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||
"gpt-5.1": "GPT-5.1",
|
||||
};
|
||||
return displayNames[model] || model;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ export type ViewMode =
|
||||
| "settings"
|
||||
| "tools"
|
||||
| "interview"
|
||||
| "context";
|
||||
| "context"
|
||||
| "profiles";
|
||||
|
||||
export type ThemeMode =
|
||||
| "light"
|
||||
@@ -32,6 +33,7 @@ export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||
export interface ApiKeys {
|
||||
anthropic: string;
|
||||
google: string;
|
||||
openai: string;
|
||||
}
|
||||
|
||||
export interface ImageAttachment {
|
||||
@@ -75,6 +77,36 @@ export interface FeatureImagePath {
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// Available models for feature execution
|
||||
// Claude models
|
||||
export type ClaudeModel = "opus" | "sonnet" | "haiku";
|
||||
// OpenAI/Codex models
|
||||
export type OpenAIModel =
|
||||
| "gpt-5.1-codex-max"
|
||||
| "gpt-5.1-codex"
|
||||
| "gpt-5.1-codex-mini"
|
||||
| "gpt-5.1";
|
||||
// Combined model type
|
||||
export type AgentModel = ClaudeModel | OpenAIModel;
|
||||
|
||||
// Model provider type
|
||||
export type ModelProvider = "claude" | "codex";
|
||||
|
||||
// Thinking level (budget_tokens) options
|
||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||
|
||||
// AI Provider Profile - user-defined presets for model configurations
|
||||
export interface AIProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: AgentModel;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
provider: ModelProvider;
|
||||
isBuiltIn: boolean; // Built-in profiles cannot be deleted
|
||||
icon?: string; // Optional icon name from lucide
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
category: string;
|
||||
@@ -86,7 +118,30 @@ export interface Feature {
|
||||
startedAt?: string; // ISO timestamp for when the card moved to in_progress
|
||||
skipTests?: boolean; // When true, skip TDD approach and require manual verification
|
||||
summary?: string; // Summary of what was done/modified by the agent
|
||||
model?: AgentModel; // Model to use for this feature (defaults to opus)
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
|
||||
error?: string; // Error message if the agent errored during processing
|
||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||
worktreePath?: string; // Path to the worktree directory
|
||||
branchName?: string; // Name of the feature branch
|
||||
}
|
||||
|
||||
// File tree node for project analysis
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
extension?: string;
|
||||
children?: FileTreeNode[];
|
||||
}
|
||||
|
||||
// Project analysis result
|
||||
export interface ProjectAnalysis {
|
||||
fileTree: FileTreeNode[];
|
||||
totalFiles: number;
|
||||
totalDirectories: number;
|
||||
filesByExtension: Record<string, number>;
|
||||
analyzedAt: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
@@ -137,6 +192,19 @@ export interface AppState {
|
||||
|
||||
// Feature Default Settings
|
||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||
|
||||
// Worktree Settings
|
||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
||||
|
||||
// AI Profiles
|
||||
aiProfiles: AIProfile[];
|
||||
|
||||
// Profile Display Settings
|
||||
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
}
|
||||
|
||||
export interface AutoModeActivity {
|
||||
@@ -226,6 +294,23 @@ export interface AppActions {
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip: boolean) => void;
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled: boolean) => void;
|
||||
|
||||
// Profile Display Settings actions
|
||||
setShowProfilesOnly: (enabled: boolean) => void;
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
|
||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||
removeAIProfile: (id: string) => void;
|
||||
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||
setIsAnalyzing: (analyzing: boolean) => void;
|
||||
clearAnalysis: () => void;
|
||||
|
||||
// Agent Session actions
|
||||
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
|
||||
getLastSelectedSession: (projectPath: string) => string | null;
|
||||
@@ -234,6 +319,60 @@ export interface AppActions {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// Default built-in AI profiles
|
||||
const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
{
|
||||
id: "profile-heavy-task",
|
||||
name: "Heavy Task",
|
||||
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||
model: "opus",
|
||||
thinkingLevel: "ultrathink",
|
||||
provider: "claude",
|
||||
isBuiltIn: true,
|
||||
icon: "Brain",
|
||||
},
|
||||
{
|
||||
id: "profile-balanced",
|
||||
name: "Balanced",
|
||||
description: "Claude Sonnet with medium thinking for typical development tasks.",
|
||||
model: "sonnet",
|
||||
thinkingLevel: "medium",
|
||||
provider: "claude",
|
||||
isBuiltIn: true,
|
||||
icon: "Scale",
|
||||
},
|
||||
{
|
||||
id: "profile-quick-edit",
|
||||
name: "Quick Edit",
|
||||
description: "Claude Haiku for fast, simple edits and minor fixes.",
|
||||
model: "haiku",
|
||||
thinkingLevel: "none",
|
||||
provider: "claude",
|
||||
isBuiltIn: true,
|
||||
icon: "Zap",
|
||||
},
|
||||
{
|
||||
id: "profile-codex-power",
|
||||
name: "Codex Power",
|
||||
description: "GPT-5.1 Codex Max for deep coding tasks via OpenAI CLI.",
|
||||
model: "gpt-5.1-codex-max",
|
||||
thinkingLevel: "none",
|
||||
provider: "codex",
|
||||
isBuiltIn: true,
|
||||
icon: "Cpu",
|
||||
},
|
||||
{
|
||||
id: "profile-codex-fast",
|
||||
name: "Codex Fast",
|
||||
description: "GPT-5.1 Codex Mini for lightweight and quick edits.",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingLevel: "none",
|
||||
provider: "codex",
|
||||
isBuiltIn: true,
|
||||
icon: "Rocket",
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: AppState = {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
@@ -250,6 +389,7 @@ const initialState: AppState = {
|
||||
apiKeys: {
|
||||
anthropic: "",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
chatSessions: [],
|
||||
currentChatSession: null,
|
||||
@@ -259,6 +399,11 @@ const initialState: AppState = {
|
||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
||||
defaultSkipTests: false, // Default to TDD mode (tests enabled)
|
||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||
showProfilesOnly: false, // Default to showing all options (not profiles only)
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
@@ -722,6 +867,48 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||
|
||||
// Profile Display Settings actions
|
||||
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
|
||||
|
||||
// AI Profile actions
|
||||
addAIProfile: (profile) => {
|
||||
const id = `profile-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] });
|
||||
},
|
||||
|
||||
updateAIProfile: (id, updates) => {
|
||||
set({
|
||||
aiProfiles: get().aiProfiles.map((p) =>
|
||||
p.id === id ? { ...p, ...updates } : p
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
removeAIProfile: (id) => {
|
||||
// Only allow removing non-built-in profiles
|
||||
const profile = get().aiProfiles.find((p) => p.id === id);
|
||||
if (profile && !profile.isBuiltIn) {
|
||||
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
|
||||
}
|
||||
},
|
||||
|
||||
reorderAIProfiles: (oldIndex, newIndex) => {
|
||||
const profiles = [...get().aiProfiles];
|
||||
const [movedProfile] = profiles.splice(oldIndex, 1);
|
||||
profiles.splice(newIndex, 0, movedProfile);
|
||||
set({ aiProfiles: profiles });
|
||||
},
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
||||
clearAnalysis: () => set({ projectAnalysis: null }),
|
||||
|
||||
// Agent Session actions
|
||||
setLastSelectedSession: (projectPath, sessionId) => {
|
||||
const current = get().lastSelectedSessionByProject;
|
||||
@@ -742,7 +929,6 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
getLastSelectedSession: (projectPath) => {
|
||||
return get().lastSelectedSessionByProject[projectPath] || null;
|
||||
},
|
||||
|
||||
// Reset
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
@@ -763,6 +949,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
aiProfiles: state.aiProfiles,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
}),
|
||||
}
|
||||
|
||||
195
app/src/types/electron.d.ts
vendored
195
app/src/types/electron.d.ts
vendored
@@ -202,6 +202,14 @@ export type AutoModeEvent =
|
||||
projectId?: string;
|
||||
phase: "planning" | "action" | "verification";
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "auto_mode_ultrathink_preparation";
|
||||
featureId: string;
|
||||
warnings: string[];
|
||||
recommendations: string[];
|
||||
estimatedCost?: number;
|
||||
estimatedTime?: string;
|
||||
};
|
||||
|
||||
export type SpecRegenerationEvent =
|
||||
@@ -279,7 +287,7 @@ export interface AutoModeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
runFeature: (projectPath: string, featureId: string) => Promise<{
|
||||
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
passes?: boolean;
|
||||
error?: string;
|
||||
@@ -370,6 +378,10 @@ export interface ElectronAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
deleteFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// App APIs
|
||||
getPath: (name: string) => Promise<string>;
|
||||
@@ -393,10 +405,191 @@ export interface ElectronAPI {
|
||||
// Auto Mode APIs
|
||||
autoMode: AutoModeAPI;
|
||||
|
||||
// Claude CLI Detection API
|
||||
checkClaudeCli: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Codex CLI Detection API
|
||||
checkCodexCli: () => Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Model Management APIs
|
||||
model: {
|
||||
// Get all available models from all providers
|
||||
getAvailable: () => Promise<{
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Check all provider installation status
|
||||
checkProviders: () => Promise<{
|
||||
success: boolean;
|
||||
providers?: Record<string, ProviderStatus>;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// OpenAI API
|
||||
testOpenAIConnection: (apiKey?: string) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Worktree Management APIs
|
||||
worktree: WorktreeAPI;
|
||||
|
||||
// Git Operations APIs (for non-worktree operations)
|
||||
git: GitAPI;
|
||||
|
||||
// Spec Regeneration APIs
|
||||
specRegeneration: SpecRegenerationAPI;
|
||||
}
|
||||
|
||||
export interface WorktreeInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
head?: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeStatus {
|
||||
success: boolean;
|
||||
modifiedFiles?: number;
|
||||
files?: string[];
|
||||
diffStat?: string;
|
||||
recentCommits?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface FileDiffsResult {
|
||||
success: boolean;
|
||||
diff?: string;
|
||||
files?: FileStatus[];
|
||||
hasChanges?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileDiffResult {
|
||||
success: boolean;
|
||||
diff?: string;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeAPI {
|
||||
// Revert feature changes by removing the worktree
|
||||
revertFeature: (projectPath: string, featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
removedPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: {
|
||||
squash?: boolean;
|
||||
commitMessage?: string;
|
||||
squashMessage?: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
mergedBranch?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get worktree info for a feature
|
||||
getInfo: (projectPath: string, featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
worktreePath?: string;
|
||||
branchName?: string;
|
||||
head?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get worktree status (changed files, commits)
|
||||
getStatus: (projectPath: string, featureId: string) => Promise<WorktreeStatus>;
|
||||
|
||||
// List all feature worktrees
|
||||
list: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
worktrees?: WorktreeInfo[];
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get file diffs for a feature worktree
|
||||
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
|
||||
|
||||
// Get diff for a specific file in a worktree
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) => Promise<FileDiffResult>;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
// Get diffs for the main project (not a worktree)
|
||||
getDiffs: (projectPath: string) => Promise<FileDiffsResult>;
|
||||
|
||||
// Get diff for a specific file in the main project
|
||||
getFileDiff: (projectPath: string, filePath: string) => Promise<FileDiffResult>;
|
||||
}
|
||||
|
||||
// Model definition type
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
modelString: string;
|
||||
provider: "claude" | "codex";
|
||||
description?: string;
|
||||
tier?: "basic" | "standard" | "premium";
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
// Provider status type
|
||||
export interface ProviderStatus {
|
||||
status: "installed" | "not_installed" | "api_key_only";
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
|
||||
Reference in New Issue
Block a user