mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
Merge branch 'main' of github.com:webdevcody/automaker
This commit is contained in:
@@ -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