mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge branch 'main' into various-improvements
This commit is contained in:
@@ -358,5 +358,38 @@
|
|||||||
"skipTests": true,
|
"skipTests": true,
|
||||||
"model": "opus",
|
"model": "opus",
|
||||||
"thinkingLevel": "none"
|
"thinkingLevel": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "feature-1765395197833-hkxty2nb9",
|
||||||
|
"category": "Uncategorized",
|
||||||
|
"description": "on the agent runner chat window, style the text to match the theme primary or secondary, restyle it to match the selected theme",
|
||||||
|
"steps": [],
|
||||||
|
"status": "waiting_approval",
|
||||||
|
"startedAt": "2025-12-10T19:33:18.742Z",
|
||||||
|
"imagePaths": [
|
||||||
|
{
|
||||||
|
"id": "img-1765395175327-vdj77vwtb",
|
||||||
|
"path": "/Users/webdevcody/Workspace/automaker/.automaker/images/1765395175325-plp2txel3_Screenshot_2025-12-10_at_2.32.52_PM.png",
|
||||||
|
"filename": "Screenshot 2025-12-10 at 2.32.52 PM.png",
|
||||||
|
"mimeType": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skipTests": true,
|
||||||
|
"summary": "Styled agent chat messages to use theme primary colors. Modified: agent-view.tsx, interview-view.tsx. Assistant messages now have border-l-4 border-primary, text-primary for content and timestamps. Loading/thinking indicators also styled with theme colors.",
|
||||||
|
"model": "opus",
|
||||||
|
"thinkingLevel": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "feature-1765395217816-22cwdpnu9",
|
||||||
|
"category": "Uncategorized",
|
||||||
|
"description": "the add feature shortcut on the add new feature modal does not work anymore, please fix it",
|
||||||
|
"steps": [],
|
||||||
|
"status": "waiting_approval",
|
||||||
|
"startedAt": "2025-12-10T19:35:54.453Z",
|
||||||
|
"imagePaths": [],
|
||||||
|
"skipTests": true,
|
||||||
|
"summary": "Fixed Cmd+Enter shortcut not working in Add Feature modal when input is focused. Modified: hotkey-button.tsx - Changed logic to allow cmdCtrl modifier shortcuts even when typing in input fields, since they are intentional submit actions.",
|
||||||
|
"model": "opus",
|
||||||
|
"thinkingLevel": "none"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -5,6 +5,7 @@ This file documents issues encountered by previous agents and their solutions. R
|
|||||||
## Testing Issues
|
## Testing Issues
|
||||||
|
|
||||||
### Issue: Mock project setup not navigating to board view
|
### Issue: Mock project setup not navigating to board view
|
||||||
|
|
||||||
**Problem:** Setting `currentProject` in localStorage didn't automatically show the board view - app stayed on welcome view.
|
**Problem:** Setting `currentProject` in localStorage didn't automatically show the board view - app stayed on welcome view.
|
||||||
**Fix:** The `currentView` state is not persisted in localStorage. Instead of trying to set it, have tests click on the recent project from the welcome view to trigger `setCurrentProject()` which handles the view transition properly.
|
**Fix:** The `currentView` state is not persisted in localStorage. Instead of trying to set it, have tests click on the recent project from the welcome view to trigger `setCurrentProject()` which handles the view transition properly.
|
||||||
|
|
||||||
@@ -18,14 +19,18 @@ await waitForElement(page, "board-view"); // ❌ Fails - still on welcome view
|
|||||||
await setupMockProject(page);
|
await setupMockProject(page);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await waitForElement(page, "welcome-view");
|
await waitForElement(page, "welcome-view");
|
||||||
const recentProject = page.locator('[data-testid="recent-project-test-project-1"]');
|
const recentProject = page.locator(
|
||||||
|
'[data-testid="recent-project-test-project-1"]'
|
||||||
|
);
|
||||||
await recentProject.click(); // ✅ Triggers proper view transition
|
await recentProject.click(); // ✅ Triggers proper view transition
|
||||||
await waitForElement(page, "board-view");
|
await waitForElement(page, "board-view");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issue: View output button test IDs are conditional
|
### Issue: View output button test IDs are conditional
|
||||||
|
|
||||||
**Problem:** Tests failed looking for `view-output-inprogress-${featureId}` when the actual button had `view-output-${featureId}`.
|
**Problem:** Tests failed looking for `view-output-inprogress-${featureId}` when the actual button had `view-output-${featureId}`.
|
||||||
**Fix:** The button test ID depends on whether the feature is actively running:
|
**Fix:** The button test ID depends on whether the feature is actively running:
|
||||||
|
|
||||||
- `view-output-${featureId}` - shown when feature is in `runningAutoTasks` (actively running)
|
- `view-output-${featureId}` - shown when feature is in `runningAutoTasks` (actively running)
|
||||||
- `view-output-inprogress-${featureId}` - shown when status is "in_progress" but NOT actively running
|
- `view-output-inprogress-${featureId}` - shown when status is "in_progress" but NOT actively running
|
||||||
|
|
||||||
@@ -33,13 +38,16 @@ After dragging a feature to in_progress, wait for the `auto_mode_feature_start`
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Wait for feature to start running
|
// Wait for feature to start running
|
||||||
const viewOutputButton = page.locator(
|
const viewOutputButton = page
|
||||||
|
.locator(
|
||||||
`[data-testid="view-output-${featureId}"], [data-testid="view-output-inprogress-${featureId}"]`
|
`[data-testid="view-output-${featureId}"], [data-testid="view-output-inprogress-${featureId}"]`
|
||||||
).first();
|
)
|
||||||
|
.first();
|
||||||
await expect(viewOutputButton).toBeVisible({ timeout: 8000 });
|
await expect(viewOutputButton).toBeVisible({ timeout: 8000 });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issue: Elements not appearing due to async event timing
|
### Issue: Elements not appearing due to async event timing
|
||||||
|
|
||||||
**Problem:** Tests checked for UI elements before async events (like `auto_mode_feature_start`) had fired and updated the UI.
|
**Problem:** Tests checked for UI elements before async events (like `auto_mode_feature_start`) had fired and updated the UI.
|
||||||
**Fix:** Add appropriate timeouts when waiting for elements that depend on async events. The mock auto mode takes ~2.4 seconds to complete, so allow sufficient time:
|
**Fix:** Add appropriate timeouts when waiting for elements that depend on async events. The mock auto mode takes ~2.4 seconds to complete, so allow sufficient time:
|
||||||
|
|
||||||
@@ -49,18 +57,22 @@ await waitForAgentOutputModalHidden(page, { timeout: 10000 });
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Issue: Slider interaction testing
|
### Issue: Slider interaction testing
|
||||||
|
|
||||||
**Problem:** Clicking on slider track didn't reliably set specific values.
|
**Problem:** Clicking on slider track didn't reliably set specific values.
|
||||||
**Fix:** Use the slider's keyboard interaction or calculate the exact click position on the track. For max value, click on the rightmost edge of the track.
|
**Fix:** Use the slider's keyboard interaction or calculate the exact click position on the track. For max value, click on the rightmost edge of the track.
|
||||||
|
|
||||||
### Issue: Port binding blocked in sandbox mode
|
### Issue: Port binding blocked in sandbox mode
|
||||||
|
|
||||||
**Problem:** Playwright tests couldn't bind to port in sandbox mode.
|
**Problem:** Playwright tests couldn't bind to port in sandbox mode.
|
||||||
**Fix:** Tests don't need sandbox disabled - the issue was TEST_REUSE_SERVER environment variable. Make sure to start the dev server separately or let Playwright's webServer config handle it.
|
**Fix:** Tests don't need sandbox disabled - the issue was TEST_REUSE_SERVER environment variable. Make sure to start the dev server separately or let Playwright's webServer config handle it.
|
||||||
|
|
||||||
## Code Architecture
|
## Code Architecture
|
||||||
|
|
||||||
### Issue: Understanding store state persistence
|
### Issue: Understanding store state persistence
|
||||||
|
|
||||||
**Problem:** Not all store state is persisted to localStorage.
|
**Problem:** Not all store state is persisted to localStorage.
|
||||||
**Fix:** Check the `partialize` function in `app-store.ts` to see which state is persisted:
|
**Fix:** Check the `partialize` function in `app-store.ts` to see which state is persisted:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
@@ -71,13 +83,16 @@ partialize: (state) => ({
|
|||||||
chatSessions: state.chatSessions,
|
chatSessions: state.chatSessions,
|
||||||
chatHistoryOpen: state.chatHistoryOpen,
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
maxConcurrency: state.maxConcurrency, // Added for concurrency feature
|
maxConcurrency: state.maxConcurrency, // Added for concurrency feature
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `currentView` is NOT persisted - it's managed through actions.
|
Note: `currentView` is NOT persisted - it's managed through actions.
|
||||||
|
|
||||||
### Issue: Auto mode task lifecycle
|
### Issue: Auto mode task lifecycle
|
||||||
|
|
||||||
**Problem:** Confusion about when features are considered "running" vs "in_progress".
|
**Problem:** Confusion about when features are considered "running" vs "in_progress".
|
||||||
**Fix:** Understand the task lifecycle:
|
**Fix:** Understand the task lifecycle:
|
||||||
|
|
||||||
1. Feature dragged to "in_progress" column → status becomes "in_progress"
|
1. Feature dragged to "in_progress" column → status becomes "in_progress"
|
||||||
2. `auto_mode_feature_start` event fires → feature added to `runningAutoTasks`
|
2. `auto_mode_feature_start` event fires → feature added to `runningAutoTasks`
|
||||||
3. Agent works on feature → periodic events sent
|
3. Agent works on feature → periodic events sent
|
||||||
@@ -85,6 +100,7 @@ Note: `currentView` is NOT persisted - it's managed through actions.
|
|||||||
5. If `passes: true` → status becomes "verified", if `passes: false` → stays "in_progress"
|
5. If `passes: true` → status becomes "verified", if `passes: false` → stays "in_progress"
|
||||||
|
|
||||||
### Issue: waiting_approval features not draggable when skipTests=true
|
### Issue: waiting_approval features not draggable when skipTests=true
|
||||||
|
|
||||||
**Problem:** Features in `waiting_approval` status couldn't be dragged to `verified` column, even though the code appeared to handle it.
|
**Problem:** Features in `waiting_approval` status couldn't be dragged to `verified` column, even though the code appeared to handle it.
|
||||||
**Fix:** The order of condition checks in `handleDragEnd` matters. The `skipTests` check was catching `waiting_approval` features before the `waiting_approval` status check could handle them. Move the `waiting_approval` status check **before** the `skipTests` check in `board-view.tsx`:
|
**Fix:** The order of condition checks in `handleDragEnd` matters. The `skipTests` check was catching `waiting_approval` features before the `waiting_approval` status check could handle them. Move the `waiting_approval` status check **before** the `skipTests` check in `board-view.tsx`:
|
||||||
|
|
||||||
@@ -103,28 +119,35 @@ if (draggedFeature.status === "backlog") {
|
|||||||
## Best Practices Discovered
|
## Best Practices Discovered
|
||||||
|
|
||||||
### Testing utilities are critical
|
### Testing utilities are critical
|
||||||
|
|
||||||
Create comprehensive testing utilities in `tests/utils.ts` to avoid repeating selector logic:
|
Create comprehensive testing utilities in `tests/utils.ts` to avoid repeating selector logic:
|
||||||
|
|
||||||
- `waitForElement` - waits for elements to appear
|
- `waitForElement` - waits for elements to appear
|
||||||
- `waitForElementHidden` - waits for elements to disappear
|
- `waitForElementHidden` - waits for elements to disappear
|
||||||
- `setupMockProject` - sets up mock localStorage state
|
- `setupMockProject` - sets up mock localStorage state
|
||||||
- `navigateToBoard` - handles navigation from welcome to board view
|
- `navigateToBoard` - handles navigation from welcome to board view
|
||||||
|
|
||||||
### Always add data-testid attributes
|
### Always add data-testid attributes
|
||||||
|
|
||||||
When implementing features, immediately add `data-testid` attributes to key UI elements. This makes tests more reliable and easier to write.
|
When implementing features, immediately add `data-testid` attributes to key UI elements. This makes tests more reliable and easier to write.
|
||||||
|
|
||||||
### Test timeouts should be generous but not excessive
|
### Test timeouts should be generous but not excessive
|
||||||
|
|
||||||
- Default timeout: 30s (set in playwright.config.ts)
|
- Default timeout: 30s (set in playwright.config.ts)
|
||||||
- Element waits: 5-15s for critical elements
|
- Element waits: 5-15s for critical elements
|
||||||
- Auto mode completion: 10s (accounts for ~4s mock duration)
|
- Auto mode completion: 10s (accounts for ~4s mock duration)
|
||||||
- Don't increase timeouts past 10s for individual operations
|
- Don't increase timeouts past 10s for individual operations
|
||||||
|
|
||||||
### Mock auto mode timing
|
### Mock auto mode timing
|
||||||
|
|
||||||
The mock auto mode in `electron.ts` has predictable timing:
|
The mock auto mode in `electron.ts` has predictable timing:
|
||||||
|
|
||||||
- Total duration: ~2.4 seconds (300+500+300+300+500+500ms)
|
- Total duration: ~2.4 seconds (300+500+300+300+500+500ms)
|
||||||
- Plus 1.5s delay before auto-closing modals
|
- Plus 1.5s delay before auto-closing modals
|
||||||
- Total: ~4 seconds from start to completion
|
- Total: ~4 seconds from start to completion
|
||||||
|
|
||||||
### Issue: HotkeyButton conflicting with useKeyboardShortcuts
|
### Issue: HotkeyButton conflicting with useKeyboardShortcuts
|
||||||
|
|
||||||
**Problem:** Adding `HotkeyButton` with a simple key (like "N") to buttons that already had keyboard shortcuts registered via `useKeyboardShortcuts` caused the hotkey to stop working. Both registered duplicate listeners, and the HotkeyButton's `stopPropagation()` call could interfere.
|
**Problem:** Adding `HotkeyButton` with a simple key (like "N") to buttons that already had keyboard shortcuts registered via `useKeyboardShortcuts` caused the hotkey to stop working. Both registered duplicate listeners, and the HotkeyButton's `stopPropagation()` call could interfere.
|
||||||
**Fix:** When a simple single-key hotkey is already handled by `useKeyboardShortcuts`, set `hotkeyActive={false}` on the `HotkeyButton` so it only displays the indicator badge without registering a duplicate listener:
|
**Fix:** When a simple single-key hotkey is already handled by `useKeyboardShortcuts`, set `hotkeyActive={false}` on the `HotkeyButton` so it only displays the indicator badge without registering a duplicate listener:
|
||||||
|
|
||||||
@@ -132,7 +155,7 @@ The mock auto mode in `electron.ts` has predictable timing:
|
|||||||
// In views that already use useKeyboardShortcuts for the "N" key:
|
// In views that already use useKeyboardShortcuts for the "N" key:
|
||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
onClick={() => setShowAddDialog(true)}
|
onClick={() => setShowAddDialog(true)}
|
||||||
hotkey={ACTION_SHORTCUTS.addFeature}
|
hotkey={shortcuts.addFeature}
|
||||||
hotkeyActive={false} // <-- Important! Prevents duplicate listener
|
hotkeyActive={false} // <-- Important! Prevents duplicate listener
|
||||||
>
|
>
|
||||||
Add Feature
|
Add Feature
|
||||||
|
|||||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Automaker
|
||||||
|
|
||||||
|
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[!CAUTION]**
|
||||||
|
>
|
||||||
|
> ## Security Disclaimer
|
||||||
|
>
|
||||||
|
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||||
|
>
|
||||||
|
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||||
|
>
|
||||||
|
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||||
|
>
|
||||||
|
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
**Step 1:** Clone this repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:AutoMaker-Org/automaker.git
|
||||||
|
cd automaker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3:** Run the Claude Code setup token command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude setup-token
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
|
||||||
|
|
||||||
|
**Step 4:** Export the Claude Code OAuth token in your shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5:** Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:electron
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start both the Next.js development server and the Electron application.
|
||||||
|
|
||||||
|
**Step 6:** MOST IMPORANT: Run the Following after all is setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
echo "W"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
||||||
|
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
||||||
|
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
||||||
|
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
||||||
|
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
||||||
|
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
||||||
|
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
||||||
|
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
||||||
|
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
||||||
|
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
||||||
|
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
||||||
|
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
||||||
|
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
||||||
|
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
||||||
|
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
||||||
|
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
||||||
|
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- [Next.js](https://nextjs.org) - React framework
|
||||||
|
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
||||||
|
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||||
|
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See [LICENSE](../LICENSE) for details.
|
||||||
@@ -818,8 +818,23 @@ ipcMain.handle(
|
|||||||
ipcMain.handle("claude:check-cli", async () => {
|
ipcMain.handle("claude:check-cli", async () => {
|
||||||
try {
|
try {
|
||||||
const claudeCliDetector = require("./services/claude-cli-detector");
|
const claudeCliDetector = require("./services/claude-cli-detector");
|
||||||
const info = claudeCliDetector.getInstallationInfo();
|
const path = require("path");
|
||||||
return { success: true, ...info };
|
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
|
||||||
|
const fullStatus = claudeCliDetector.getFullStatus(credentialsPath);
|
||||||
|
|
||||||
|
// Return in format expected by settings view (status: "installed" | "not_installed")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: fullStatus.installed ? "installed" : "not_installed",
|
||||||
|
method: fullStatus.auth?.method || null,
|
||||||
|
version: fullStatus.version || null,
|
||||||
|
path: fullStatus.path || null,
|
||||||
|
authenticated: fullStatus.auth?.authenticated || false,
|
||||||
|
recommendation: fullStatus.installed
|
||||||
|
? null
|
||||||
|
: "Install Claude Code CLI for optimal performance with ultrathink.",
|
||||||
|
installCommands: fullStatus.installed ? null : claudeCliDetector.getInstallCommands(),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[IPC] claude:check-cli error:", error);
|
console.error("[IPC] claude:check-cli error:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -1363,3 +1378,233 @@ ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup & CLI Management IPC Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive Claude CLI status including auth
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:claude-status", async () => {
|
||||||
|
try {
|
||||||
|
const claudeCliDetector = require("./services/claude-cli-detector");
|
||||||
|
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
|
||||||
|
const result = claudeCliDetector.getFullStatus(credentialsPath);
|
||||||
|
console.log("[IPC] setup:claude-status result:", result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:claude-status error:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive Codex CLI status including auth
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:codex-status", async () => {
|
||||||
|
try {
|
||||||
|
const codexCliDetector = require("./services/codex-cli-detector");
|
||||||
|
const info = codexCliDetector.getFullStatus();
|
||||||
|
return { success: true, ...info };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:codex-status error:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Claude CLI
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:install-claude", async (event) => {
|
||||||
|
try {
|
||||||
|
const claudeCliDetector = require("./services/claude-cli-detector");
|
||||||
|
|
||||||
|
const sendProgress = (progress) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("setup:install-progress", {
|
||||||
|
cli: "claude",
|
||||||
|
...progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await claudeCliDetector.installCli(sendProgress);
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:install-claude error:", error);
|
||||||
|
return { success: false, error: error.message || error.error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Codex CLI
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:install-codex", async (event) => {
|
||||||
|
try {
|
||||||
|
const codexCliDetector = require("./services/codex-cli-detector");
|
||||||
|
|
||||||
|
const sendProgress = (progress) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("setup:install-progress", {
|
||||||
|
cli: "codex",
|
||||||
|
...progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await codexCliDetector.installCli(sendProgress);
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:install-codex error:", error);
|
||||||
|
return { success: false, error: error.message || error.error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate Claude CLI (manual auth required)
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:auth-claude", async (event) => {
|
||||||
|
try {
|
||||||
|
const claudeCliDetector = require("./services/claude-cli-detector");
|
||||||
|
|
||||||
|
const sendProgress = (progress) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("setup:auth-progress", {
|
||||||
|
cli: "claude",
|
||||||
|
...progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await claudeCliDetector.runSetupToken(sendProgress);
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:auth-claude error:", error);
|
||||||
|
return { success: false, error: error.message || error.error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate Codex CLI with optional API key
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
|
||||||
|
try {
|
||||||
|
const codexCliDetector = require("./services/codex-cli-detector");
|
||||||
|
|
||||||
|
const sendProgress = (progress) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("setup:auth-progress", {
|
||||||
|
cli: "codex",
|
||||||
|
...progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await codexCliDetector.authenticate(apiKey, sendProgress);
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:auth-codex error:", error);
|
||||||
|
return { success: false, error: error.message || error.error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store API key or OAuth token securely (using app's userData)
|
||||||
|
* @param {string} provider - Provider name (anthropic, openai, google, anthropic_oauth_token)
|
||||||
|
* @param {string} apiKey - The API key or OAuth token to store
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => {
|
||||||
|
try {
|
||||||
|
console.log("[IPC] setup:store-api-key called for provider:", provider);
|
||||||
|
const configPath = path.join(app.getPath("userData"), "credentials.json");
|
||||||
|
let credentials = {};
|
||||||
|
|
||||||
|
// Read existing credentials
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, "utf-8");
|
||||||
|
credentials = JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
// File doesn't exist, start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the new key/token
|
||||||
|
credentials[provider] = apiKey;
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(credentials, null, 2), "utf-8");
|
||||||
|
|
||||||
|
console.log("[IPC] setup:store-api-key stored successfully for:", provider);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:store-api-key error:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored API keys and tokens
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:get-api-keys", async () => {
|
||||||
|
try {
|
||||||
|
const configPath = path.join(app.getPath("userData"), "credentials.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(configPath, "utf-8");
|
||||||
|
const credentials = JSON.parse(content);
|
||||||
|
|
||||||
|
// Return which keys/tokens exist (not the actual values for security)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasAnthropicKey: !!credentials.anthropic,
|
||||||
|
hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token,
|
||||||
|
hasOpenAIKey: !!credentials.openai,
|
||||||
|
hasGoogleKey: !!credentials.google
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasAnthropicKey: false,
|
||||||
|
hasAnthropicOAuthToken: false,
|
||||||
|
hasOpenAIKey: false,
|
||||||
|
hasGoogleKey: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:get-api-keys error:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Codex MCP server for a project
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => {
|
||||||
|
try {
|
||||||
|
const codexConfigManager = require("./services/codex-config-manager");
|
||||||
|
const mcpServerPath = path.join(__dirname, "services", "mcp-server-factory.js");
|
||||||
|
|
||||||
|
const configPath = await codexConfigManager.configureMcpServer(projectPath, mcpServerPath);
|
||||||
|
|
||||||
|
return { success: true, configPath };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[IPC] setup:configure-codex-mcp error:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform information
|
||||||
|
*/
|
||||||
|
ipcMain.handle("setup:get-platform", async () => {
|
||||||
|
const os = require("os");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
isWindows: process.platform === "win32",
|
||||||
|
isMac: process.platform === "darwin",
|
||||||
|
isLinux: process.platform === "linux"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -252,6 +252,59 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Setup & CLI Management API
|
||||||
|
setup: {
|
||||||
|
// Get comprehensive Claude CLI status
|
||||||
|
getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"),
|
||||||
|
|
||||||
|
// Get comprehensive Codex CLI status
|
||||||
|
getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"),
|
||||||
|
|
||||||
|
// Install Claude CLI
|
||||||
|
installClaude: () => ipcRenderer.invoke("setup:install-claude"),
|
||||||
|
|
||||||
|
// Install Codex CLI
|
||||||
|
installCodex: () => ipcRenderer.invoke("setup:install-codex"),
|
||||||
|
|
||||||
|
// Authenticate Claude CLI
|
||||||
|
authClaude: () => ipcRenderer.invoke("setup:auth-claude"),
|
||||||
|
|
||||||
|
// Authenticate Codex CLI with optional API key
|
||||||
|
authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }),
|
||||||
|
|
||||||
|
// Store API key securely
|
||||||
|
storeApiKey: (provider, apiKey) =>
|
||||||
|
ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }),
|
||||||
|
|
||||||
|
// Get stored API keys status
|
||||||
|
getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"),
|
||||||
|
|
||||||
|
// Configure Codex MCP server for a project
|
||||||
|
configureCodexMcp: (projectPath) =>
|
||||||
|
ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }),
|
||||||
|
|
||||||
|
// Get platform information
|
||||||
|
getPlatform: () => ipcRenderer.invoke("setup:get-platform"),
|
||||||
|
|
||||||
|
// Listen for installation progress
|
||||||
|
onInstallProgress: (callback) => {
|
||||||
|
const subscription = (_, data) => callback(data);
|
||||||
|
ipcRenderer.on("setup:install-progress", subscription);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener("setup:install-progress", subscription);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Listen for auth progress
|
||||||
|
onAuthProgress: (callback) => {
|
||||||
|
const subscription = (_, data) => callback(data);
|
||||||
|
ipcRenderer.on("setup:auth-progress", subscription);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener("setup:auth-progress", subscription);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also expose a flag to detect if we're in Electron
|
// Also expose a flag to detect if we're in Electron
|
||||||
|
|||||||
@@ -1,50 +1,139 @@
|
|||||||
const { execSync } = require('child_process');
|
const { execSync, spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude CLI Detector
|
||||||
|
*
|
||||||
|
* Authentication options:
|
||||||
|
* 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token to the app
|
||||||
|
* 2. API Key (Pay-per-use): User provides their Anthropic API key directly
|
||||||
|
*/
|
||||||
class ClaudeCliDetector {
|
class ClaudeCliDetector {
|
||||||
/**
|
/**
|
||||||
* Check if Claude Code CLI is installed and accessible
|
* Check if Claude Code CLI is installed and accessible
|
||||||
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'sdk'|'none' }
|
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'none' }
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Try to get updated PATH from shell config files
|
||||||
|
* This helps detect CLI installations that modify shell config but haven't updated the current process PATH
|
||||||
|
*/
|
||||||
|
static getUpdatedPathFromShellConfig() {
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const shell = process.env.SHELL || '/bin/bash';
|
||||||
|
const shellName = path.basename(shell);
|
||||||
|
|
||||||
|
// Common shell config files
|
||||||
|
const configFiles = [];
|
||||||
|
if (shellName.includes('zsh')) {
|
||||||
|
configFiles.push(path.join(homeDir, '.zshrc'));
|
||||||
|
configFiles.push(path.join(homeDir, '.zshenv'));
|
||||||
|
configFiles.push(path.join(homeDir, '.zprofile'));
|
||||||
|
} else if (shellName.includes('bash')) {
|
||||||
|
configFiles.push(path.join(homeDir, '.bashrc'));
|
||||||
|
configFiles.push(path.join(homeDir, '.bash_profile'));
|
||||||
|
configFiles.push(path.join(homeDir, '.profile'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check common locations
|
||||||
|
const commonPaths = [
|
||||||
|
path.join(homeDir, '.local', 'bin'),
|
||||||
|
path.join(homeDir, '.cargo', 'bin'),
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
path.join(homeDir, 'bin'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to extract PATH additions from config files
|
||||||
|
for (const configFile of configFiles) {
|
||||||
|
if (fs.existsSync(configFile)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(configFile, 'utf-8');
|
||||||
|
// Look for PATH exports that might include claude installation paths
|
||||||
|
const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g);
|
||||||
|
if (pathMatches) {
|
||||||
|
for (const match of pathMatches) {
|
||||||
|
const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, '');
|
||||||
|
const paths = pathValue.split(':').filter(p => p && !p.includes('$'));
|
||||||
|
commonPaths.push(...paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors reading config files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(commonPaths)]; // Remove duplicates
|
||||||
|
}
|
||||||
|
|
||||||
static detectClaudeInstallation() {
|
static detectClaudeInstallation() {
|
||||||
|
console.log('[ClaudeCliDetector] Detecting Claude installation...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Method 1: Check if 'claude' command is in PATH
|
// Method 1: Check if 'claude' command is in PATH (Unix)
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
try {
|
try {
|
||||||
const claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim();
|
||||||
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
|
if (claudePath) {
|
||||||
|
const version = this.getClaudeVersion(claudePath);
|
||||||
|
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: 'cli'
|
||||||
};
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// CLI not in PATH, check local installation
|
// CLI not in PATH, continue checking other locations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 2: Check for local installation
|
// Method 2: Check Windows path
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
|
||||||
|
if (claudePath) {
|
||||||
|
const version = this.getClaudeVersion(claudePath);
|
||||||
|
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
path: claudePath,
|
||||||
|
version: version,
|
||||||
|
method: 'cli'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Not found on Windows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check for local installation
|
||||||
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
|
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
|
||||||
if (fs.existsSync(localClaudePath)) {
|
if (fs.existsSync(localClaudePath)) {
|
||||||
try {
|
const version = this.getClaudeVersion(localClaudePath);
|
||||||
const version = execSync(`${localClaudePath} --version`, { encoding: 'utf-8' }).trim();
|
console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: localClaudePath,
|
path: localClaudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli-local'
|
method: 'cli-local'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
// Local CLI exists but may not be executable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 3: Check Windows path
|
// Method 4: Check common installation locations (including those from shell config)
|
||||||
if (process.platform === 'win32') {
|
const commonPaths = this.getUpdatedPathFromShellConfig();
|
||||||
|
const binaryNames = ['claude', 'claude-code'];
|
||||||
|
|
||||||
|
for (const basePath of commonPaths) {
|
||||||
|
for (const binaryName of binaryNames) {
|
||||||
|
const claudePath = path.join(basePath, binaryName);
|
||||||
|
if (fs.existsSync(claudePath)) {
|
||||||
try {
|
try {
|
||||||
const claudePath = execSync('where claude', { encoding: 'utf-8' }).trim();
|
const version = this.getClaudeVersion(claudePath);
|
||||||
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
|
console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
@@ -52,20 +141,45 @@ class ClaudeCliDetector {
|
|||||||
method: 'cli'
|
method: 'cli'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not found
|
// File exists but can't get version, might not be executable
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 4: SDK mode (using OAuth token)
|
// Method 5: Try to source shell config and check PATH again (for Unix)
|
||||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
if (process.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
const shell = process.env.SHELL || '/bin/bash';
|
||||||
|
const shellName = path.basename(shell);
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
|
let sourceCmd = '';
|
||||||
|
if (shellName.includes('zsh')) {
|
||||||
|
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
|
||||||
|
} else if (shellName.includes('bash')) {
|
||||||
|
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceCmd) {
|
||||||
|
const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
||||||
|
if (claudePath && claudePath.startsWith('/')) {
|
||||||
|
const version = this.getClaudeVersion(claudePath);
|
||||||
|
console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version);
|
||||||
return {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: null,
|
path: claudePath,
|
||||||
version: 'SDK Mode',
|
version: version,
|
||||||
method: 'sdk'
|
method: 'cli'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Failed to source shell config or find claude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ClaudeCliDetector] Claude CLI not found');
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
path: null,
|
path: null,
|
||||||
@@ -85,35 +199,223 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get installation recommendations
|
* Get Claude CLI version
|
||||||
|
* @param {string} claudePath Path to claude executable
|
||||||
|
* @returns {string|null} Version string or null
|
||||||
*/
|
*/
|
||||||
static getInstallationInfo() {
|
static getClaudeVersion(claudePath) {
|
||||||
|
try {
|
||||||
|
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000
|
||||||
|
}).trim();
|
||||||
|
return version || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication status
|
||||||
|
* Checks for:
|
||||||
|
* 1. OAuth token stored in app's credentials (from `claude setup-token`)
|
||||||
|
* 2. API key stored in app's credentials
|
||||||
|
* 3. API key in environment variable
|
||||||
|
*
|
||||||
|
* @param {string} appCredentialsPath Path to app's credentials.json
|
||||||
|
* @returns {Object} Authentication status
|
||||||
|
*/
|
||||||
|
static getAuthStatus(appCredentialsPath) {
|
||||||
|
console.log('[ClaudeCliDetector] Checking auth status...');
|
||||||
|
|
||||||
|
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey);
|
||||||
|
|
||||||
|
// Check app's stored credentials
|
||||||
|
let storedOAuthToken = null;
|
||||||
|
let storedApiKey = null;
|
||||||
|
|
||||||
|
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(appCredentialsPath, 'utf-8');
|
||||||
|
const credentials = JSON.parse(content);
|
||||||
|
storedOAuthToken = credentials.anthropic_oauth_token || null;
|
||||||
|
storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null;
|
||||||
|
console.log('[ClaudeCliDetector] App credentials:', {
|
||||||
|
hasOAuthToken: !!storedOAuthToken,
|
||||||
|
hasApiKey: !!storedApiKey
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine authentication method
|
||||||
|
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
||||||
|
let authenticated = false;
|
||||||
|
let method = 'none';
|
||||||
|
|
||||||
|
if (storedOAuthToken) {
|
||||||
|
authenticated = true;
|
||||||
|
method = 'oauth_token';
|
||||||
|
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
|
||||||
|
} else if (storedApiKey) {
|
||||||
|
authenticated = true;
|
||||||
|
method = 'api_key';
|
||||||
|
console.log('[ClaudeCliDetector] Using stored API key');
|
||||||
|
} else if (envApiKey) {
|
||||||
|
authenticated = true;
|
||||||
|
method = 'api_key_env';
|
||||||
|
console.log('[ClaudeCliDetector] Using environment API key');
|
||||||
|
} else {
|
||||||
|
console.log('[ClaudeCliDetector] No authentication found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
authenticated,
|
||||||
|
method,
|
||||||
|
hasStoredOAuthToken: !!storedOAuthToken,
|
||||||
|
hasStoredApiKey: !!storedApiKey,
|
||||||
|
hasEnvApiKey: !!envApiKey
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[ClaudeCliDetector] Auth status result:', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full status including installation and auth
|
||||||
|
* @param {string} appCredentialsPath Path to app's credentials.json
|
||||||
|
* @returns {Object} Full status
|
||||||
|
*/
|
||||||
|
static getFullStatus(appCredentialsPath) {
|
||||||
|
const installation = this.detectClaudeInstallation();
|
||||||
|
const auth = this.getAuthStatus(appCredentialsPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: installation.installed ? 'installed' : 'not_installed',
|
||||||
|
installed: installation.installed,
|
||||||
|
path: installation.path,
|
||||||
|
version: installation.version,
|
||||||
|
method: installation.method,
|
||||||
|
auth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get installation commands for different platforms
|
||||||
|
* @returns {Object} Installation commands
|
||||||
|
*/
|
||||||
|
static getInstallCommands() {
|
||||||
|
return {
|
||||||
|
macos: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||||
|
windows: 'irm https://claude.ai/install.ps1 | iex',
|
||||||
|
linux: 'curl -fsSL https://claude.ai/install.sh | bash'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Claude CLI using the official script
|
||||||
|
* @param {Function} onProgress Callback for progress updates
|
||||||
|
* @returns {Promise<Object>} Installation result
|
||||||
|
*/
|
||||||
|
static async installCli(onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const platform = process.platform;
|
||||||
|
let command, args;
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
command = 'powershell';
|
||||||
|
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex'];
|
||||||
|
} else {
|
||||||
|
command = 'bash';
|
||||||
|
args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ClaudeCliDetector] Installing Claude CLI...');
|
||||||
|
|
||||||
|
const proc = spawn(command, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: false
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stdout', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
errorOutput += text;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stderr', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
console.log('[ClaudeCliDetector] Installation completed successfully');
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
output,
|
||||||
|
message: 'Claude CLI installed successfully'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[ClaudeCliDetector] Installation failed with code:', code);
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: errorOutput || `Installation failed with code ${code}`,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (error) => {
|
||||||
|
console.error('[ClaudeCliDetector] Installation error:', error);
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get instructions for setup-token command
|
||||||
|
* @returns {Object} Setup token instructions
|
||||||
|
*/
|
||||||
|
static getSetupTokenInstructions() {
|
||||||
const detection = this.detectClaudeInstallation();
|
const detection = this.detectClaudeInstallation();
|
||||||
|
|
||||||
if (detection.installed) {
|
if (!detection.installed) {
|
||||||
return {
|
return {
|
||||||
status: 'installed',
|
success: false,
|
||||||
method: detection.method,
|
error: 'Claude CLI is not installed. Please install it first.',
|
||||||
version: detection.version,
|
installCommands: this.getInstallCommands()
|
||||||
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 {
|
return {
|
||||||
status: 'not_installed',
|
success: true,
|
||||||
recommendation: 'Consider installing Claude Code CLI for better performance with ultrathink',
|
command: 'claude setup-token',
|
||||||
installCommands: {
|
instructions: [
|
||||||
macos: 'curl -fsSL claude.ai/install.sh | bash',
|
'1. Open your terminal',
|
||||||
windows: 'irm https://claude.ai/install.ps1 | iex',
|
'2. Run: claude setup-token',
|
||||||
linux: 'curl -fsSL claude.ai/install.sh | bash',
|
'3. Follow the prompts to authenticate',
|
||||||
npm: 'npm install -g @anthropic-ai/claude-code'
|
'4. Copy the token that is displayed',
|
||||||
}
|
'5. Paste the token in the field below'
|
||||||
|
],
|
||||||
|
note: 'This token is from your Claude subscription and allows you to use Claude without API charges.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ClaudeCliDetector;
|
module.exports = ClaudeCliDetector;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { execSync } = require('child_process');
|
const { execSync, spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
@@ -11,6 +11,205 @@ const os = require('os');
|
|||||||
* for code generation and agentic tasks.
|
* for code generation and agentic tasks.
|
||||||
*/
|
*/
|
||||||
class CodexCliDetector {
|
class CodexCliDetector {
|
||||||
|
/**
|
||||||
|
* Get the path to Codex config directory
|
||||||
|
* @returns {string} Path to .codex directory
|
||||||
|
*/
|
||||||
|
static getConfigDir() {
|
||||||
|
return path.join(os.homedir(), '.codex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to Codex auth file
|
||||||
|
* @returns {string} Path to auth.json
|
||||||
|
*/
|
||||||
|
static getAuthPath() {
|
||||||
|
return path.join(this.getConfigDir(), 'auth.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex authentication status
|
||||||
|
* @returns {Object} Authentication status
|
||||||
|
*/
|
||||||
|
static checkAuth() {
|
||||||
|
console.log('[CodexCliDetector] Checking auth status...');
|
||||||
|
try {
|
||||||
|
const authPath = this.getAuthPath();
|
||||||
|
const envApiKey = process.env.OPENAI_API_KEY;
|
||||||
|
console.log('[CodexCliDetector] Auth path:', authPath);
|
||||||
|
console.log('[CodexCliDetector] Has env API key:', !!envApiKey);
|
||||||
|
|
||||||
|
// First, try to verify authentication using codex CLI command if available
|
||||||
|
try {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
if (detection.installed) {
|
||||||
|
try {
|
||||||
|
// Use 'codex login status' to verify authentication
|
||||||
|
const statusOutput = execSync(`"${detection.path || 'codex'}" login status 2>/dev/null`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
// If command succeeds and shows logged in status
|
||||||
|
if (statusOutput && (statusOutput.includes('Logged in') || statusOutput.includes('Authenticated'))) {
|
||||||
|
const result = {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'cli_verified',
|
||||||
|
hasAuthFile: fs.existsSync(authPath),
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (cli_verified):', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (statusError) {
|
||||||
|
// status command failed, continue with file-based check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
// CLI verification failed, continue with file-based check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auth file exists
|
||||||
|
if (fs.existsSync(authPath)) {
|
||||||
|
const content = fs.readFileSync(authPath, 'utf-8');
|
||||||
|
const auth = JSON.parse(content);
|
||||||
|
|
||||||
|
// Check for token object structure (from codex auth login)
|
||||||
|
// Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... }
|
||||||
|
if (auth.token && typeof auth.token === 'object') {
|
||||||
|
const token = auth.token;
|
||||||
|
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for various possible auth fields that codex might use
|
||||||
|
if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the file has any meaningful content (non-empty object)
|
||||||
|
const keys = Object.keys(auth);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
// File exists and has content, likely authenticated
|
||||||
|
// Try to verify by checking if codex command works
|
||||||
|
try {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
if (detection.installed) {
|
||||||
|
// Try to verify auth by running a simple command
|
||||||
|
try {
|
||||||
|
execSync(`"${detection.path || 'codex'}" --version 2>/dev/null`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 3000
|
||||||
|
});
|
||||||
|
// If command succeeds, assume authenticated
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
} catch (cmdError) {
|
||||||
|
// Command failed, but file exists - might still be authenticated
|
||||||
|
// Return authenticated if file has content
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
// Verification failed, but file exists with content
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variable
|
||||||
|
if (envApiKey) {
|
||||||
|
const result = {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'env_var',
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasEnvKey: true,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (env_var):', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If auth file exists but we didn't find standard keys,
|
||||||
|
// check if codex CLI is installed and try to verify auth
|
||||||
|
if (fs.existsSync(authPath)) {
|
||||||
|
try {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
if (detection.installed) {
|
||||||
|
// Auth file exists and CLI is installed - likely authenticated
|
||||||
|
// The file existing is a good indicator that login was successful
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
// Verification attempt failed, but file exists
|
||||||
|
// Assume authenticated if file exists
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'auth_file',
|
||||||
|
hasAuthFile: true,
|
||||||
|
hasEnvKey: !!envApiKey,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none',
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasEnvKey: false,
|
||||||
|
authPath
|
||||||
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (not authenticated):', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CodexCliDetector] Error checking auth:', error);
|
||||||
|
const result = {
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (error):', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Check if Codex CLI is installed and accessible
|
* Check if Codex CLI is installed and accessible
|
||||||
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
|
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
|
||||||
@@ -224,6 +423,171 @@ class CodexCliDetector {
|
|||||||
static getDefaultModel() {
|
static getDefaultModel() {
|
||||||
return 'gpt-5.1-codex-max';
|
return 'gpt-5.1-codex-max';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive installation info including auth status
|
||||||
|
* @returns {Object} Full status object
|
||||||
|
*/
|
||||||
|
static getFullStatus() {
|
||||||
|
const installation = this.detectCodexInstallation();
|
||||||
|
const auth = this.checkAuth();
|
||||||
|
const info = this.getInstallationInfo();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
auth,
|
||||||
|
installation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Codex CLI using npm
|
||||||
|
* @param {Function} onProgress Callback for progress updates
|
||||||
|
* @returns {Promise<Object>} Installation result
|
||||||
|
*/
|
||||||
|
static async installCli(onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const command = 'npm';
|
||||||
|
const args = ['install', '-g', '@openai/codex@latest'];
|
||||||
|
|
||||||
|
const proc = spawn(command, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stdout', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
errorOutput += text;
|
||||||
|
// npm often outputs progress to stderr
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stderr', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
output,
|
||||||
|
message: 'Codex CLI installed successfully'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: errorOutput || `Installation failed with code ${code}`,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (error) => {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate Codex CLI - opens browser for OAuth or stores API key
|
||||||
|
* @param {string} apiKey Optional API key to store
|
||||||
|
* @param {Function} onProgress Callback for progress updates
|
||||||
|
* @returns {Promise<Object>} Authentication result
|
||||||
|
*/
|
||||||
|
static async authenticate(apiKey, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const detection = this.detectCodexInstallation();
|
||||||
|
|
||||||
|
if (!detection.installed) {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: 'Codex CLI is not installed'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexPath = detection.path || 'codex';
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// Store API key directly using codex auth command
|
||||||
|
const proc = spawn(codexPath, ['auth', 'login', '--api-key', apiKey], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: false
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stdout', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
errorOutput += text;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ type: 'stderr', data: text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
output,
|
||||||
|
message: 'Codex CLI authenticated successfully'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: errorOutput || `Authentication failed with code ${code}`,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (error) => {
|
||||||
|
reject({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
output
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Require manual authentication
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
type: 'info',
|
||||||
|
data: 'Please run the following command in your terminal to authenticate:\n\ncodex auth login\n\nThen return here to continue setup.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
requiresManualAuth: true,
|
||||||
|
command: `${codexPath} auth login`,
|
||||||
|
message: 'Please authenticate Codex CLI manually'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = CodexCliDetector;
|
module.exports = CodexCliDetector;
|
||||||
|
|||||||
@@ -412,16 +412,16 @@ class FeatureExecutor {
|
|||||||
if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) {
|
if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) {
|
||||||
// Check if CLI is installed to provide better error message
|
// Check if CLI is installed to provide better error message
|
||||||
let authMsg =
|
let authMsg =
|
||||||
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
|
"Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.";
|
||||||
try {
|
try {
|
||||||
const claudeCliDetector = require("./claude-cli-detector");
|
const claudeCliDetector = require("./claude-cli-detector");
|
||||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||||
if (detection.installed && detection.method === "cli") {
|
if (detection.installed && detection.method === "cli") {
|
||||||
authMsg =
|
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.";
|
"Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.";
|
||||||
} else {
|
} else {
|
||||||
authMsg =
|
authMsg =
|
||||||
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.";
|
"Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fallback to default message
|
// Fallback to default message
|
||||||
|
|||||||
@@ -94,9 +94,42 @@ class ClaudeProvider extends ModelProvider {
|
|||||||
this.sdk = null;
|
this.sdk = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to load credentials from the app's own credentials.json file.
|
||||||
|
* This is where we store OAuth tokens and API keys that users enter in the setup wizard.
|
||||||
|
* Returns { oauthToken, apiKey } or null values if not found.
|
||||||
|
*/
|
||||||
|
loadTokenFromAppCredentials() {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { app } = require('electron');
|
||||||
|
const credentialsPath = path.join(app.getPath('userData'), 'credentials.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(credentialsPath)) {
|
||||||
|
console.log('[ClaudeProvider] App credentials file does not exist:', credentialsPath);
|
||||||
|
return { oauthToken: null, apiKey: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Check for OAuth token first (from claude setup-token), then API key
|
||||||
|
const oauthToken = parsed.anthropic_oauth_token || null;
|
||||||
|
const apiKey = parsed.anthropic || parsed.anthropic_api_key || null;
|
||||||
|
|
||||||
|
console.log('[ClaudeProvider] App credentials check - OAuth token:', !!oauthToken, ', API key:', !!apiKey);
|
||||||
|
return { oauthToken, apiKey };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ClaudeProvider] Failed to read app credentials:', err?.message);
|
||||||
|
return { oauthToken: null, apiKey: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
|
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
|
||||||
* Returns the token string or null if not found.
|
* Returns the token string or null if not found.
|
||||||
|
* NOTE: Claude's credentials.json is encrypted, so we only try config.json
|
||||||
*/
|
*/
|
||||||
loadTokenFromCliConfig() {
|
loadTokenFromCliConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -117,12 +150,26 @@ class ClaudeProvider extends ModelProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureAuthEnv() {
|
ensureAuthEnv() {
|
||||||
// If API key or token already present, keep as-is.
|
// If API key or token already present in environment, keep as-is.
|
||||||
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||||
console.log('[ClaudeProvider] Auth already present in environment');
|
console.log('[ClaudeProvider] Auth already present in environment');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Try to hydrate from CLI login config
|
|
||||||
|
// Priority 1: Try to load from app's own credentials (setup wizard)
|
||||||
|
const appCredentials = this.loadTokenFromAppCredentials();
|
||||||
|
if (appCredentials.oauthToken) {
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = appCredentials.oauthToken;
|
||||||
|
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from app credentials');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (appCredentials.apiKey) {
|
||||||
|
process.env.ANTHROPIC_API_KEY = appCredentials.apiKey;
|
||||||
|
console.log('[ClaudeProvider] Loaded ANTHROPIC_API_KEY from app credentials');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Try to hydrate from CLI login config (legacy)
|
||||||
const token = this.loadTokenFromCliConfig();
|
const token = this.loadTokenFromCliConfig();
|
||||||
if (token) {
|
if (token) {
|
||||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
||||||
@@ -135,12 +182,12 @@ class ClaudeProvider extends ModelProvider {
|
|||||||
const claudeCliDetector = require('./claude-cli-detector');
|
const claudeCliDetector = require('./claude-cli-detector');
|
||||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||||
if (detection.installed && detection.method === 'cli') {
|
if (detection.installed && detection.method === 'cli') {
|
||||||
console.error('[ClaudeProvider] Claude CLI is installed but not logged in. Run `claude login` to authenticate.');
|
console.error('[ClaudeProvider] Claude CLI is installed but not authenticated. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.');
|
||||||
} else {
|
} else {
|
||||||
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
|
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
|
console.error('[ClaudeProvider] No Anthropic auth found. Use the setup wizard or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN.');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -156,17 +203,17 @@ class ClaudeProvider extends ModelProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *executeQuery(options) {
|
async *executeQuery(options) {
|
||||||
// Ensure we have auth; fall back to CLI login token if available.
|
// Ensure we have auth; fall back to app credentials or CLI login token if available.
|
||||||
if (!this.ensureAuthEnv()) {
|
if (!this.ensureAuthEnv()) {
|
||||||
// Check if CLI is installed to provide better error message
|
// 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.';
|
let msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication.';
|
||||||
try {
|
try {
|
||||||
const claudeCliDetector = require('./claude-cli-detector');
|
const claudeCliDetector = require('./claude-cli-detector');
|
||||||
const detection = claudeCliDetector.detectClaudeInstallation();
|
const detection = claudeCliDetector.detectClaudeInstallation();
|
||||||
if (detection.installed && detection.method === 'cli') {
|
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.';
|
msg = 'Claude CLI is installed but not authenticated. Go to Settings > Setup to provide your subscription token (from `claude setup-token`) or API key.';
|
||||||
} else {
|
} else {
|
||||||
msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.';
|
msg = 'Missing Anthropic auth. Go to Settings > Setup to configure your Claude authentication, or set ANTHROPIC_API_KEY environment variable.';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fallback to default message
|
// Fallback to default message
|
||||||
@@ -239,11 +286,11 @@ class ClaudeProvider extends ModelProvider {
|
|||||||
validateConfig() {
|
validateConfig() {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Ensure auth is available (try to auto-load from CLI config)
|
// Ensure auth is available (try to auto-load from app credentials or CLI config)
|
||||||
this.ensureAuthEnv();
|
this.ensureAuthEnv();
|
||||||
|
|
||||||
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) {
|
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.');
|
errors.push('No Claude authentication found. Go to Settings > Setup to configure your subscription token or API key.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import { AgentToolsView } from "@/components/views/agent-tools-view";
|
|||||||
import { InterviewView } from "@/components/views/interview-view";
|
import { InterviewView } from "@/components/views/interview-view";
|
||||||
import { ContextView } from "@/components/views/context-view";
|
import { ContextView } from "@/components/views/context-view";
|
||||||
import { ProfilesView } from "@/components/views/profiles-view";
|
import { ProfilesView } from "@/components/views/profiles-view";
|
||||||
|
import { SetupView } from "@/components/views/setup-view";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { currentView, setIpcConnected, theme, currentProject } = useAppStore();
|
const { currentView, setCurrentView, setIpcConnected, theme, currentProject } = useAppStore();
|
||||||
|
const { isFirstRun, setupComplete } = useSetupStore();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Compute the effective theme: project theme takes priority over global theme
|
// Compute the effective theme: project theme takes priority over global theme
|
||||||
@@ -27,6 +30,24 @@ export default function Home() {
|
|||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check if this is first run and redirect to setup if needed
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[Setup Flow] Checking setup state:", {
|
||||||
|
isMounted,
|
||||||
|
isFirstRun,
|
||||||
|
setupComplete,
|
||||||
|
currentView,
|
||||||
|
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMounted && isFirstRun && !setupComplete) {
|
||||||
|
console.log("[Setup Flow] Redirecting to setup wizard (first run, not complete)");
|
||||||
|
setCurrentView("setup");
|
||||||
|
} else if (isMounted && setupComplete) {
|
||||||
|
console.log("[Setup Flow] Setup already complete, showing normal view");
|
||||||
|
}
|
||||||
|
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
|
||||||
|
|
||||||
// Test IPC connection on mount
|
// Test IPC connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
@@ -100,6 +121,8 @@ export default function Home() {
|
|||||||
switch (currentView) {
|
switch (currentView) {
|
||||||
case "welcome":
|
case "welcome":
|
||||||
return <WelcomeView />;
|
return <WelcomeView />;
|
||||||
|
case "setup":
|
||||||
|
return <SetupView />;
|
||||||
case "board":
|
case "board":
|
||||||
return <BoardView />;
|
return <BoardView />;
|
||||||
case "spec":
|
case "spec":
|
||||||
@@ -121,6 +144,21 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup view is full-screen without sidebar
|
||||||
|
if (currentView === "setup") {
|
||||||
|
return (
|
||||||
|
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||||
|
<SetupView />
|
||||||
|
{/* Environment indicator */}
|
||||||
|
{isMounted && !isElectron() && (
|
||||||
|
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
|
||||||
|
Web Mode (Mock IPC)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
NAV_SHORTCUTS,
|
useKeyboardShortcutsConfig,
|
||||||
UI_SHORTCUTS,
|
|
||||||
ACTION_SHORTCUTS,
|
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
|
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
|
||||||
@@ -213,6 +211,9 @@ export function Sidebar() {
|
|||||||
theme: globalTheme,
|
theme: globalTheme,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Get customizable keyboard shortcuts
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
|
||||||
// State for project picker dropdown
|
// State for project picker dropdown
|
||||||
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
|
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
|
||||||
const [projectSearchQuery, setProjectSearchQuery] = useState("");
|
const [projectSearchQuery, setProjectSearchQuery] = useState("");
|
||||||
@@ -527,13 +528,13 @@ export function Sidebar() {
|
|||||||
id: "board",
|
id: "board",
|
||||||
label: "Kanban Board",
|
label: "Kanban Board",
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
shortcut: NAV_SHORTCUTS.board,
|
shortcut: shortcuts.board,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "agent",
|
id: "agent",
|
||||||
label: "Agent Runner",
|
label: "Agent Runner",
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
shortcut: NAV_SHORTCUTS.agent,
|
shortcut: shortcuts.agent,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -544,25 +545,25 @@ export function Sidebar() {
|
|||||||
id: "spec",
|
id: "spec",
|
||||||
label: "Spec Editor",
|
label: "Spec Editor",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
shortcut: NAV_SHORTCUTS.spec,
|
shortcut: shortcuts.spec,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "context",
|
id: "context",
|
||||||
label: "Context",
|
label: "Context",
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
shortcut: NAV_SHORTCUTS.context,
|
shortcut: shortcuts.context,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tools",
|
id: "tools",
|
||||||
label: "Agent Tools",
|
label: "Agent Tools",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
shortcut: NAV_SHORTCUTS.tools,
|
shortcut: shortcuts.tools,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "profiles",
|
id: "profiles",
|
||||||
label: "AI Profiles",
|
label: "AI Profiles",
|
||||||
icon: UserCircle,
|
icon: UserCircle,
|
||||||
shortcut: NAV_SHORTCUTS.profiles,
|
shortcut: shortcuts.profiles,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -610,26 +611,26 @@ export function Sidebar() {
|
|||||||
|
|
||||||
// Build keyboard shortcuts for navigation
|
// Build keyboard shortcuts for navigation
|
||||||
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
const shortcuts: KeyboardShortcut[] = [];
|
const shortcutsList: KeyboardShortcut[] = [];
|
||||||
|
|
||||||
// Sidebar toggle shortcut - always available
|
// Sidebar toggle shortcut - always available
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: UI_SHORTCUTS.toggleSidebar,
|
key: shortcuts.toggleSidebar,
|
||||||
action: () => toggleSidebar(),
|
action: () => toggleSidebar(),
|
||||||
description: "Toggle sidebar",
|
description: "Toggle sidebar",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open project shortcut - opens the folder selection dialog directly
|
// Open project shortcut - opens the folder selection dialog directly
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.openProject,
|
key: shortcuts.openProject,
|
||||||
action: () => handleOpenFolder(),
|
action: () => handleOpenFolder(),
|
||||||
description: "Open folder selection dialog",
|
description: "Open folder selection dialog",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Project picker shortcut - only when we have projects
|
// Project picker shortcut - only when we have projects
|
||||||
if (projects.length > 0) {
|
if (projects.length > 0) {
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.projectPicker,
|
key: shortcuts.projectPicker,
|
||||||
action: () => setIsProjectPickerOpen((prev) => !prev),
|
action: () => setIsProjectPickerOpen((prev) => !prev),
|
||||||
description: "Toggle project picker",
|
description: "Toggle project picker",
|
||||||
});
|
});
|
||||||
@@ -637,13 +638,13 @@ export function Sidebar() {
|
|||||||
|
|
||||||
// Project cycling shortcuts - only when we have project history
|
// Project cycling shortcuts - only when we have project history
|
||||||
if (projectHistory.length > 1) {
|
if (projectHistory.length > 1) {
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.cyclePrevProject,
|
key: shortcuts.cyclePrevProject,
|
||||||
action: () => cyclePrevProject(),
|
action: () => cyclePrevProject(),
|
||||||
description: "Cycle to previous project (MRU)",
|
description: "Cycle to previous project (MRU)",
|
||||||
});
|
});
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.cycleNextProject,
|
key: shortcuts.cycleNextProject,
|
||||||
action: () => cycleNextProject(),
|
action: () => cycleNextProject(),
|
||||||
description: "Cycle to next project (LRU)",
|
description: "Cycle to next project (LRU)",
|
||||||
});
|
});
|
||||||
@@ -654,7 +655,7 @@ export function Sidebar() {
|
|||||||
navSections.forEach((section) => {
|
navSections.forEach((section) => {
|
||||||
section.items.forEach((item) => {
|
section.items.forEach((item) => {
|
||||||
if (item.shortcut) {
|
if (item.shortcut) {
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: item.shortcut,
|
key: item.shortcut,
|
||||||
action: () => setCurrentView(item.id as any),
|
action: () => setCurrentView(item.id as any),
|
||||||
description: `Navigate to ${item.label}`,
|
description: `Navigate to ${item.label}`,
|
||||||
@@ -664,15 +665,16 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add settings shortcut
|
// Add settings shortcut
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: NAV_SHORTCUTS.settings,
|
key: shortcuts.settings,
|
||||||
action: () => setCurrentView("settings"),
|
action: () => setCurrentView("settings"),
|
||||||
description: "Navigate to Settings",
|
description: "Navigate to Settings",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts;
|
return shortcutsList;
|
||||||
}, [
|
}, [
|
||||||
|
shortcuts,
|
||||||
currentProject,
|
currentProject,
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
@@ -681,6 +683,7 @@ export function Sidebar() {
|
|||||||
projectHistory.length,
|
projectHistory.length,
|
||||||
cyclePrevProject,
|
cyclePrevProject,
|
||||||
cycleNextProject,
|
cycleNextProject,
|
||||||
|
navSections,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
@@ -719,7 +722,7 @@ export function Sidebar() {
|
|||||||
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
|
className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70"
|
||||||
data-testid="sidebar-toggle-shortcut"
|
data-testid="sidebar-toggle-shortcut"
|
||||||
>
|
>
|
||||||
{UI_SHORTCUTS.toggleSidebar}
|
{shortcuts.toggleSidebar}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -772,12 +775,12 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleOpenFolder}
|
onClick={handleOpenFolder}
|
||||||
className="group flex items-center justify-center flex-1 px-3 py-2.5 rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border"
|
className="group flex items-center justify-center flex-1 px-3 py-2.5 rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border"
|
||||||
title={`Open Folder (${ACTION_SHORTCUTS.openProject})`}
|
title={`Open Folder (${shortcuts.openProject})`}
|
||||||
data-testid="open-project-button"
|
data-testid="open-project-button"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 shrink-0" />
|
<FolderOpen className="w-4 h-4 shrink-0" />
|
||||||
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
||||||
{ACTION_SHORTCUTS.openProject}
|
{shortcuts.openProject}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -819,7 +822,7 @@ export function Sidebar() {
|
|||||||
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||||
data-testid="project-picker-shortcut"
|
data-testid="project-picker-shortcut"
|
||||||
>
|
>
|
||||||
{ACTION_SHORTCUTS.projectPicker}
|
{shortcuts.projectPicker}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
@@ -955,14 +958,14 @@ export function Sidebar() {
|
|||||||
<Undo2 className="w-4 h-4 mr-2" />
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Previous</span>
|
<span className="flex-1">Previous</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{ACTION_SHORTCUTS.cyclePrevProject}
|
{shortcuts.cyclePrevProject}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
|
||||||
<Redo2 className="w-4 h-4 mr-2" />
|
<Redo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Next</span>
|
<span className="flex-1">Next</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{ACTION_SHORTCUTS.cycleNextProject}
|
{shortcuts.cycleNextProject}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
|
||||||
@@ -1118,7 +1121,7 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
data-testid="shortcut-settings"
|
data-testid="shortcut-settings"
|
||||||
>
|
>
|
||||||
{NAV_SHORTCUTS.settings}
|
{shortcuts.settings}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
|
|||||||
@@ -25,19 +25,61 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { SessionListItem } from "@/types/electron";
|
import type { SessionListItem } from "@/types/electron";
|
||||||
import { ACTION_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
// Random session name generator
|
// Random session name generator
|
||||||
const adjectives = [
|
const adjectives = [
|
||||||
"Swift", "Bright", "Clever", "Dynamic", "Eager", "Focused", "Gentle", "Happy",
|
"Swift",
|
||||||
"Inventive", "Jolly", "Keen", "Lively", "Mighty", "Noble", "Optimal", "Peaceful",
|
"Bright",
|
||||||
"Quick", "Radiant", "Smart", "Tranquil", "Unique", "Vibrant", "Wise", "Zealous"
|
"Clever",
|
||||||
|
"Dynamic",
|
||||||
|
"Eager",
|
||||||
|
"Focused",
|
||||||
|
"Gentle",
|
||||||
|
"Happy",
|
||||||
|
"Inventive",
|
||||||
|
"Jolly",
|
||||||
|
"Keen",
|
||||||
|
"Lively",
|
||||||
|
"Mighty",
|
||||||
|
"Noble",
|
||||||
|
"Optimal",
|
||||||
|
"Peaceful",
|
||||||
|
"Quick",
|
||||||
|
"Radiant",
|
||||||
|
"Smart",
|
||||||
|
"Tranquil",
|
||||||
|
"Unique",
|
||||||
|
"Vibrant",
|
||||||
|
"Wise",
|
||||||
|
"Zealous",
|
||||||
];
|
];
|
||||||
|
|
||||||
const nouns = [
|
const nouns = [
|
||||||
"Agent", "Builder", "Coder", "Developer", "Explorer", "Forge", "Garden", "Helper",
|
"Agent",
|
||||||
"Innovator", "Journey", "Kernel", "Lighthouse", "Mission", "Navigator", "Oracle",
|
"Builder",
|
||||||
"Project", "Quest", "Runner", "Spark", "Task", "Unicorn", "Voyage", "Workshop"
|
"Coder",
|
||||||
|
"Developer",
|
||||||
|
"Explorer",
|
||||||
|
"Forge",
|
||||||
|
"Garden",
|
||||||
|
"Helper",
|
||||||
|
"Innovator",
|
||||||
|
"Journey",
|
||||||
|
"Kernel",
|
||||||
|
"Lighthouse",
|
||||||
|
"Mission",
|
||||||
|
"Navigator",
|
||||||
|
"Oracle",
|
||||||
|
"Project",
|
||||||
|
"Quest",
|
||||||
|
"Runner",
|
||||||
|
"Spark",
|
||||||
|
"Task",
|
||||||
|
"Unicorn",
|
||||||
|
"Voyage",
|
||||||
|
"Workshop",
|
||||||
];
|
];
|
||||||
|
|
||||||
function generateRandomSessionName(): string {
|
function generateRandomSessionName(): string {
|
||||||
@@ -62,13 +104,16 @@ export function SessionManager({
|
|||||||
isCurrentSessionThinking = false,
|
isCurrentSessionThinking = false,
|
||||||
onQuickCreateRef,
|
onQuickCreateRef,
|
||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
|
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
|
||||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newSessionName, setNewSessionName] = useState("");
|
const [newSessionName, setNewSessionName] = useState("");
|
||||||
const [runningSessions, setRunningSessions] = useState<Set<string>>(new Set());
|
const [runningSessions, setRunningSessions] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -85,7 +130,10 @@ export function SessionManager({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore errors for individual session checks
|
// Ignore errors for individual session checks
|
||||||
console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
|
console.warn(
|
||||||
|
`[SessionManager] Failed to check running state for ${session.id}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +282,8 @@ export function SessionManager({
|
|||||||
|
|
||||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||||
const displayedSessions = activeTab === "active" ? activeSessions : archivedSessions;
|
const displayedSessions =
|
||||||
|
activeTab === "active" ? activeSessions : archivedSessions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col">
|
<Card className="h-full flex flex-col">
|
||||||
@@ -246,10 +295,10 @@ export function SessionManager({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleQuickCreateSession}
|
onClick={handleQuickCreateSession}
|
||||||
hotkey={ACTION_SHORTCUTS.newSession}
|
hotkey={shortcuts.newSession}
|
||||||
hotkeyActive={false}
|
hotkeyActive={false}
|
||||||
data-testid="new-session-button"
|
data-testid="new-session-button"
|
||||||
title={`New Session (${ACTION_SHORTCUTS.newSession})`}
|
title={`New Session (${shortcuts.newSession})`}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
New
|
New
|
||||||
@@ -259,7 +308,9 @@ export function SessionManager({
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as "active" | "archived")}
|
onValueChange={(value) =>
|
||||||
|
setActiveTab(value as "active" | "archived")
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
@@ -275,7 +326,10 @@ export function SessionManager({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex-1 overflow-y-auto space-y-2" data-testid="session-list">
|
<CardContent
|
||||||
|
className="flex-1 overflow-y-auto space-y-2"
|
||||||
|
data-testid="session-list"
|
||||||
|
>
|
||||||
{/* Create new session */}
|
{/* Create new session */}
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div className="p-3 border rounded-lg bg-muted/50">
|
<div className="p-3 border rounded-lg bg-muted/50">
|
||||||
@@ -330,8 +384,7 @@ export function SessionManager({
|
|||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter")
|
if (e.key === "Enter") handleRenameSession(session.id);
|
||||||
handleRenameSession(session.id);
|
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName("");
|
setEditingName("");
|
||||||
@@ -368,13 +421,17 @@ export function SessionManager({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||||
{((currentSessionId === session.id && isCurrentSessionThinking) || runningSessions.has(session.id)) ? (
|
{(currentSessionId === session.id &&
|
||||||
|
isCurrentSessionThinking) ||
|
||||||
|
runningSessions.has(session.id) ? (
|
||||||
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
<h3 className="font-medium truncate">{session.name}</h3>
|
<h3 className="font-medium truncate">{session.name}</h3>
|
||||||
{((currentSessionId === session.id && isCurrentSessionThinking) || runningSessions.has(session.id)) && (
|
{((currentSessionId === session.id &&
|
||||||
|
isCurrentSessionThinking) ||
|
||||||
|
runningSessions.has(session.id)) && (
|
||||||
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||||
thinking...
|
thinking...
|
||||||
</span>
|
</span>
|
||||||
@@ -458,7 +515,9 @@ export function SessionManager({
|
|||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{activeTab === "active" ? "No active sessions" : "No archived sessions"}
|
{activeTab === "active"
|
||||||
|
? "No active sessions"
|
||||||
|
: "No archived sessions"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
{activeTab === "active"
|
{activeTab === "active"
|
||||||
|
|||||||
@@ -203,8 +203,9 @@ export function HotkeyButton({
|
|||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (!config || !hotkeyActive || disabled) return;
|
if (!config || !hotkeyActive || disabled) return;
|
||||||
|
|
||||||
// Don't trigger when typing in inputs (unless explicitly scoped)
|
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
|
||||||
if (!scopeRef && isInputElement(document.activeElement)) {
|
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
|
||||||
|
if (!scopeRef && !config.cmdCtrl && isInputElement(document.activeElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import { Markdown } from "@/components/ui/markdown";
|
|||||||
import type { ImageAttachment } from "@/store/app-store";
|
import type { ImageAttachment } from "@/store/app-store";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
ACTION_SHORTCUTS,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||||
@@ -417,12 +418,12 @@ export function AgentView() {
|
|||||||
|
|
||||||
// Keyboard shortcuts for agent view
|
// Keyboard shortcuts for agent view
|
||||||
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
const shortcuts: KeyboardShortcut[] = [];
|
const shortcutsList: KeyboardShortcut[] = [];
|
||||||
|
|
||||||
// New session shortcut - only when in agent view with a project
|
// New session shortcut - only when in agent view with a project
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.newSession,
|
key: shortcuts.newSession,
|
||||||
action: () => {
|
action: () => {
|
||||||
if (quickCreateSessionRef.current) {
|
if (quickCreateSessionRef.current) {
|
||||||
quickCreateSessionRef.current();
|
quickCreateSessionRef.current();
|
||||||
@@ -432,8 +433,8 @@ export function AgentView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts;
|
return shortcutsList;
|
||||||
}, [currentProject]);
|
}, [currentProject, shortcuts]);
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
// Register keyboard shortcuts
|
||||||
useKeyboardShortcuts(agentShortcuts);
|
useKeyboardShortcuts(agentShortcuts);
|
||||||
@@ -592,13 +593,16 @@ export function AgentView() {
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[80%]",
|
"max-w-[80%]",
|
||||||
message.role === "user" &&
|
message.role === "user"
|
||||||
"bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "border-l-4 border-primary bg-card"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<Markdown className="text-sm">{message.content}</Markdown>
|
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
|
||||||
|
{message.content}
|
||||||
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap">
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
{message.content}
|
{message.content}
|
||||||
@@ -609,7 +613,7 @@ export function AgentView() {
|
|||||||
"text-xs mt-2",
|
"text-xs mt-2",
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "text-primary-foreground/70"
|
? "text-primary-foreground/70"
|
||||||
: "text-muted-foreground"
|
: "text-primary/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{new Date(message.timestamp).toLocaleTimeString()}
|
{new Date(message.timestamp).toLocaleTimeString()}
|
||||||
@@ -624,11 +628,11 @@ export function AgentView() {
|
|||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
<Bot className="w-4 h-4 text-primary" />
|
<Bot className="w-4 h-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="border-l-4 border-primary bg-card">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-primary">
|
||||||
Thinking...
|
Thinking...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
ACTION_SHORTCUTS,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useWindowState } from "@/hooks/use-window-state";
|
import { useWindowState } from "@/hooks/use-window-state";
|
||||||
@@ -176,7 +176,10 @@ const CODEX_MODELS: ModelOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Profile icon mapping
|
// Profile icon mapping
|
||||||
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const PROFILE_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
Brain,
|
Brain,
|
||||||
Zap,
|
Zap,
|
||||||
Scale,
|
Scale,
|
||||||
@@ -203,6 +206,7 @@ export function BoardView() {
|
|||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
@@ -233,9 +237,8 @@ export function BoardView() {
|
|||||||
DescriptionImagePath[]
|
DescriptionImagePath[]
|
||||||
>([]);
|
>([]);
|
||||||
// Preview maps to persist image previews across tab switches
|
// Preview maps to persist image previews across tab switches
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
|
const [newFeaturePreviewMap, setNewFeaturePreviewMap] =
|
||||||
() => new Map()
|
useState<ImagePreviewMap>(() => new Map());
|
||||||
);
|
|
||||||
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(
|
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(
|
||||||
() => new Map()
|
() => new Map()
|
||||||
);
|
);
|
||||||
@@ -313,14 +316,14 @@ export function BoardView() {
|
|||||||
|
|
||||||
// Keyboard shortcuts for this view
|
// Keyboard shortcuts for this view
|
||||||
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const boardShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
const shortcuts: KeyboardShortcut[] = [
|
const shortcutsList: KeyboardShortcut[] = [
|
||||||
{
|
{
|
||||||
key: ACTION_SHORTCUTS.addFeature,
|
key: shortcuts.addFeature,
|
||||||
action: () => setShowAddDialog(true),
|
action: () => setShowAddDialog(true),
|
||||||
description: "Add new feature",
|
description: "Add new feature",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: ACTION_SHORTCUTS.startNext,
|
key: shortcuts.startNext,
|
||||||
action: () => startNextFeaturesRef.current(),
|
action: () => startNextFeaturesRef.current(),
|
||||||
description: "Start next features from backlog",
|
description: "Start next features from backlog",
|
||||||
},
|
},
|
||||||
@@ -335,7 +338,7 @@ export function BoardView() {
|
|||||||
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
|
inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => {
|
||||||
// Keys 1-9 for first 9 cards, 0 for 10th card
|
// Keys 1-9 for first 9 cards, 0 for 10th card
|
||||||
const key = index === 9 ? "0" : String(index + 1);
|
const key = index === 9 ? "0" : String(index + 1);
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key,
|
key,
|
||||||
action: () => {
|
action: () => {
|
||||||
setOutputFeature(feature);
|
setOutputFeature(feature);
|
||||||
@@ -345,8 +348,8 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return shortcuts;
|
return shortcutsList;
|
||||||
}, [inProgressFeaturesForShortcuts]);
|
}, [inProgressFeaturesForShortcuts, shortcuts]);
|
||||||
useKeyboardShortcuts(boardShortcuts);
|
useKeyboardShortcuts(boardShortcuts);
|
||||||
|
|
||||||
// Prevent hydration issues
|
// Prevent hydration issues
|
||||||
@@ -1510,7 +1513,10 @@ export function BoardView() {
|
|||||||
const isSelected = selectedModel === option.id;
|
const isSelected = selectedModel === option.id;
|
||||||
const isCodex = option.provider === "codex";
|
const isCodex = option.provider === "codex";
|
||||||
// Shorter display names for compact view
|
// Shorter display names for compact view
|
||||||
const shortName = option.label.replace("Claude ", "").replace("GPT-5.1 Codex ", "").replace("GPT-5.1 ", "");
|
const shortName = option.label
|
||||||
|
.replace("Claude ", "")
|
||||||
|
.replace("GPT-5.1 Codex ", "")
|
||||||
|
.replace("GPT-5.1 ", "");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -1626,7 +1632,7 @@ export function BoardView() {
|
|||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowAddDialog(true)}
|
onClick={() => setShowAddDialog(true)}
|
||||||
hotkey={ACTION_SHORTCUTS.addFeature}
|
hotkey={shortcuts.addFeature}
|
||||||
hotkeyActive={false}
|
hotkeyActive={false}
|
||||||
data-testid="add-feature-button"
|
data-testid="add-feature-button"
|
||||||
>
|
>
|
||||||
@@ -1794,7 +1800,7 @@ export function BoardView() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||||
onClick={handleStartNextFeatures}
|
onClick={handleStartNextFeatures}
|
||||||
hotkey={ACTION_SHORTCUTS.startNext}
|
hotkey={shortcuts.startNext}
|
||||||
hotkeyActive={false}
|
hotkeyActive={false}
|
||||||
data-testid="start-next-button"
|
data-testid="start-next-button"
|
||||||
>
|
>
|
||||||
@@ -1867,7 +1873,9 @@ export function BoardView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Feature Dialog */}
|
{/* Add Feature Dialog */}
|
||||||
<Dialog open={showAddDialog} onOpenChange={(open) => {
|
<Dialog
|
||||||
|
open={showAddDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
setShowAddDialog(open);
|
setShowAddDialog(open);
|
||||||
// Clear preview map, validation error, and reset advanced options when dialog closes
|
// Clear preview map, validation error, and reset advanced options when dialog closes
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -1875,18 +1883,19 @@ export function BoardView() {
|
|||||||
setShowAdvancedOptions(false);
|
setShowAdvancedOptions(false);
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
<DialogContent
|
|
||||||
compact={!isMaximized}
|
|
||||||
data-testid="add-feature-dialog"
|
|
||||||
>
|
>
|
||||||
|
<DialogContent compact={!isMaximized} data-testid="add-feature-dialog">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Feature</DialogTitle>
|
<DialogTitle>Add New Feature</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a new feature card for the Kanban board.
|
Create a new feature card for the Kanban board.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
<Tabs
|
||||||
|
defaultValue="prompt"
|
||||||
|
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||||
|
>
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
@@ -1949,7 +1958,8 @@ export function BoardView() {
|
|||||||
Simple Mode Active
|
Simple Mode Active
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
Only showing AI profiles. Advanced model tweaking is
|
||||||
|
hidden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -1959,7 +1969,7 @@ export function BoardView() {
|
|||||||
data-testid="show-advanced-options-toggle"
|
data-testid="show-advanced-options-toggle"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
{showAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1978,9 +1988,12 @@ export function BoardView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{aiProfiles.slice(0, 6).map((profile) => {
|
{aiProfiles.slice(0, 6).map((profile) => {
|
||||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
const IconComponent = profile.icon
|
||||||
|
? PROFILE_ICONS[profile.icon]
|
||||||
|
: Brain;
|
||||||
const isCodex = profile.provider === "codex";
|
const isCodex = profile.provider === "codex";
|
||||||
const isSelected = newFeature.model === profile.model &&
|
const isSelected =
|
||||||
|
newFeature.model === profile.model &&
|
||||||
newFeature.thinkingLevel === profile.thinkingLevel;
|
newFeature.thinkingLevel === profile.thinkingLevel;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1994,7 +2007,8 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
if (profile.thinkingLevel === "ultrathink") {
|
if (profile.thinkingLevel === "ultrathink") {
|
||||||
toast.warning("Ultrathink Selected", {
|
toast.warning("Ultrathink Selected", {
|
||||||
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
description:
|
||||||
|
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2007,21 +2021,29 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
data-testid={`profile-quick-select-${profile.id}`}
|
data-testid={`profile-quick-select-${profile.id}`}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<IconComponent className={cn(
|
<IconComponent
|
||||||
|
className={cn(
|
||||||
"w-4 h-4",
|
"w-4 h-4",
|
||||||
isCodex ? "text-emerald-500" : "text-primary"
|
isCodex ? "text-emerald-500" : "text-primary"
|
||||||
)} />
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">{profile.name}</p>
|
<p className="text-sm font-medium truncate">
|
||||||
|
{profile.name}
|
||||||
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground truncate">
|
<p className="text-[10px] text-muted-foreground truncate">
|
||||||
{profile.model}{profile.thinkingLevel !== "none" && ` + ${profile.thinkingLevel}`}
|
{profile.model}
|
||||||
|
{profile.thinkingLevel !== "none" &&
|
||||||
|
` + ${profile.thinkingLevel}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -2045,7 +2067,10 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && <div className="border-t border-border" />}
|
{aiProfiles.length > 0 &&
|
||||||
|
(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Claude Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
{/* Claude Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
@@ -2080,16 +2105,28 @@ export function BoardView() {
|
|||||||
Thinking Level
|
Thinking Level
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(["none", "low", "medium", "high", "ultrathink"] as ThinkingLevel[]).map((level) => (
|
{(
|
||||||
|
[
|
||||||
|
"none",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"ultrathink",
|
||||||
|
] as ThinkingLevel[]
|
||||||
|
).map((level) => (
|
||||||
<button
|
<button
|
||||||
key={level}
|
key={level}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNewFeature({ ...newFeature, thinkingLevel: level });
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
thinkingLevel: level,
|
||||||
|
});
|
||||||
if (level === "ultrathink") {
|
if (level === "ultrathink") {
|
||||||
toast.warning("Ultrathink Selected", {
|
toast.warning("Ultrathink Selected", {
|
||||||
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
description:
|
||||||
duration: 5000
|
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
||||||
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2110,7 +2147,8 @@ export function BoardView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Higher levels give more time to reason through complex problems.
|
Higher levels give more time to reason through complex
|
||||||
|
problems.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2118,7 +2156,9 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && <div className="border-t border-border" />}
|
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
{/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */}
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
{(!showProfilesOnly || showAdvancedOptions) && (
|
||||||
@@ -2132,10 +2172,7 @@ export function BoardView() {
|
|||||||
CLI
|
CLI
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{renderModelOptions(
|
{renderModelOptions(CODEX_MODELS, newFeature.model, (model) =>
|
||||||
CODEX_MODELS,
|
|
||||||
newFeature.model,
|
|
||||||
(model) =>
|
|
||||||
setNewFeature({
|
setNewFeature({
|
||||||
...newFeature,
|
...newFeature,
|
||||||
model,
|
model,
|
||||||
@@ -2156,12 +2193,18 @@ export function BoardView() {
|
|||||||
id="skip-tests"
|
id="skip-tests"
|
||||||
checked={newFeature.skipTests}
|
checked={newFeature.skipTests}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
setNewFeature({ ...newFeature, skipTests: checked === true })
|
setNewFeature({
|
||||||
|
...newFeature,
|
||||||
|
skipTests: checked === true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
data-testid="skip-tests-checkbox"
|
data-testid="skip-tests-checkbox"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="skip-tests" className="text-sm cursor-pointer">
|
<Label
|
||||||
|
htmlFor="skip-tests"
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
Skip automated testing
|
Skip automated testing
|
||||||
</Label>
|
</Label>
|
||||||
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
@@ -2242,7 +2285,10 @@ export function BoardView() {
|
|||||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{editingFeature && (
|
{editingFeature && (
|
||||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
<Tabs
|
||||||
|
defaultValue="prompt"
|
||||||
|
className="py-4 flex-1 min-h-0 flex flex-col"
|
||||||
|
>
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
@@ -2302,17 +2348,20 @@ export function BoardView() {
|
|||||||
Simple Mode Active
|
Simple Mode Active
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
Only showing AI profiles. Advanced model tweaking is
|
||||||
|
hidden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
|
onClick={() =>
|
||||||
|
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
||||||
|
}
|
||||||
data-testid="edit-show-advanced-options-toggle"
|
data-testid="edit-show-advanced-options-toggle"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2331,10 +2380,14 @@ export function BoardView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{aiProfiles.slice(0, 6).map((profile) => {
|
{aiProfiles.slice(0, 6).map((profile) => {
|
||||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
const IconComponent = profile.icon
|
||||||
|
? PROFILE_ICONS[profile.icon]
|
||||||
|
: Brain;
|
||||||
const isCodex = profile.provider === "codex";
|
const isCodex = profile.provider === "codex";
|
||||||
const isSelected = editingFeature.model === profile.model &&
|
const isSelected =
|
||||||
editingFeature.thinkingLevel === profile.thinkingLevel;
|
editingFeature.model === profile.model &&
|
||||||
|
editingFeature.thinkingLevel ===
|
||||||
|
profile.thinkingLevel;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={profile.id}
|
key={profile.id}
|
||||||
@@ -2347,7 +2400,8 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
if (profile.thinkingLevel === "ultrathink") {
|
if (profile.thinkingLevel === "ultrathink") {
|
||||||
toast.warning("Ultrathink Selected", {
|
toast.warning("Ultrathink Selected", {
|
||||||
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
description:
|
||||||
|
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2360,21 +2414,31 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
data-testid={`edit-profile-quick-select-${profile.id}`}
|
data-testid={`edit-profile-quick-select-${profile.id}`}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<IconComponent className={cn(
|
<IconComponent
|
||||||
|
className={cn(
|
||||||
"w-4 h-4",
|
"w-4 h-4",
|
||||||
isCodex ? "text-emerald-500" : "text-primary"
|
isCodex
|
||||||
)} />
|
? "text-emerald-500"
|
||||||
|
: "text-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">{profile.name}</p>
|
<p className="text-sm font-medium truncate">
|
||||||
|
{profile.name}
|
||||||
|
</p>
|
||||||
<p className="text-[10px] text-muted-foreground truncate">
|
<p className="text-[10px] text-muted-foreground truncate">
|
||||||
{profile.model}{profile.thinkingLevel !== "none" && ` + ${profile.thinkingLevel}`}
|
{profile.model}
|
||||||
|
{profile.thinkingLevel !== "none" &&
|
||||||
|
` + ${profile.thinkingLevel}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -2388,7 +2452,10 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && <div className="border-t border-border" />}
|
{aiProfiles.length > 0 &&
|
||||||
|
(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Claude Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
{/* Claude Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
@@ -2424,22 +2491,35 @@ export function BoardView() {
|
|||||||
Thinking Level
|
Thinking Level
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(["none", "low", "medium", "high", "ultrathink"] as ThinkingLevel[]).map((level) => (
|
{(
|
||||||
|
[
|
||||||
|
"none",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"ultrathink",
|
||||||
|
] as ThinkingLevel[]
|
||||||
|
).map((level) => (
|
||||||
<button
|
<button
|
||||||
key={level}
|
key={level}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingFeature({ ...editingFeature, thinkingLevel: level });
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
thinkingLevel: level,
|
||||||
|
});
|
||||||
if (level === "ultrathink") {
|
if (level === "ultrathink") {
|
||||||
toast.warning("Ultrathink Selected", {
|
toast.warning("Ultrathink Selected", {
|
||||||
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
description:
|
||||||
duration: 5000
|
"Ultrathink uses extensive reasoning (45-180s, ~$0.48/task). Best for complex architecture, migrations, or debugging.",
|
||||||
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]",
|
||||||
(editingFeature.thinkingLevel ?? "none") === level
|
(editingFeature.thinkingLevel ?? "none") ===
|
||||||
|
level
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
: "bg-background hover:bg-accent border-input"
|
: "bg-background hover:bg-accent border-input"
|
||||||
)}
|
)}
|
||||||
@@ -2454,7 +2534,8 @@ export function BoardView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Higher levels give more time to reason through complex problems.
|
Higher levels give more time to reason through complex
|
||||||
|
problems.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2462,7 +2543,9 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && <div className="border-t border-border" />}
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Codex Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
{/* Codex Models Section - Hidden when showProfilesOnly is true and showEditAdvancedOptions is false */}
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
@@ -2495,7 +2578,10 @@ export function BoardView() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Testing Tab */}
|
{/* Testing Tab */}
|
||||||
<TabsContent value="testing" className="space-y-4 overflow-y-auto">
|
<TabsContent
|
||||||
|
value="testing"
|
||||||
|
className="space-y-4 overflow-y-auto"
|
||||||
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="edit-skip-tests"
|
id="edit-skip-tests"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
ACTION_SHORTCUTS,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +44,7 @@ interface ContextFile {
|
|||||||
|
|
||||||
export function ContextView() {
|
export function ContextView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
||||||
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -64,12 +65,12 @@ export function ContextView() {
|
|||||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: ACTION_SHORTCUTS.addContextFile,
|
key: shortcuts.addContextFile,
|
||||||
action: () => setIsAddDialogOpen(true),
|
action: () => setIsAddDialogOpen(true),
|
||||||
description: "Add new context file",
|
description: "Add new context file",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[]
|
[shortcuts]
|
||||||
);
|
);
|
||||||
useKeyboardShortcuts(contextShortcuts);
|
useKeyboardShortcuts(contextShortcuts);
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ export function ContextView() {
|
|||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
hotkey={ACTION_SHORTCUTS.addContextFile}
|
hotkey={shortcuts.addContextFile}
|
||||||
hotkeyActive={false}
|
hotkeyActive={false}
|
||||||
data-testid="add-context-file"
|
data-testid="add-context-file"
|
||||||
>
|
>
|
||||||
@@ -501,7 +502,9 @@ export function ContextView() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||||
<p className="text-foreground-secondary">Select a file to view or edit</p>
|
<p className="text-foreground-secondary">
|
||||||
|
Select a file to view or edit
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
Or drop files here to add them
|
Or drop files here to add them
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -431,17 +431,22 @@ export function InterviewView() {
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[80%]",
|
"max-w-[80%]",
|
||||||
message.role === "user" && "bg-primary text-primary-foreground"
|
message.role === "user"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "border-l-4 border-primary bg-card"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
<p className={cn(
|
||||||
|
"text-sm whitespace-pre-wrap",
|
||||||
|
message.role === "assistant" && "text-primary"
|
||||||
|
)}>{message.content}</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs mt-2",
|
"text-xs mt-2",
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "text-primary-foreground/70"
|
? "text-primary-foreground/70"
|
||||||
: "text-muted-foreground"
|
: "text-primary/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.timestamp.toLocaleTimeString()}
|
{message.timestamp.toLocaleTimeString()}
|
||||||
@@ -456,11 +461,11 @@ export function InterviewView() {
|
|||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
<Bot className="w-4 h-4 text-primary" />
|
<Bot className="w-4 h-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="border-l-4 border-primary bg-card">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-primary">
|
||||||
Generating specification...
|
Generating specification...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
|
import {
|
||||||
|
useAppStore,
|
||||||
|
AIProfile,
|
||||||
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
ModelProvider,
|
||||||
|
} from "@/store/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -10,7 +16,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
import { cn, modelSupportsThinking } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
ACTION_SHORTCUTS,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +59,10 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
// Icon mapping for profiles
|
// Icon mapping for profiles
|
||||||
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
const PROFILE_ICONS: Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
> = {
|
||||||
Brain,
|
Brain,
|
||||||
Zap,
|
Zap,
|
||||||
Scale,
|
Scale,
|
||||||
@@ -446,8 +455,14 @@ function ProfileForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilesView() {
|
export function ProfilesView() {
|
||||||
const { aiProfiles, addAIProfile, updateAIProfile, removeAIProfile, reorderAIProfiles } =
|
const {
|
||||||
useAppStore();
|
aiProfiles,
|
||||||
|
addAIProfile,
|
||||||
|
updateAIProfile,
|
||||||
|
removeAIProfile,
|
||||||
|
reorderAIProfiles,
|
||||||
|
} = useAppStore();
|
||||||
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
||||||
@@ -516,17 +531,17 @@ export function ProfilesView() {
|
|||||||
|
|
||||||
// Build keyboard shortcuts for profiles view
|
// Build keyboard shortcuts for profiles view
|
||||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
||||||
const shortcuts: KeyboardShortcut[] = [];
|
const shortcutsList: KeyboardShortcut[] = [];
|
||||||
|
|
||||||
// Add profile shortcut - when in profiles view
|
// Add profile shortcut - when in profiles view
|
||||||
shortcuts.push({
|
shortcutsList.push({
|
||||||
key: ACTION_SHORTCUTS.addProfile,
|
key: shortcuts.addProfile,
|
||||||
action: () => setShowAddDialog(true),
|
action: () => setShowAddDialog(true),
|
||||||
description: "Create new profile",
|
description: "Create new profile",
|
||||||
});
|
});
|
||||||
|
|
||||||
return shortcuts;
|
return shortcutsList;
|
||||||
}, []);
|
}, [shortcuts]);
|
||||||
|
|
||||||
// Register keyboard shortcuts for profiles view
|
// Register keyboard shortcuts for profiles view
|
||||||
useKeyboardShortcuts(profilesShortcuts);
|
useKeyboardShortcuts(profilesShortcuts);
|
||||||
@@ -555,7 +570,7 @@ export function ProfilesView() {
|
|||||||
</div>
|
</div>
|
||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
onClick={() => setShowAddDialog(true)}
|
onClick={() => setShowAddDialog(true)}
|
||||||
hotkey={ACTION_SHORTCUTS.addProfile}
|
hotkey={shortcuts.addProfile}
|
||||||
hotkeyActive={false}
|
hotkeyActive={false}
|
||||||
data-testid="add-profile-button"
|
data-testid="add-profile-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store";
|
||||||
|
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
TestTube,
|
TestTube,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -57,6 +60,7 @@ const NAV_ITEMS = [
|
|||||||
{ id: "codex", label: "Codex", icon: Atom },
|
{ id: "codex", label: "Codex", icon: Atom },
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
||||||
|
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
||||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
||||||
];
|
];
|
||||||
@@ -79,6 +83,9 @@ export function SettingsView() {
|
|||||||
setShowProfilesOnly,
|
setShowProfilesOnly,
|
||||||
currentProject,
|
currentProject,
|
||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
|
keyboardShortcuts,
|
||||||
|
setKeyboardShortcut,
|
||||||
|
resetKeyboardShortcuts,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Compute the effective theme for the current project
|
// Compute the effective theme for the current project
|
||||||
@@ -147,6 +154,11 @@ export function SettingsView() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [activeSection, setActiveSection] = useState("api-keys");
|
const [activeSection, setActiveSection] = useState("api-keys");
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
||||||
|
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
||||||
|
const [editingShortcut, setEditingShortcut] = useState<string | null>(null);
|
||||||
|
const [shortcutValue, setShortcutValue] = useState("");
|
||||||
|
const [shortcutError, setShortcutError] = useState<string | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -367,6 +379,36 @@ export function SettingsView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshClaudeCli = useCallback(async () => {
|
||||||
|
setIsCheckingClaudeCli(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.checkClaudeCli) {
|
||||||
|
const status = await api.checkClaudeCli();
|
||||||
|
setClaudeCliStatus(status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh Claude CLI status:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingClaudeCli(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefreshCodexCli = useCallback(async () => {
|
||||||
|
setIsCheckingCodexCli(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.checkCodexCli) {
|
||||||
|
const status = await api.checkCodexCli();
|
||||||
|
setCodexCliStatus(status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh Codex CLI status:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCodexCli(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setApiKeys({
|
setApiKeys({
|
||||||
anthropic: anthropicKey,
|
anthropic: anthropicKey,
|
||||||
@@ -757,12 +799,28 @@ export function SettingsView() {
|
|||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
>
|
>
|
||||||
<div className="p-6 border-b border-border">
|
<div className="p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="w-5 h-5 text-brand-500" />
|
<Terminal className="w-5 h-5 text-brand-500" />
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
Claude Code CLI
|
Claude Code CLI
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefreshClaudeCli}
|
||||||
|
disabled={isCheckingClaudeCli}
|
||||||
|
data-testid="refresh-claude-cli"
|
||||||
|
title="Refresh Claude CLI detection"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
isCheckingClaudeCli ? "animate-spin" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Claude Code CLI provides better performance for long-running
|
Claude Code CLI provides better performance for long-running
|
||||||
tasks, especially with ultrathink.
|
tasks, especially with ultrathink.
|
||||||
@@ -881,12 +939,28 @@ export function SettingsView() {
|
|||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
>
|
>
|
||||||
<div className="p-6 border-b border-border">
|
<div className="p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="w-5 h-5 text-green-500" />
|
<Terminal className="w-5 h-5 text-green-500" />
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
OpenAI Codex CLI
|
OpenAI Codex CLI
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefreshCodexCli}
|
||||||
|
disabled={isCheckingCodexCli}
|
||||||
|
data-testid="refresh-codex-cli"
|
||||||
|
title="Refresh Codex CLI detection"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
isCheckingCodexCli ? "animate-spin" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Codex CLI enables GPT-5.1 Codex models for autonomous coding
|
Codex CLI enables GPT-5.1 Codex models for autonomous coding
|
||||||
tasks.
|
tasks.
|
||||||
@@ -1330,6 +1404,393 @@ export function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Section */}
|
||||||
|
<div
|
||||||
|
id="keyboard"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Settings2 className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize keyboard shortcuts for navigation and actions. Click
|
||||||
|
on any shortcut to edit it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Navigation Shortcuts */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Navigation
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetKeyboardShortcuts()}
|
||||||
|
className="text-xs h-7"
|
||||||
|
data-testid="reset-shortcuts-button"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
|
Reset All to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: "board" as keyof KeyboardShortcuts, label: "Kanban Board" },
|
||||||
|
{ key: "agent" as keyof KeyboardShortcuts, label: "Agent Runner" },
|
||||||
|
{ key: "spec" as keyof KeyboardShortcuts, label: "Spec Editor" },
|
||||||
|
{ key: "context" as keyof KeyboardShortcuts, label: "Context" },
|
||||||
|
{ key: "tools" as keyof KeyboardShortcuts, label: "Agent Tools" },
|
||||||
|
{ key: "profiles" as keyof KeyboardShortcuts, label: "AI Profiles" },
|
||||||
|
{ key: "settings" as keyof KeyboardShortcuts, label: "Settings" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingShortcut === key ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={shortcutValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.toUpperCase();
|
||||||
|
setShortcutValue(value);
|
||||||
|
// Check for conflicts
|
||||||
|
const conflict = Object.entries(keyboardShortcuts).find(
|
||||||
|
([k, v]) => k !== key && v.toUpperCase() === value
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
setShortcutError(`Already used by ${conflict[0]}`);
|
||||||
|
} else {
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 h-8 text-center font-mono"
|
||||||
|
placeholder="Key"
|
||||||
|
maxLength={2}
|
||||||
|
autoFocus
|
||||||
|
data-testid={`edit-shortcut-${key}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (!shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!shortcutError || !shortcutValue}
|
||||||
|
data-testid={`save-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
data-testid={`cancel-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(key);
|
||||||
|
setShortcutValue(keyboardShortcuts[key]);
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||||
|
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||||
|
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||||
|
)}
|
||||||
|
data-testid={`shortcut-${key}`}
|
||||||
|
>
|
||||||
|
{keyboardShortcuts[key]}
|
||||||
|
</button>
|
||||||
|
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||||
|
<span className="text-xs text-brand-400">(modified)</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{shortcutError && (
|
||||||
|
<p className="text-xs text-red-400">{shortcutError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UI Shortcuts */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
UI Controls
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: "toggleSidebar" as keyof KeyboardShortcuts, label: "Toggle Sidebar" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingShortcut === key ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={shortcutValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setShortcutValue(value);
|
||||||
|
// Check for conflicts
|
||||||
|
const conflict = Object.entries(keyboardShortcuts).find(
|
||||||
|
([k, v]) => k !== key && v === value
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
setShortcutError(`Already used by ${conflict[0]}`);
|
||||||
|
} else {
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 h-8 text-center font-mono"
|
||||||
|
placeholder="Key"
|
||||||
|
maxLength={2}
|
||||||
|
autoFocus
|
||||||
|
data-testid={`edit-shortcut-${key}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (!shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!shortcutError || !shortcutValue}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(key);
|
||||||
|
setShortcutValue(keyboardShortcuts[key]);
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||||
|
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||||
|
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||||
|
)}
|
||||||
|
data-testid={`shortcut-${key}`}
|
||||||
|
>
|
||||||
|
{keyboardShortcuts[key]}
|
||||||
|
</button>
|
||||||
|
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||||
|
<span className="text-xs text-brand-400">(modified)</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Shortcuts */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Actions
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: "addFeature" as keyof KeyboardShortcuts, label: "Add Feature" },
|
||||||
|
{ key: "addContextFile" as keyof KeyboardShortcuts, label: "Add Context File" },
|
||||||
|
{ key: "startNext" as keyof KeyboardShortcuts, label: "Start Next Features" },
|
||||||
|
{ key: "newSession" as keyof KeyboardShortcuts, label: "New Session" },
|
||||||
|
{ key: "openProject" as keyof KeyboardShortcuts, label: "Open Project" },
|
||||||
|
{ key: "projectPicker" as keyof KeyboardShortcuts, label: "Project Picker" },
|
||||||
|
{ key: "cyclePrevProject" as keyof KeyboardShortcuts, label: "Previous Project" },
|
||||||
|
{ key: "cycleNextProject" as keyof KeyboardShortcuts, label: "Next Project" },
|
||||||
|
{ key: "addProfile" as keyof KeyboardShortcuts, label: "Add Profile" },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingShortcut === key ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={shortcutValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.toUpperCase();
|
||||||
|
setShortcutValue(value);
|
||||||
|
// Check for conflicts
|
||||||
|
const conflict = Object.entries(keyboardShortcuts).find(
|
||||||
|
([k, v]) => k !== key && v.toUpperCase() === value
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
setShortcutError(`Already used by ${conflict[0]}`);
|
||||||
|
} else {
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 h-8 text-center font-mono"
|
||||||
|
placeholder="Key"
|
||||||
|
maxLength={2}
|
||||||
|
autoFocus
|
||||||
|
data-testid={`edit-shortcut-${key}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (!shortcutError && shortcutValue) {
|
||||||
|
setKeyboardShortcut(key, shortcutValue);
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!!shortcutError || !shortcutValue}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(null);
|
||||||
|
setShortcutValue("");
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingShortcut(key);
|
||||||
|
setShortcutValue(keyboardShortcuts[key]);
|
||||||
|
setShortcutError(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-sm font-mono rounded bg-sidebar-accent/20 border border-sidebar-border hover:bg-sidebar-accent/30 transition-colors",
|
||||||
|
keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] &&
|
||||||
|
"border-brand-500/50 bg-brand-500/10 text-brand-400"
|
||||||
|
)}
|
||||||
|
data-testid={`shortcut-${key}`}
|
||||||
|
>
|
||||||
|
{keyboardShortcuts[key]}
|
||||||
|
</button>
|
||||||
|
{keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key] && (
|
||||||
|
<span className="text-xs text-brand-400">(modified)</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Information */}
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-blue-400">
|
||||||
|
About Keyboard Shortcuts
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-400/80 text-xs mt-1">
|
||||||
|
Shortcuts won't trigger when typing in input fields. Use
|
||||||
|
single keys (A-Z, 0-9) or special keys like ` (backtick).
|
||||||
|
Changes take effect immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Feature Defaults Section */}
|
{/* Feature Defaults Section */}
|
||||||
<div
|
<div
|
||||||
id="defaults"
|
id="defaults"
|
||||||
|
|||||||
1486
app/src/components/views/setup-view.tsx
Normal file
1486
app/src/components/views/setup-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -97,36 +98,10 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shortcut definitions for navigation
|
* Hook to get current keyboard shortcuts from store
|
||||||
|
* This replaces the static constants and allows customization
|
||||||
*/
|
*/
|
||||||
export const NAV_SHORTCUTS: Record<string, string> = {
|
export function useKeyboardShortcutsConfig() {
|
||||||
board: "K", // K for Kanban
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
agent: "A", // A for Agent
|
return keyboardShortcuts;
|
||||||
spec: "D", // D for Document (Spec)
|
}
|
||||||
context: "C", // C for Context
|
|
||||||
tools: "T", // T for Tools
|
|
||||||
settings: "S", // S for Settings
|
|
||||||
profiles: "M", // M for Models/profiles
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shortcut definitions for UI controls
|
|
||||||
*/
|
|
||||||
export const UI_SHORTCUTS: Record<string, string> = {
|
|
||||||
toggleSidebar: "`", // Backtick to toggle sidebar
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shortcut definitions for add buttons
|
|
||||||
*/
|
|
||||||
export const ACTION_SHORTCUTS: Record<string, string> = {
|
|
||||||
addFeature: "N", // N for New feature
|
|
||||||
addContextFile: "F", // F for File (add context file)
|
|
||||||
startNext: "G", // G for Grab (start next features from backlog)
|
|
||||||
newSession: "N", // N for New session (in agent view)
|
|
||||||
openProject: "O", // O for Open project (navigate to welcome view)
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -172,6 +172,54 @@ export interface ElectronAPI {
|
|||||||
git?: GitAPI;
|
git?: GitAPI;
|
||||||
suggestions?: SuggestionsAPI;
|
suggestions?: SuggestionsAPI;
|
||||||
specRegeneration?: SpecRegenerationAPI;
|
specRegeneration?: SpecRegenerationAPI;
|
||||||
|
setup?: {
|
||||||
|
getClaudeStatus: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
hasCredentialsFile: boolean;
|
||||||
|
hasToken: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
getCodexStatus: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
hasAuthFile: boolean;
|
||||||
|
hasEnvKey: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
|
installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
|
authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
|
||||||
|
authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
|
||||||
|
storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>;
|
||||||
|
configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
||||||
|
getPlatform: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
homeDir: string;
|
||||||
|
isWindows: boolean;
|
||||||
|
isMac: boolean;
|
||||||
|
isLinux: boolean;
|
||||||
|
}>;
|
||||||
|
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
|
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Window interface is declared in @/types/electron.d.ts
|
// Note: Window interface is declared in @/types/electron.d.ts
|
||||||
@@ -461,6 +509,9 @@ export const getElectronAPI = (): ElectronAPI => {
|
|||||||
error: "OpenAI connection test is only available in the Electron app.",
|
error: "OpenAI connection test is only available in the Electron app.",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Mock Setup API
|
||||||
|
setup: createMockSetupAPI(),
|
||||||
|
|
||||||
// Mock Auto Mode API
|
// Mock Auto Mode API
|
||||||
autoMode: createMockAutoModeAPI(),
|
autoMode: createMockAutoModeAPI(),
|
||||||
|
|
||||||
@@ -478,6 +529,175 @@ export const getElectronAPI = (): ElectronAPI => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup API interface
|
||||||
|
interface SetupAPI {
|
||||||
|
getClaudeStatus: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
hasCredentialsFile: boolean;
|
||||||
|
hasToken: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
getCodexStatus: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
hasAuthFile: boolean;
|
||||||
|
hasEnvKey: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
installClaude: () => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
|
installCodex: () => Promise<{ success: boolean; message?: string; error?: string }>;
|
||||||
|
authClaude: () => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
|
||||||
|
authCodex: (apiKey?: string) => Promise<{ success: boolean; requiresManualAuth?: boolean; command?: string; error?: string }>;
|
||||||
|
storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; hasOpenAIKey: boolean; hasGoogleKey: boolean }>;
|
||||||
|
configureCodexMcp: (projectPath: string) => Promise<{ success: boolean; configPath?: string; error?: string }>;
|
||||||
|
getPlatform: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
homeDir: string;
|
||||||
|
isWindows: boolean;
|
||||||
|
isMac: boolean;
|
||||||
|
isLinux: boolean;
|
||||||
|
}>;
|
||||||
|
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
|
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock Setup API implementation
|
||||||
|
function createMockSetupAPI(): SetupAPI {
|
||||||
|
return {
|
||||||
|
getClaudeStatus: async () => {
|
||||||
|
console.log("[Mock] Getting Claude status");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "not_installed",
|
||||||
|
auth: {
|
||||||
|
authenticated: false,
|
||||||
|
method: "none",
|
||||||
|
hasCredentialsFile: false,
|
||||||
|
hasToken: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getCodexStatus: async () => {
|
||||||
|
console.log("[Mock] Getting Codex status");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "not_installed",
|
||||||
|
auth: {
|
||||||
|
authenticated: false,
|
||||||
|
method: "none",
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasEnvKey: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
installClaude: async () => {
|
||||||
|
console.log("[Mock] Installing Claude CLI");
|
||||||
|
// Simulate installation delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "CLI installation is only available in the Electron app. Please run the command manually.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
installCodex: async () => {
|
||||||
|
console.log("[Mock] Installing Codex CLI");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "CLI installation is only available in the Electron app. Please run the command manually.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
authClaude: async () => {
|
||||||
|
console.log("[Mock] Auth Claude CLI");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresManualAuth: true,
|
||||||
|
command: "claude login",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
authCodex: async (apiKey?: string) => {
|
||||||
|
console.log("[Mock] Auth Codex CLI", { hasApiKey: !!apiKey });
|
||||||
|
if (apiKey) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requiresManualAuth: true,
|
||||||
|
command: "codex auth login",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
storeApiKey: async (provider: string, apiKey: string) => {
|
||||||
|
console.log("[Mock] Storing API key for:", provider);
|
||||||
|
// In mock mode, we just pretend to store it (it's already in the app store)
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
getApiKeys: async () => {
|
||||||
|
console.log("[Mock] Getting API keys");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasAnthropicKey: false,
|
||||||
|
hasOpenAIKey: false,
|
||||||
|
hasGoogleKey: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
configureCodexMcp: async (projectPath: string) => {
|
||||||
|
console.log("[Mock] Configuring Codex MCP for:", projectPath);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
configPath: `${projectPath}/.codex/config.toml`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getPlatform: async () => {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
platform: "darwin",
|
||||||
|
arch: "arm64",
|
||||||
|
homeDir: "/Users/mock",
|
||||||
|
isWindows: false,
|
||||||
|
isMac: true,
|
||||||
|
isLinux: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onInstallProgress: (callback) => {
|
||||||
|
// Mock progress events
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
|
||||||
|
onAuthProgress: (callback) => {
|
||||||
|
// Mock auth events
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Mock Worktree API implementation
|
// Mock Worktree API implementation
|
||||||
function createMockWorktreeAPI(): WorktreeAPI {
|
function createMockWorktreeAPI(): WorktreeAPI {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Project, TrashedProject } from "@/lib/electron";
|
|||||||
|
|
||||||
export type ViewMode =
|
export type ViewMode =
|
||||||
| "welcome"
|
| "welcome"
|
||||||
|
| "setup"
|
||||||
| "spec"
|
| "spec"
|
||||||
| "board"
|
| "board"
|
||||||
| "agent"
|
| "agent"
|
||||||
@@ -36,6 +37,60 @@ export interface ApiKeys {
|
|||||||
openai: string;
|
openai: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard Shortcuts
|
||||||
|
export interface KeyboardShortcuts {
|
||||||
|
// Navigation shortcuts
|
||||||
|
board: string;
|
||||||
|
agent: string;
|
||||||
|
spec: string;
|
||||||
|
context: string;
|
||||||
|
tools: string;
|
||||||
|
settings: string;
|
||||||
|
profiles: string;
|
||||||
|
|
||||||
|
// UI shortcuts
|
||||||
|
toggleSidebar: string;
|
||||||
|
|
||||||
|
// Action shortcuts
|
||||||
|
addFeature: string;
|
||||||
|
addContextFile: string;
|
||||||
|
startNext: string;
|
||||||
|
newSession: string;
|
||||||
|
openProject: string;
|
||||||
|
projectPicker: string;
|
||||||
|
cyclePrevProject: string;
|
||||||
|
cycleNextProject: string;
|
||||||
|
addProfile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default keyboard shortcuts
|
||||||
|
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||||
|
// Navigation
|
||||||
|
board: "K",
|
||||||
|
agent: "A",
|
||||||
|
spec: "D",
|
||||||
|
context: "C",
|
||||||
|
tools: "T",
|
||||||
|
settings: "S",
|
||||||
|
profiles: "M",
|
||||||
|
|
||||||
|
// UI
|
||||||
|
toggleSidebar: "`",
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
|
||||||
|
// This is intentional as they are context-specific and only active in their respective views
|
||||||
|
addFeature: "N", // Only active in board view
|
||||||
|
addContextFile: "F", // Only active in context view
|
||||||
|
startNext: "G", // Only active in board view
|
||||||
|
newSession: "N", // Only active in agent view
|
||||||
|
openProject: "O", // Global shortcut
|
||||||
|
projectPicker: "P", // Global shortcut
|
||||||
|
cyclePrevProject: "Q", // Global shortcut
|
||||||
|
cycleNextProject: "E", // Global shortcut
|
||||||
|
addProfile: "N", // Only active in profiles view
|
||||||
|
};
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
data: string; // base64 encoded image data
|
data: string; // base64 encoded image data
|
||||||
@@ -202,6 +257,9 @@ export interface AppState {
|
|||||||
// Profile Display Settings
|
// Profile Display Settings
|
||||||
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
|
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
|
||||||
|
|
||||||
|
// Keyboard Shortcuts
|
||||||
|
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
|
||||||
|
|
||||||
// Project Analysis
|
// Project Analysis
|
||||||
projectAnalysis: ProjectAnalysis | null;
|
projectAnalysis: ProjectAnalysis | null;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
@@ -302,6 +360,11 @@ export interface AppActions {
|
|||||||
// Profile Display Settings actions
|
// Profile Display Settings actions
|
||||||
setShowProfilesOnly: (enabled: boolean) => void;
|
setShowProfilesOnly: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
// Keyboard Shortcuts actions
|
||||||
|
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
|
||||||
|
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
|
||||||
|
resetKeyboardShortcuts: () => void;
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
|
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
|
||||||
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
|
||||||
@@ -403,6 +466,7 @@ const initialState: AppState = {
|
|||||||
defaultSkipTests: false, // Default to TDD mode (tests enabled)
|
defaultSkipTests: false, // Default to TDD mode (tests enabled)
|
||||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||||
showProfilesOnly: false, // Default to showing all options (not profiles only)
|
showProfilesOnly: false, // Default to showing all options (not profiles only)
|
||||||
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||||
aiProfiles: DEFAULT_AI_PROFILES,
|
aiProfiles: DEFAULT_AI_PROFILES,
|
||||||
projectAnalysis: null,
|
projectAnalysis: null,
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
@@ -906,6 +970,29 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Profile Display Settings actions
|
// Profile Display Settings actions
|
||||||
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
|
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
|
||||||
|
|
||||||
|
// Keyboard Shortcuts actions
|
||||||
|
setKeyboardShortcut: (key, value) => {
|
||||||
|
set({
|
||||||
|
keyboardShortcuts: {
|
||||||
|
...get().keyboardShortcuts,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setKeyboardShortcuts: (shortcuts) => {
|
||||||
|
set({
|
||||||
|
keyboardShortcuts: {
|
||||||
|
...get().keyboardShortcuts,
|
||||||
|
...shortcuts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetKeyboardShortcuts: () => {
|
||||||
|
set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS });
|
||||||
|
},
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile) => {
|
addAIProfile: (profile) => {
|
||||||
const id = `profile-${Date.now()}-${Math.random()
|
const id = `profile-${Date.now()}-${Math.random()
|
||||||
@@ -984,6 +1071,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
defaultSkipTests: state.defaultSkipTests,
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
useWorktrees: state.useWorktrees,
|
useWorktrees: state.useWorktrees,
|
||||||
showProfilesOnly: state.showProfilesOnly,
|
showProfilesOnly: state.showProfilesOnly,
|
||||||
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||||
}),
|
}),
|
||||||
|
|||||||
182
app/src/store/setup-store.ts
Normal file
182
app/src/store/setup-store.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
// CLI Installation Status
|
||||||
|
export interface CliStatus {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
method: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Auth Status
|
||||||
|
export interface ClaudeAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: "oauth" | "api_key" | "none";
|
||||||
|
hasCredentialsFile: boolean;
|
||||||
|
oauthTokenValid?: boolean;
|
||||||
|
apiKeyValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex Auth Status
|
||||||
|
export interface CodexAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: "api_key" | "env" | "none";
|
||||||
|
apiKeyValid?: boolean;
|
||||||
|
mcpConfigured?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installation Progress
|
||||||
|
export interface InstallProgress {
|
||||||
|
isInstalling: boolean;
|
||||||
|
currentStep: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
output: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetupStep =
|
||||||
|
| "welcome"
|
||||||
|
| "claude_detect"
|
||||||
|
| "claude_auth"
|
||||||
|
| "codex_detect"
|
||||||
|
| "codex_auth"
|
||||||
|
| "complete";
|
||||||
|
|
||||||
|
export interface SetupState {
|
||||||
|
// Setup wizard state
|
||||||
|
isFirstRun: boolean;
|
||||||
|
setupComplete: boolean;
|
||||||
|
currentStep: SetupStep;
|
||||||
|
|
||||||
|
// Claude CLI state
|
||||||
|
claudeCliStatus: CliStatus | null;
|
||||||
|
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||||
|
claudeInstallProgress: InstallProgress;
|
||||||
|
|
||||||
|
// Codex CLI state
|
||||||
|
codexCliStatus: CliStatus | null;
|
||||||
|
codexAuthStatus: CodexAuthStatus | null;
|
||||||
|
codexInstallProgress: InstallProgress;
|
||||||
|
|
||||||
|
// Setup preferences
|
||||||
|
skipClaudeSetup: boolean;
|
||||||
|
skipCodexSetup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupActions {
|
||||||
|
// Setup flow
|
||||||
|
setCurrentStep: (step: SetupStep) => void;
|
||||||
|
completeSetup: () => void;
|
||||||
|
resetSetup: () => void;
|
||||||
|
setIsFirstRun: (isFirstRun: boolean) => void;
|
||||||
|
|
||||||
|
// Claude CLI
|
||||||
|
setClaudeCliStatus: (status: CliStatus | null) => void;
|
||||||
|
setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void;
|
||||||
|
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||||
|
resetClaudeInstallProgress: () => void;
|
||||||
|
|
||||||
|
// Codex CLI
|
||||||
|
setCodexCliStatus: (status: CliStatus | null) => void;
|
||||||
|
setCodexAuthStatus: (status: CodexAuthStatus | null) => void;
|
||||||
|
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||||
|
resetCodexInstallProgress: () => void;
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
setSkipClaudeSetup: (skip: boolean) => void;
|
||||||
|
setSkipCodexSetup: (skip: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialInstallProgress: InstallProgress = {
|
||||||
|
isInstalling: false,
|
||||||
|
currentStep: "",
|
||||||
|
progress: 0,
|
||||||
|
output: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: SetupState = {
|
||||||
|
isFirstRun: true,
|
||||||
|
setupComplete: false,
|
||||||
|
currentStep: "welcome",
|
||||||
|
|
||||||
|
claudeCliStatus: null,
|
||||||
|
claudeAuthStatus: null,
|
||||||
|
claudeInstallProgress: { ...initialInstallProgress },
|
||||||
|
|
||||||
|
codexCliStatus: null,
|
||||||
|
codexAuthStatus: null,
|
||||||
|
codexInstallProgress: { ...initialInstallProgress },
|
||||||
|
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
skipCodexSetup: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetupStore = create<SetupState & SetupActions>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// Setup flow
|
||||||
|
setCurrentStep: (step) => set({ currentStep: step }),
|
||||||
|
|
||||||
|
completeSetup: () => set({ setupComplete: true, currentStep: "complete" }),
|
||||||
|
|
||||||
|
resetSetup: () => set({
|
||||||
|
...initialState,
|
||||||
|
isFirstRun: false, // Don't reset first run flag
|
||||||
|
}),
|
||||||
|
|
||||||
|
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
|
||||||
|
|
||||||
|
// Claude CLI
|
||||||
|
setClaudeCliStatus: (status) => set({ claudeCliStatus: status }),
|
||||||
|
|
||||||
|
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
|
||||||
|
|
||||||
|
setClaudeInstallProgress: (progress) => set({
|
||||||
|
claudeInstallProgress: {
|
||||||
|
...get().claudeInstallProgress,
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetClaudeInstallProgress: () => set({
|
||||||
|
claudeInstallProgress: { ...initialInstallProgress },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Codex CLI
|
||||||
|
setCodexCliStatus: (status) => set({ codexCliStatus: status }),
|
||||||
|
|
||||||
|
setCodexAuthStatus: (status) => set({ codexAuthStatus: status }),
|
||||||
|
|
||||||
|
setCodexInstallProgress: (progress) => set({
|
||||||
|
codexInstallProgress: {
|
||||||
|
...get().codexInstallProgress,
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetCodexInstallProgress: () => set({
|
||||||
|
codexInstallProgress: { ...initialInstallProgress },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||||
|
|
||||||
|
setSkipCodexSetup: (skip) => set({ skipCodexSetup: skip }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "automaker-setup",
|
||||||
|
partialize: (state) => ({
|
||||||
|
isFirstRun: state.isFirstRun,
|
||||||
|
setupComplete: state.setupComplete,
|
||||||
|
skipClaudeSetup: state.skipClaudeSetup,
|
||||||
|
skipCodexSetup: state.skipCodexSetup,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -2338,3 +2338,173 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
|
|||||||
options
|
options
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Setup View Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the app store to show setup view (simulate first run)
|
||||||
|
*/
|
||||||
|
export async function setupFirstRun(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// Clear any existing setup state to simulate first run
|
||||||
|
localStorage.removeItem("automaker-setup");
|
||||||
|
localStorage.removeItem("automaker-storage");
|
||||||
|
|
||||||
|
// Set up the setup store state for first run
|
||||||
|
const setupState = {
|
||||||
|
state: {
|
||||||
|
isFirstRun: true,
|
||||||
|
setupComplete: false,
|
||||||
|
currentStep: "welcome",
|
||||||
|
claudeCliStatus: null,
|
||||||
|
claudeAuthStatus: null,
|
||||||
|
claudeInstallProgress: {
|
||||||
|
isInstalling: false,
|
||||||
|
currentStep: "",
|
||||||
|
progress: 0,
|
||||||
|
output: [],
|
||||||
|
},
|
||||||
|
codexCliStatus: null,
|
||||||
|
codexAuthStatus: null,
|
||||||
|
codexInstallProgress: {
|
||||||
|
isInstalling: false,
|
||||||
|
currentStep: "",
|
||||||
|
progress: 0,
|
||||||
|
output: [],
|
||||||
|
},
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
skipCodexSetup: false,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||||
|
|
||||||
|
// Also set up app store to show setup view
|
||||||
|
const appState = {
|
||||||
|
state: {
|
||||||
|
projects: [],
|
||||||
|
currentProject: null,
|
||||||
|
theme: "dark",
|
||||||
|
sidebarOpen: true,
|
||||||
|
apiKeys: { anthropic: "", google: "", openai: "" },
|
||||||
|
chatSessions: [],
|
||||||
|
chatHistoryOpen: false,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
isAutoModeRunning: false,
|
||||||
|
runningAutoTasks: [],
|
||||||
|
autoModeActivityLog: [],
|
||||||
|
currentView: "setup",
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-storage", JSON.stringify(appState));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the app to skip the setup wizard (setup already complete)
|
||||||
|
*/
|
||||||
|
export async function setupComplete(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// Mark setup as complete
|
||||||
|
const setupState = {
|
||||||
|
state: {
|
||||||
|
isFirstRun: false,
|
||||||
|
setupComplete: true,
|
||||||
|
currentStep: "complete",
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
skipCodexSetup: false,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the setup view directly
|
||||||
|
*/
|
||||||
|
export async function navigateToSetup(page: Page): Promise<void> {
|
||||||
|
await setupFirstRun(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await waitForElement(page, "setup-view", { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for setup view to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForSetupView(page: Page): Promise<Locator> {
|
||||||
|
return waitForElement(page, "setup-view", { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click "Get Started" button on setup welcome step
|
||||||
|
*/
|
||||||
|
export async function clickSetupGetStarted(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "setup-start-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click continue on Claude setup step
|
||||||
|
*/
|
||||||
|
export async function clickClaudeContinue(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "claude-next-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click continue on Codex setup step
|
||||||
|
*/
|
||||||
|
export async function clickCodexContinue(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "codex-next-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click finish on setup complete step
|
||||||
|
*/
|
||||||
|
export async function clickSetupFinish(page: Page): Promise<void> {
|
||||||
|
const button = await getByTestId(page, "setup-finish-button");
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter Anthropic API key in setup
|
||||||
|
*/
|
||||||
|
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
|
||||||
|
// Click "Use Anthropic API Key Instead" button
|
||||||
|
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
|
||||||
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
|
// Enter the API key
|
||||||
|
const input = await getByTestId(page, "anthropic-api-key-input");
|
||||||
|
await input.fill(apiKey);
|
||||||
|
|
||||||
|
// Click save button
|
||||||
|
const saveButton = await getByTestId(page, "save-anthropic-key-button");
|
||||||
|
await saveButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter OpenAI API key in setup
|
||||||
|
*/
|
||||||
|
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
|
||||||
|
// Click "Enter OpenAI API Key" button
|
||||||
|
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
|
||||||
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
|
// Enter the API key
|
||||||
|
const input = await getByTestId(page, "openai-api-key-input");
|
||||||
|
await input.fill(apiKey);
|
||||||
|
|
||||||
|
// Click save button
|
||||||
|
const saveButton = await getByTestId(page, "save-openai-key-button");
|
||||||
|
await saveButton.click();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user