mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
UX prototype complete
This commit is contained in:
111
.github/workflows/release.yml
vendored
Normal file
111
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: Release Maestro
|
||||||
|
|
||||||
|
# This workflow is triggered only by git tags (trusted input)
|
||||||
|
# No user-controlled data is executed in shell commands
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
platform: mac
|
||||||
|
- os: ubuntu-latest
|
||||||
|
platform: linux
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package for macOS
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
run: npm run package:mac
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||||
|
|
||||||
|
- name: Package for Windows
|
||||||
|
if: matrix.platform == 'win'
|
||||||
|
run: npm run package:win
|
||||||
|
|
||||||
|
- name: Package for Linux
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
run: npm run package:linux
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: maestro-macos
|
||||||
|
path: |
|
||||||
|
release/*.dmg
|
||||||
|
release/*.zip
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Upload Windows artifacts
|
||||||
|
if: matrix.platform == 'win'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: maestro-windows
|
||||||
|
path: |
|
||||||
|
release/*.exe
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Upload Linux artifacts
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: maestro-linux
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
release/*.rpm
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
artifacts/maestro-macos/*
|
||||||
|
artifacts/maestro-windows/*
|
||||||
|
artifacts/maestro-linux/*
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
521
CLAUDE.md
Normal file
521
CLAUDE.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Maestro is a unified, highly-responsive Electron desktop application for managing multiple AI coding assistants (Claude Code, Aider, OpenCode, etc.) simultaneously. It provides a Linear/Superhuman-level responsive interface with keyboard-first navigation, dual-mode input (terminal vs AI), and remote web access capabilities.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode with hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build and run production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build both main and renderer processes
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Build main process only (Electron backend)
|
||||||
|
npm run build:main
|
||||||
|
|
||||||
|
# Build renderer only (React frontend)
|
||||||
|
npm run build:renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Package for all platforms
|
||||||
|
npm run package
|
||||||
|
|
||||||
|
# Platform-specific builds
|
||||||
|
npm run package:mac # macOS (.dmg, .zip)
|
||||||
|
npm run package:win # Windows (.exe, portable)
|
||||||
|
npm run package:linux # Linux (.AppImage, .deb, .rpm)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean build artifacts and cache
|
||||||
|
npm run clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Dual-Process Model
|
||||||
|
|
||||||
|
Maestro uses Electron's main/renderer architecture with strict context isolation:
|
||||||
|
|
||||||
|
**Main Process (`src/main/`)** - Node.js backend with full system access
|
||||||
|
- `index.ts` - Application entry point, IPC handler registration, window management
|
||||||
|
- `process-manager.ts` - Core primitive for spawning and managing CLI processes
|
||||||
|
- `web-server.ts` - Fastify-based HTTP/WebSocket server for remote access
|
||||||
|
- `agent-detector.ts` - Auto-detects available AI tools (Claude Code, Aider, etc.) via PATH
|
||||||
|
- `preload.ts` - Secure IPC bridge via contextBridge (no direct Node.js exposure to renderer)
|
||||||
|
|
||||||
|
**Renderer Process (`src/renderer/`)** - React frontend with no direct Node.js access
|
||||||
|
- `App.tsx` - Main UI component
|
||||||
|
- `main.tsx` - Renderer entry point
|
||||||
|
- `components/` - React components (modals, panels, UI elements)
|
||||||
|
|
||||||
|
### Process Management System
|
||||||
|
|
||||||
|
The `ProcessManager` class is the core architectural primitive that abstracts two process types:
|
||||||
|
|
||||||
|
1. **PTY Processes** (via `node-pty`) - For terminal sessions with full shell emulation
|
||||||
|
- Used for `toolType: 'terminal'`
|
||||||
|
- Supports resize, ANSI escape codes, interactive shell
|
||||||
|
|
||||||
|
2. **Child Processes** (via `child_process`) - For AI assistants
|
||||||
|
- Used for all non-terminal tool types (claude-code, aider, etc.)
|
||||||
|
- Direct stdin/stdout/stderr capture without shell interpretation
|
||||||
|
- **Security**: Uses `spawn()` with `shell: false` to prevent command injection
|
||||||
|
|
||||||
|
All process operations go through IPC handlers in `src/main/index.ts`:
|
||||||
|
- `process:spawn` - Start a new process
|
||||||
|
- `process:write` - Send data to stdin
|
||||||
|
- `process:kill` - Terminate a process
|
||||||
|
- `process:resize` - Resize PTY terminal (terminal mode only)
|
||||||
|
|
||||||
|
Events are emitted back to renderer via:
|
||||||
|
- `process:data` - Stdout/stderr output
|
||||||
|
- `process:exit` - Process exit code
|
||||||
|
|
||||||
|
### Session Model
|
||||||
|
|
||||||
|
Each "session" is a unified abstraction with these attributes:
|
||||||
|
- `sessionId` - Unique identifier
|
||||||
|
- `toolType` - Agent type (claude-code, aider, terminal, custom)
|
||||||
|
- `cwd` - Working directory
|
||||||
|
- `state` - Current state (idle, busy, error)
|
||||||
|
- `stdinMode` - Input routing mode (terminal vs AI)
|
||||||
|
|
||||||
|
### IPC Security Model
|
||||||
|
|
||||||
|
All renderer-to-main communication goes through the preload script:
|
||||||
|
- **Context isolation**: Enabled (renderer has no direct Node.js access)
|
||||||
|
- **Node integration**: Disabled (no `require()` in renderer)
|
||||||
|
- **Preload script**: Exposes minimal API via `contextBridge.exposeInMainWorld('maestro', ...)`
|
||||||
|
|
||||||
|
The `window.maestro` API provides type-safe access to:
|
||||||
|
- Settings management
|
||||||
|
- Process control
|
||||||
|
- Git operations
|
||||||
|
- File system access
|
||||||
|
- Tunnel management
|
||||||
|
- Agent detection
|
||||||
|
|
||||||
|
### Git Integration
|
||||||
|
|
||||||
|
Git operations use the safe `execFileNoThrow` utility (located in `src/main/utils/execFile.ts`) to prevent shell injection vulnerabilities:
|
||||||
|
- `git:status` - Get porcelain status
|
||||||
|
- `git:diff` - Get diff for files
|
||||||
|
- `git:isRepo` - Check if directory is a Git repository
|
||||||
|
|
||||||
|
### Web Server Architecture
|
||||||
|
|
||||||
|
Fastify server (`src/main/web-server.ts`) provides:
|
||||||
|
- REST API endpoints (`/api/sessions`, `/health`)
|
||||||
|
- WebSocket endpoint (`/ws`) for real-time updates
|
||||||
|
- CORS enabled for mobile/remote access
|
||||||
|
- Binds to `0.0.0.0:8000` for LAN access
|
||||||
|
|
||||||
|
### Agent Detection
|
||||||
|
|
||||||
|
`AgentDetector` class auto-discovers CLI tools in PATH:
|
||||||
|
- Uses `which` (Unix) or `where` (Windows) via `execFileNoThrow`
|
||||||
|
- Caches results for performance
|
||||||
|
- Pre-configured agents: Claude Code, Aider, Qwen Coder, CLI Terminal
|
||||||
|
- Extensible via `AGENT_DEFINITIONS` array in `src/main/agent-detector.ts`
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
### Dual-Mode Input Router
|
||||||
|
|
||||||
|
Sessions toggle between two input modes:
|
||||||
|
1. **Terminal Mode** - Raw shell commands via PTY
|
||||||
|
2. **AI Interaction Mode** - Direct communication with AI assistant
|
||||||
|
|
||||||
|
This is implemented via the `isTerminal` flag in `ProcessManager`.
|
||||||
|
|
||||||
|
### Event-Driven Output Streaming
|
||||||
|
|
||||||
|
ProcessManager extends EventEmitter:
|
||||||
|
```typescript
|
||||||
|
processManager.on('data', (sessionId, data) => { ... })
|
||||||
|
processManager.on('exit', (sessionId, code) => { ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are forwarded to renderer via IPC:
|
||||||
|
```typescript
|
||||||
|
mainWindow?.webContents.send('process:data', sessionId, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secure Command Execution
|
||||||
|
|
||||||
|
**ALWAYS use `execFileNoThrow` utility** from `src/main/utils/execFile.ts` for running external commands. This prevents shell injection vulnerabilities by using `execFile` instead of `exec`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Correct - safe from injection
|
||||||
|
import { execFileNoThrow } from './utils/execFile';
|
||||||
|
const result = await execFileNoThrow('git', ['status', '--porcelain'], cwd);
|
||||||
|
|
||||||
|
// The utility returns: { stdout: string, stderr: string, exitCode: number }
|
||||||
|
// It never throws - non-zero exit codes return exitCode !== 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Architecture & Components
|
||||||
|
|
||||||
|
### Main Application Structure (App.tsx)
|
||||||
|
|
||||||
|
The main application is structured in three columns:
|
||||||
|
1. **Left Sidebar** - Session list, groups, new instance button
|
||||||
|
2. **Main Panel** - Terminal/AI output, input area, toolbar
|
||||||
|
3. **Right Panel** - Files, History, Scratchpad tabs
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### SettingsModal (`src/renderer/components/SettingsModal.tsx`)
|
||||||
|
- Tabbed interface: General, LLM, Shortcuts, Themes, Network
|
||||||
|
- All settings changes should use wrapper functions for persistence
|
||||||
|
- Includes LLM test functionality to verify API connectivity
|
||||||
|
|
||||||
|
#### Scratchpad (`src/renderer/components/Scratchpad.tsx`)
|
||||||
|
- Edit/Preview mode toggle (Command-E to switch)
|
||||||
|
- Markdown rendering with GFM support
|
||||||
|
- Smart list continuation (unordered, ordered, task lists)
|
||||||
|
- Container must be focusable (tabIndex) for keyboard shortcuts
|
||||||
|
|
||||||
|
#### FilePreview (`src/renderer/components/FilePreview.tsx`)
|
||||||
|
- Full-screen overlay for file viewing
|
||||||
|
- Syntax highlighting via react-syntax-highlighter
|
||||||
|
- Markdown rendering for .md files
|
||||||
|
- Arrow keys for scrolling, Escape to close
|
||||||
|
- Auto-focuses when opened for immediate keyboard control
|
||||||
|
|
||||||
|
### Keyboard Navigation Patterns
|
||||||
|
|
||||||
|
The app is keyboard-first with these patterns:
|
||||||
|
|
||||||
|
**Focus Management:**
|
||||||
|
- Escape in input → Focus output window
|
||||||
|
- Escape in output → Focus back to input
|
||||||
|
- Escape in file preview → Return to file tree
|
||||||
|
- Components need `tabIndex={-1}` and `outline-none` for programmatic focus
|
||||||
|
|
||||||
|
**Output Window:**
|
||||||
|
- `/` → Open search/filter
|
||||||
|
- Arrow Up/Down → Scroll output
|
||||||
|
- Cmd/Ctrl + Arrow Up/Down → Jump to top/bottom
|
||||||
|
- Escape → Close search (if open) or return to input
|
||||||
|
|
||||||
|
**File Tree:**
|
||||||
|
- Arrow keys → Navigate files/folders
|
||||||
|
- Enter → Open file preview
|
||||||
|
- Space → Toggle folder expansion
|
||||||
|
- Cmd+E → Expand all, Cmd+Shift+E → Collapse all
|
||||||
|
|
||||||
|
**Scratchpad:**
|
||||||
|
- Cmd+E → Toggle Edit/Preview mode
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
|
||||||
|
Themes defined in `THEMES` object in App.tsx with structure:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mode: 'light' | 'dark';
|
||||||
|
colors: {
|
||||||
|
bgMain: string; // Main content background
|
||||||
|
bgSidebar: string; // Sidebar background
|
||||||
|
bgActivity: string; // Accent background
|
||||||
|
border: string; // Border colors
|
||||||
|
textMain: string; // Primary text
|
||||||
|
textDim: string; // Secondary text
|
||||||
|
accent: string; // Accent color
|
||||||
|
accentDim: string; // Dimmed accent
|
||||||
|
success: string; // Success state
|
||||||
|
warning: string; // Warning state
|
||||||
|
error: string; // Error state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `style={{ color: theme.colors.textMain }}` instead of fixed colors.
|
||||||
|
|
||||||
|
### Styling Conventions
|
||||||
|
|
||||||
|
- **Tailwind CSS** for layout and spacing
|
||||||
|
- **Inline styles** for theme colors (dynamic based on selected theme)
|
||||||
|
- **Standard spacing**: `gap-2`, `p-4`, `mb-3` for consistency
|
||||||
|
- **Focus states**: Always add `outline-none` when using `tabIndex`
|
||||||
|
- **Sticky elements**: Use `sticky top-0 z-10` with solid background
|
||||||
|
- **Overlays**: Use `fixed inset-0` with backdrop blur and high z-index
|
||||||
|
|
||||||
|
### State Management Per Session
|
||||||
|
|
||||||
|
Each session stores:
|
||||||
|
- `cwd` - Current working directory
|
||||||
|
- `fileTree` - File tree structure
|
||||||
|
- `fileExplorerExpanded` - Expanded folder paths
|
||||||
|
- `fileExplorerScrollPos` - Scroll position in file tree
|
||||||
|
- `aiLogs` / `shellLogs` - Output history
|
||||||
|
- `inputMode` - 'ai' or 'terminal'
|
||||||
|
- `state` - 'idle' | 'busy' | 'waiting_input'
|
||||||
|
|
||||||
|
Sessions persist scroll positions, expanded states, and UI state per-session.
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- All code is TypeScript with strict mode enabled
|
||||||
|
- Interface definitions for all data structures
|
||||||
|
- Type exports via `preload.ts` for renderer types
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
Use conventional commits:
|
||||||
|
- `feat:` - New features
|
||||||
|
- `fix:` - Bug fixes
|
||||||
|
- `docs:` - Documentation changes
|
||||||
|
- `refactor:` - Code refactoring
|
||||||
|
- `test:` - Test additions/changes
|
||||||
|
- `chore:` - Build process or tooling changes
|
||||||
|
|
||||||
|
### Security Requirements
|
||||||
|
|
||||||
|
1. **Use `execFileNoThrow` for all external commands** - Located in `src/main/utils/execFile.ts`
|
||||||
|
2. **Context isolation** - Keep enabled in BrowserWindow
|
||||||
|
3. **Input sanitization** - Validate all user inputs
|
||||||
|
4. **Minimal preload exposure** - Only expose necessary APIs via contextBridge
|
||||||
|
5. **Process spawning** - Use `spawn()` with `shell: false` flag
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Backend (Main Process)
|
||||||
|
- Electron 28+
|
||||||
|
- TypeScript
|
||||||
|
- node-pty - Terminal emulation
|
||||||
|
- Fastify - Web server
|
||||||
|
- electron-store - Settings persistence
|
||||||
|
- ws - WebSocket support
|
||||||
|
|
||||||
|
### Frontend (Renderer)
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Vite
|
||||||
|
- Lucide React - Icons
|
||||||
|
- react-syntax-highlighter - Code display
|
||||||
|
- marked - Markdown rendering
|
||||||
|
- dompurify - XSS prevention
|
||||||
|
|
||||||
|
## Settings Storage
|
||||||
|
|
||||||
|
Settings persisted via `electron-store`:
|
||||||
|
- **macOS**: `~/Library/Application Support/maestro/`
|
||||||
|
- **Windows**: `%APPDATA%/maestro/`
|
||||||
|
- **Linux**: `~/.config/maestro/`
|
||||||
|
|
||||||
|
Files:
|
||||||
|
- `maestro-settings.json` - User preferences
|
||||||
|
- `maestro-sessions.json` - Session persistence (planned)
|
||||||
|
- `maestro-groups.json` - Session groups (planned)
|
||||||
|
|
||||||
|
### Adding New Persistent Settings
|
||||||
|
|
||||||
|
To add a new setting that persists across sessions:
|
||||||
|
|
||||||
|
1. **Define state variable** in App.tsx:
|
||||||
|
```typescript
|
||||||
|
const [mySetting, setMySettingState] = useState<MyType>(defaultValue);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create wrapper function** that persists:
|
||||||
|
```typescript
|
||||||
|
const setMySetting = (value: MyType) => {
|
||||||
|
setMySettingState(value);
|
||||||
|
window.maestro.settings.set('mySetting', value);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Load in useEffect**:
|
||||||
|
```typescript
|
||||||
|
// Inside the loadSettings useEffect
|
||||||
|
const savedMySetting = await window.maestro.settings.get('mySetting');
|
||||||
|
if (savedMySetting !== undefined) setMySettingState(savedMySetting);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Pass wrapper to child components**, not the direct setState
|
||||||
|
|
||||||
|
**Current Persistent Settings:**
|
||||||
|
- `llmProvider`, `modelSlug`, `apiKey` - LLM configuration
|
||||||
|
- `tunnelProvider`, `tunnelApiKey` - Tunnel configuration
|
||||||
|
- `defaultAgent` - Default AI agent selection
|
||||||
|
- `fontFamily`, `fontSize`, `customFonts` - UI font settings
|
||||||
|
- `enterToSend` - Input behavior (Enter vs Command-Enter to send)
|
||||||
|
- `activeThemeId` - Selected theme
|
||||||
|
|
||||||
|
## Development Phases
|
||||||
|
|
||||||
|
The project follows a phased development approach (see PRD.md):
|
||||||
|
|
||||||
|
**Completed Phases:**
|
||||||
|
- Phase 1: Core Primitives (ProcessManager, Session Model, Dual-Mode Input)
|
||||||
|
- Phase 2: UI Foundations (Obsidian-inspired design, keyboard navigation)
|
||||||
|
|
||||||
|
**In Progress:**
|
||||||
|
- Phase 3: Intelligence Layer (auto-generated descriptions, semantic worklogs)
|
||||||
|
|
||||||
|
**Planned:**
|
||||||
|
- Phase 4: Workspace Intelligence (dual Git/non-Git modes)
|
||||||
|
- Phase 5: Status & Control Layer
|
||||||
|
- Phase 6: Remote Access & Tunneling (ngrok integration)
|
||||||
|
- Phase 7: Performance & Polish
|
||||||
|
- Phase 8: Multi-Device Continuity
|
||||||
|
|
||||||
|
## Common Development Tasks
|
||||||
|
|
||||||
|
### Adding a New UI Feature
|
||||||
|
|
||||||
|
1. **Plan the state** - Determine if it's per-session or global
|
||||||
|
2. **Add state management** - In App.tsx or component
|
||||||
|
3. **Create persistence** - Use wrapper function pattern if global
|
||||||
|
4. **Implement UI** - Follow Tailwind + theme color pattern
|
||||||
|
5. **Add keyboard shortcuts** - Integrate with existing keyboard handler
|
||||||
|
6. **Test focus flow** - Ensure Escape key navigation works
|
||||||
|
|
||||||
|
### Adding a New Modal
|
||||||
|
|
||||||
|
1. Create component in `src/renderer/components/`
|
||||||
|
2. Add state in App.tsx: `const [myModalOpen, setMyModalOpen] = useState(false)`
|
||||||
|
3. Add Escape handler in keyboard shortcuts
|
||||||
|
4. Use `fixed inset-0` overlay with `z-[50]` or higher
|
||||||
|
5. Include close button and backdrop click handler
|
||||||
|
6. Use `ref={(el) => el?.focus()}` for immediate keyboard control
|
||||||
|
|
||||||
|
### Adding Keyboard Shortcuts
|
||||||
|
|
||||||
|
1. Find the main keyboard handler in App.tsx (around line 650)
|
||||||
|
2. Add your shortcut in the appropriate section:
|
||||||
|
- Component-specific: Inside component's onKeyDown
|
||||||
|
- Global: In main useEffect keyboard handler
|
||||||
|
- Modify pressed: Check for `e.metaKey || e.ctrlKey`
|
||||||
|
3. Remember to `e.preventDefault()` to avoid browser defaults
|
||||||
|
|
||||||
|
### Working with File Tree
|
||||||
|
|
||||||
|
File tree structure is stored per-session as `fileTree` array of nodes:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
path: string;
|
||||||
|
children?: FileTreeNode[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expanded folders tracked in session's `fileExplorerExpanded: string[]` as full paths.
|
||||||
|
|
||||||
|
### Modifying Themes
|
||||||
|
|
||||||
|
1. Find `THEMES` constant in App.tsx
|
||||||
|
2. Add new theme or modify existing one
|
||||||
|
3. All color keys must be present (11 required colors)
|
||||||
|
4. Test in both light and dark mode contexts
|
||||||
|
5. Theme ID is stored in settings and persists across sessions
|
||||||
|
|
||||||
|
### Adding to Settings Modal
|
||||||
|
|
||||||
|
1. Add tab if needed in SettingsModal.tsx
|
||||||
|
2. Create state in App.tsx with wrapper function
|
||||||
|
3. Add to loadSettings useEffect
|
||||||
|
4. Pass wrapper function (not setState) to SettingsModal props
|
||||||
|
5. Add UI in appropriate tab section
|
||||||
|
|
||||||
|
## Important File Locations
|
||||||
|
|
||||||
|
### Main Process Entry Points
|
||||||
|
- `src/main/index.ts:81-109` - IPC handler setup
|
||||||
|
- `src/main/index.ts:272-282` - Process event listeners
|
||||||
|
- `src/main/process-manager.ts:30-116` - Process spawning logic
|
||||||
|
|
||||||
|
### Security-Critical Code
|
||||||
|
- `src/main/preload.ts:5` - Context bridge API exposure
|
||||||
|
- `src/main/process-manager.ts:75` - Shell disabled for spawn
|
||||||
|
- `src/main/utils/execFile.ts` - Safe command execution wrapper
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `package.json:26-86` - electron-builder config
|
||||||
|
- `tsconfig.json` - Renderer TypeScript config
|
||||||
|
- `tsconfig.main.json` - Main process TypeScript config
|
||||||
|
- `vite.config.mts` - Vite bundler config
|
||||||
|
|
||||||
|
## Debugging & Common Issues
|
||||||
|
|
||||||
|
### Focus Not Working
|
||||||
|
|
||||||
|
If keyboard shortcuts aren't working:
|
||||||
|
1. Check element has `tabIndex={0}` or `tabIndex={-1}`
|
||||||
|
2. Add `outline-none` class to hide focus ring
|
||||||
|
3. Use `ref={(el) => el?.focus()}` or `useEffect` to auto-focus
|
||||||
|
4. Check for `e.stopPropagation()` blocking events
|
||||||
|
|
||||||
|
### Settings Not Persisting
|
||||||
|
|
||||||
|
If settings don't save across sessions:
|
||||||
|
1. Ensure you created a wrapper function with `window.maestro.settings.set()`
|
||||||
|
2. Check the wrapper is passed to child components, not the direct setState
|
||||||
|
3. Verify loading code exists in the `loadSettings` useEffect
|
||||||
|
4. Use direct state setter (e.g., `setMySettingState`) in the loading code
|
||||||
|
|
||||||
|
### Modal Escape Key Not Working
|
||||||
|
|
||||||
|
If Escape doesn't close a modal:
|
||||||
|
1. Modal overlay needs `tabIndex={0}`
|
||||||
|
2. Use `ref={(el) => el?.focus()}` to focus on mount
|
||||||
|
3. Add `e.stopPropagation()` in onKeyDown handler
|
||||||
|
4. Check z-index is higher than other modals
|
||||||
|
|
||||||
|
### Theme Colors Not Applying
|
||||||
|
|
||||||
|
If colors appear hardcoded:
|
||||||
|
1. Replace fixed colors with `style={{ color: theme.colors.textMain }}`
|
||||||
|
2. Never use hardcoded hex colors for text/borders
|
||||||
|
3. Use inline styles for theme colors, Tailwind for layout
|
||||||
|
4. Check theme prop is being passed down correctly
|
||||||
|
|
||||||
|
### Scroll Position Not Saving
|
||||||
|
|
||||||
|
Per-session scroll position:
|
||||||
|
1. Container needs a ref: `useRef<HTMLDivElement>(null)`
|
||||||
|
2. Add `onScroll` handler that updates session state
|
||||||
|
3. Add useEffect to restore scroll on session change
|
||||||
|
4. Use `ref.current.scrollTop` to get/set position
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Currently no test suite implemented. When adding tests, use the `test` script in package.json.
|
||||||
|
|
||||||
|
## Recent Features Added
|
||||||
|
|
||||||
|
- **Output Search/Filter** - Press `/` in output window to filter logs
|
||||||
|
- **Scratchpad Command-E** - Toggle between Edit and Preview modes
|
||||||
|
- **File Preview Focus** - Arrow keys scroll, Escape returns to file tree
|
||||||
|
- **Sticky File Tree Header** - Working directory stays visible when scrolling
|
||||||
|
- **LLM Connection Test** - Test API connectivity in Settings before saving
|
||||||
|
- **Settings Persistence** - All settings now save across sessions
|
||||||
|
- **Emoji Picker Improvements** - Proper positioning and Escape key support
|
||||||
71
CONTRIBUTING.md
Normal file
71
CONTRIBUTING.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Contributing to Maestro
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Maestro! This document provides guidelines and instructions for contributing.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Fork and clone the repository
|
||||||
|
2. Install dependencies: `npm install`
|
||||||
|
3. Start development server: `npm run dev`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- TypeScript for all new code
|
||||||
|
- Use ESLint and Prettier (configs coming soon)
|
||||||
|
- Follow existing code patterns
|
||||||
|
- Add JSDoc comments for public APIs
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Use conventional commits:
|
||||||
|
- `feat:` - New features
|
||||||
|
- `fix:` - Bug fixes
|
||||||
|
- `docs:` - Documentation changes
|
||||||
|
- `refactor:` - Code refactoring
|
||||||
|
- `test:` - Test additions/changes
|
||||||
|
- `chore:` - Build process or tooling changes
|
||||||
|
|
||||||
|
Example: `feat: add context usage visualization`
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. Create a feature branch from `main`
|
||||||
|
2. Make your changes
|
||||||
|
3. Add tests if applicable
|
||||||
|
4. Update documentation
|
||||||
|
5. Submit PR with clear description
|
||||||
|
6. Wait for review
|
||||||
|
|
||||||
|
## Architecture Guidelines
|
||||||
|
|
||||||
|
### Main Process (Backend)
|
||||||
|
- Keep IPC handlers simple and focused
|
||||||
|
- Use TypeScript interfaces for all data structures
|
||||||
|
- Handle errors gracefully
|
||||||
|
- No blocking operations
|
||||||
|
|
||||||
|
### Renderer Process (Frontend)
|
||||||
|
- Use React hooks
|
||||||
|
- Keep components small and focused
|
||||||
|
- Use Tailwind for styling
|
||||||
|
- Maintain keyboard accessibility
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Never expose Node.js APIs to renderer
|
||||||
|
- Use preload script for all IPC
|
||||||
|
- Sanitize all user inputs
|
||||||
|
- Use `execFile` instead of `exec`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests (when available)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
npm run build:main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a GitHub Discussion or reach out in Issues.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Pedram Amini
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
362
PRD.md
Normal file
362
PRD.md
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# **Project Specification: Multi-Instance AI Coding Console (MACC)**
|
||||||
|
*A phased, validated-primitive product requirements document for engineering.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **0. Product Summary**
|
||||||
|
MACC is a unified, highly-responsive developer IDE that manages multiple AI coding assistants (Claude Code, Aider, OpenCode, Codex, etc.) simultaneously.
|
||||||
|
It runs as an Electron desktop application with a fully responsive interface that also exposes a web-view for mobile access.
|
||||||
|
Each “instance” behaves like a worker: showing state, logs, artifacts, diffs, and contextual metadata.
|
||||||
|
|
||||||
|
The app emphasizes:
|
||||||
|
- Obsidian-like UI (themes, fixed-width fonts, panel layout)
|
||||||
|
- Linear/Superhuman-level responsiveness + keyboard-first UX
|
||||||
|
- Full per-instance lifecycle control (create, resume, terminate)
|
||||||
|
- Support for Git and non-Git workspaces
|
||||||
|
- Artifact tracking, semantic worklogs, context usage visualization
|
||||||
|
- Remote control via auto-tunneled HTTPS endpoints (ngrok)
|
||||||
|
- Generic support for any CLI-based AI coding tool with no modifications required.
|
||||||
|
|
||||||
|
The specification is broken into **phases**, each ending in a **primitive** that must be validated before continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 1 — Core Architectural Primitives**
|
||||||
|
|
||||||
|
## **1.1 Primitive: Process Manager**
|
||||||
|
**Goal**: A minimal Electron backend that can launch arbitrary CLI tools, track PID, and capture raw stdout/stderr streams.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Electron main process spawns child processes.
|
||||||
|
- Output is captured line-by-line in a structured format.
|
||||||
|
- Input can be sent via STDIN.
|
||||||
|
- Must work with any CLI tool, unchanged.
|
||||||
|
- Supports persistent process registry (ID, type, path, state).
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Launch any AI CLI (Claude Code, Aider, etc.) and see raw output streaming into a debug pane.
|
||||||
|
- Send simple commands via STDIN.
|
||||||
|
- Kill and restart processes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **1.2 Primitive: Unified Session Model**
|
||||||
|
**Goal**: A generic model for a “Session” that abstracts Claude Code, Aider, Codex, etc. into the same interface.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Attributes:
|
||||||
|
- `sessionId`
|
||||||
|
- `sessionName`
|
||||||
|
- `toolType` (claude, aider, opencode, custom)
|
||||||
|
- `cwd` (working dir)
|
||||||
|
- `state` (idle, busy)
|
||||||
|
- `stdinMode` (terminal vs. AI)
|
||||||
|
- `contextUsage` (%)
|
||||||
|
- `stdoutLog[]`
|
||||||
|
- `semanticLog[]` (future)
|
||||||
|
- `artifacts[]`
|
||||||
|
- Pluggable command templates per tool type:
|
||||||
|
- Start command
|
||||||
|
- Resume command (if applicable)
|
||||||
|
- Batch/script command flags
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Create a session pointing to Claude Code or Aider and confirm identical behaviors via the unified object.
|
||||||
|
- Switching toolType requires no UI changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **1.3 Primitive: Dual-Mode Input Router**
|
||||||
|
**Goal**: Toggle between:
|
||||||
|
1. **Terminal Mode** → sends raw shell commands to the OS.
|
||||||
|
2. **AI Interaction Mode** → sends input to the AI process.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- UI toggle switch.
|
||||||
|
- Terminal mode uses node-pty or equivalent pseudoterminal.
|
||||||
|
- AI mode uses direct STDIN to the assistant process.
|
||||||
|
- Visual indication of current mode.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Send `ls` in terminal mode and get directory listing.
|
||||||
|
- Send message in AI mode and receive assistant output.
|
||||||
|
- No cross-contamination.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 2 — UI Foundations**
|
||||||
|
|
||||||
|
## **2.1 Primitive: Obsidian-Inspired Visual System**
|
||||||
|
**Goal**: Core layout and theming.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Fixed-width fonts everywhere.
|
||||||
|
- Theme engine with:
|
||||||
|
- Dracula (default)
|
||||||
|
- Dark
|
||||||
|
- Light
|
||||||
|
- Monokai
|
||||||
|
- Minimalistic panel layout:
|
||||||
|
- Session list (left)
|
||||||
|
- Workspace panel (center)
|
||||||
|
- Logs/Artifacts panel (right)
|
||||||
|
- Responsive grid that collapses elegantly for mobile web.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Theme switching works live.
|
||||||
|
- Layout remains stable on window resize.
|
||||||
|
- Mobile view loads via localhost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **2.2 Primitive: Keyboard-First Navigation**
|
||||||
|
**Goal**: Achieve Linear/Superhuman responsiveness.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Global shortcuts:
|
||||||
|
- `⌘K / Ctrl+K` command palette
|
||||||
|
- `⌘1,2,3` for switching major panels
|
||||||
|
- `⌘⇧N` create new session
|
||||||
|
- `⌘[` and `⌘]` cycle sessions
|
||||||
|
- Per-session shortcuts:
|
||||||
|
- Toggle terminal/AI mode
|
||||||
|
- Kill/restart instance
|
||||||
|
- Low-latency event pipeline (< 16ms UI update target)
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Command palette works.
|
||||||
|
- All shortcuts trigger instantly.
|
||||||
|
- No perceptible lag (< ~30ms worst case).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 3 — Session Intelligence Layer**
|
||||||
|
|
||||||
|
## **3.1 Primitive: Auto-Generated Description**
|
||||||
|
**Goal**: Session auto-describes its purpose based on recent activity.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Analyze last N messages.
|
||||||
|
- Generate a one-sentence summary.
|
||||||
|
- Update after every user instruction.
|
||||||
|
- Display under session name.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Summary updates correctly when switching tasks.
|
||||||
|
- Descriptions feel accurate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **3.2 Primitive: Semantic Worklog Engine**
|
||||||
|
**Goal**: Maintain a human-readable summary of activity per instance.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Summaries generated incrementally:
|
||||||
|
- What tasks were attempted?
|
||||||
|
- What results occurred?
|
||||||
|
- What files changed?
|
||||||
|
- What errors occurred?
|
||||||
|
- Stored as structured entries.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- After a session with multiple commands, worklog renders meaningful history.
|
||||||
|
- Semantic entries correlate with artifacts/diffs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **3.3 Primitive: Context Window Analyzer**
|
||||||
|
**Goal**: Display % context used for each assistant.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Query tool APIs for context usage when available (Claude Code supports this).
|
||||||
|
- Estimate context via tokenization fallback for tools lacking API.
|
||||||
|
- Show progress bar (0–100%).
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Claude Code sessions show real usage.
|
||||||
|
- Other tools display estimated usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 4 — Workspace Intelligence**
|
||||||
|
|
||||||
|
## **4.1 Primitive: Dual Workspace Modes**
|
||||||
|
**Goal**: Behavior changes depending on whether the session is in a Git repo.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Auto-detect Git repo.
|
||||||
|
- If **Git workspace**:
|
||||||
|
- Show “Changed Files” list (`git diff --name-only`)
|
||||||
|
- On click → show full diff
|
||||||
|
- If **Non-Git workspace**:
|
||||||
|
- Track file changes (created, modified, deleted)
|
||||||
|
- Display artifact list
|
||||||
|
- Link artifacts to semantic worklog
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Switching between Git and non-Git folders produces different UI.
|
||||||
|
- Diffs display cleanly.
|
||||||
|
- Artifacts tracked reliably.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 5 — State, Status & Control Layer**
|
||||||
|
|
||||||
|
## **5.1 Primitive: Status Indicators**
|
||||||
|
**Goal**: Show live state of each instance.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Green = idle / awaiting input.
|
||||||
|
- Red = busy / processing.
|
||||||
|
- Update state based on stdout patterns & timing.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Status flips to “busy” on any processing.
|
||||||
|
- Returns to “idle” after output completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **5.2 Primitive: Session Lifecycle Controls**
|
||||||
|
**Goal**: Full process management.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Buttons:
|
||||||
|
- Start
|
||||||
|
- Resume
|
||||||
|
- Stop/Kill
|
||||||
|
- Duplicate session
|
||||||
|
- Safe shutdown logic.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Perform lifecycle control on all supported tools.
|
||||||
|
- No orphaned processes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 6 — Remote Access & Tunneling**
|
||||||
|
|
||||||
|
## **6.1 Primitive: Local Web Server**
|
||||||
|
**Goal**: Expose a web-based interface with responsive layout.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Serves:
|
||||||
|
- Session list
|
||||||
|
- Log viewer
|
||||||
|
- Input pane
|
||||||
|
- Mobile-friendly navigation
|
||||||
|
- Read-only mode toggle (security)
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Accessed from phone on LAN.
|
||||||
|
- Full session control works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **6.2 Primitive: Integrated ngrok Tunneling**
|
||||||
|
**Goal**: One-click public remote access.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- User enters ngrok API key once.
|
||||||
|
- A button toggles tunnel on/off.
|
||||||
|
- Electron app retrieves and displays:
|
||||||
|
- Public URL
|
||||||
|
- Connection status
|
||||||
|
- Automatic reconnect on failure.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Tunnel spins up with one click.
|
||||||
|
- Mobile access works over LTE.
|
||||||
|
- Reconnecting maintains session state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 7 — Performance & Polish**
|
||||||
|
|
||||||
|
## **7.1 Primitive: High-Responsiveness Engine**
|
||||||
|
**Goal**: Match Linear/Superhuman’s feel.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Pre-render critical views.
|
||||||
|
- Use React + TypeScript + Recoil/Zustand for low latency.
|
||||||
|
- GPU rendering hints (CSS transform layering).
|
||||||
|
- WebSocket bridge with main process for real-time output.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Session switching < 50ms.
|
||||||
|
- Keyboard commands < 30ms response.
|
||||||
|
- Streaming output renders with minimal delay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **7.2 Primitive: Extension & Plugin System**
|
||||||
|
**Goal**: Allow future support for more AI tools or features.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Configurable tool definitions:
|
||||||
|
- Startup commands
|
||||||
|
- Flags
|
||||||
|
- Output parsing rules
|
||||||
|
- User-added tool templates.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Adding a new tool requires no core code modification.
|
||||||
|
- YAML/JSON templates work successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **PHASE 8 — Final Integration & UX Polish**
|
||||||
|
|
||||||
|
## **8.1 Primitive: Unified Project Pane**
|
||||||
|
**Goal**: Provide high-level visibility across all sessions.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Master session dashboard:
|
||||||
|
- Status indicators
|
||||||
|
- Descriptions
|
||||||
|
- Context bars
|
||||||
|
- Git/non-Git badges
|
||||||
|
- Global search.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Dashboard accurately reflects all instance states.
|
||||||
|
- Search finds sessions, logs, and artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## **8.2 Primitive: Multi-Device Continuity**
|
||||||
|
**Goal**: Seamless transition between desktop and mobile.
|
||||||
|
|
||||||
|
### **Requirements**
|
||||||
|
- Shared session state between desktop UI and web UI.
|
||||||
|
- Live sync of logs and output streams.
|
||||||
|
|
||||||
|
### **Validation Criteria**
|
||||||
|
- Actions performed on phone instantly reflect on desktop.
|
||||||
|
- Desktop input reflects on mobile viewer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# **TECHNICAL STACK DECISIONS**
|
||||||
|
|
||||||
|
### **Electron**
|
||||||
|
- Desktop environment
|
||||||
|
- Mobility via embedded webserver
|
||||||
|
|
||||||
|
### **Frontend**
|
||||||
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Zustand (store)
|
||||||
|
- Tailwind or custom styling engine
|
||||||
|
- Monaco editor for diff rendering
|
||||||
|
|
||||||
|
### **Backend**
|
||||||
|
- Node.js
|
||||||
|
- node-pty for terminal mode
|
||||||
|
- Child process spawn for AI tools
|
||||||
|
- Fastify or Express for local web server
|
||||||
|
- WebSocket bridge for real-time updates
|
||||||
|
|
||||||
|
### **Remote Access**
|
||||||
|
- ngrok (free tier)
|
||||||
|
|
||||||
|
|
||||||
225
README.md
Normal file
225
README.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Maestro
|
||||||
|
|
||||||
|
> A unified, highly-responsive developer IDE for managing multiple AI coding assistants simultaneously.
|
||||||
|
|
||||||
|
Maestro is a desktop application built with Electron that allows you to run and manage multiple AI coding tools (Claude Code, Aider, OpenCode, etc.) in parallel with a Linear/Superhuman-level responsive interface.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 **Multi-Instance Management** - Run multiple AI assistants and terminal sessions simultaneously
|
||||||
|
- 🎨 **Beautiful UI** - Obsidian-inspired themes with keyboard-first navigation
|
||||||
|
- 🔄 **Dual-Mode Input** - Switch between terminal and AI interaction modes seamlessly
|
||||||
|
- 📊 **Context Tracking** - Monitor token usage and context windows in real-time
|
||||||
|
- 🌐 **Remote Access** - Built-in web server with optional ngrok/Cloudflare tunneling
|
||||||
|
- 🎯 **Git Integration** - Automatic git status, diff tracking, and workspace detection
|
||||||
|
- ⚡ **Keyboard Shortcuts** - Full keyboard control with customizable shortcuts
|
||||||
|
- 📝 **Session Management** - Group, rename, and organize your sessions
|
||||||
|
- 🎭 **Multiple Themes** - Dracula, Monokai, GitHub Light, and Solarized
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm or yarn
|
||||||
|
- Git (optional, for git-aware features)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/maestro.git
|
||||||
|
cd maestro
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Package for distribution
|
||||||
|
npm run package
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform-Specific Builds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS only
|
||||||
|
npm run package:mac
|
||||||
|
|
||||||
|
# Windows only
|
||||||
|
npm run package:win
|
||||||
|
|
||||||
|
# Linux only
|
||||||
|
npm run package:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
maestro/
|
||||||
|
├── src/
|
||||||
|
│ ├── main/ # Electron main process
|
||||||
|
│ │ ├── index.ts # Main entry point
|
||||||
|
│ │ ├── process-manager.ts # CLI tool spawning
|
||||||
|
│ │ ├── web-server.ts # Remote access server
|
||||||
|
│ │ ├── preload.ts # IPC bridge
|
||||||
|
│ │ └── utils/ # Utilities
|
||||||
|
│ └── renderer/ # React frontend
|
||||||
|
│ ├── App.tsx # Main UI component
|
||||||
|
│ ├── main.tsx # Renderer entry
|
||||||
|
│ └── index.css # Styles
|
||||||
|
├── build/ # App icons
|
||||||
|
├── .github/workflows/ # CI/CD
|
||||||
|
└── dist/ # Build output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
**Backend (Electron Main)**
|
||||||
|
- Electron 28+
|
||||||
|
- TypeScript
|
||||||
|
- node-pty (terminal emulation)
|
||||||
|
- Fastify (web server)
|
||||||
|
- electron-store (settings persistence)
|
||||||
|
|
||||||
|
**Frontend (Renderer)**
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- Vite
|
||||||
|
- Lucide React (icons)
|
||||||
|
|
||||||
|
### Development Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server with hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build main process only
|
||||||
|
npm run build:main
|
||||||
|
|
||||||
|
# Build renderer only
|
||||||
|
npm run build:renderer
|
||||||
|
|
||||||
|
# Full production build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start built application
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for Release
|
||||||
|
|
||||||
|
### 1. Prepare Icons
|
||||||
|
|
||||||
|
Place your application icons in the `build/` directory:
|
||||||
|
- `icon.icns` - macOS (512x512 or 1024x1024)
|
||||||
|
- `icon.ico` - Windows (256x256)
|
||||||
|
- `icon.png` - Linux (512x512)
|
||||||
|
|
||||||
|
### 2. Update Version
|
||||||
|
|
||||||
|
Update version in `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build Distributables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for all platforms
|
||||||
|
npm run package
|
||||||
|
|
||||||
|
# Platform-specific
|
||||||
|
npm run package:mac # Creates .dmg and .zip
|
||||||
|
npm run package:win # Creates .exe installer
|
||||||
|
npm run package:linux # Creates .AppImage, .deb, .rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Output will be in the `release/` directory.
|
||||||
|
|
||||||
|
## GitHub Actions Workflow
|
||||||
|
|
||||||
|
The project includes automated builds via GitHub Actions:
|
||||||
|
|
||||||
|
1. **Create a release tag:**
|
||||||
|
```bash
|
||||||
|
git tag v0.1.0
|
||||||
|
git push origin v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **GitHub Actions will automatically:**
|
||||||
|
- Build for macOS, Windows, and Linux
|
||||||
|
- Create release artifacts
|
||||||
|
- Publish a GitHub Release with downloads
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are stored in:
|
||||||
|
- **macOS**: `~/Library/Application Support/maestro/`
|
||||||
|
- **Windows**: `%APPDATA%/maestro/`
|
||||||
|
- **Linux**: `~/.config/maestro/`
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- `maestro-settings.json` - User preferences (theme, shortcuts, API keys)
|
||||||
|
- `maestro-sessions.json` - Session persistence
|
||||||
|
- `maestro-groups.json` - Session groups
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Process Management
|
||||||
|
|
||||||
|
Maestro uses a dual-process model:
|
||||||
|
|
||||||
|
1. **PTY Processes** - For terminal sessions (full shell emulation)
|
||||||
|
2. **Child Processes** - For AI tools (Claude Code, Aider, etc.)
|
||||||
|
|
||||||
|
All processes are managed through IPC (Inter-Process Communication) with secure context isolation.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- ✅ Context isolation enabled
|
||||||
|
- ✅ No node integration in renderer
|
||||||
|
- ✅ Secure IPC via preload script
|
||||||
|
- ✅ No shell injection (uses `execFile` instead of `exec`)
|
||||||
|
- ✅ Input sanitization for all user inputs
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Action | Shortcut |
|
||||||
|
|--------|----------|
|
||||||
|
| Quick Actions | `⌘K` / `Ctrl+K` |
|
||||||
|
| Toggle Sidebar | `⌘B` / `Ctrl+B` |
|
||||||
|
| Toggle Right Panel | `⌘\` / `Ctrl+\` |
|
||||||
|
| New Instance | `⌘N` / `Ctrl+N` |
|
||||||
|
| Kill Instance | `⌘⌫` / `Ctrl+Backspace` |
|
||||||
|
| Previous Instance | `⌘⇧{` / `Ctrl+Shift+{` |
|
||||||
|
| Next Instance | `⌘⇧}` / `Ctrl+Shift+}` |
|
||||||
|
| Switch AI/Shell Mode | `⌘J` / `Ctrl+J` |
|
||||||
|
| Show Shortcuts | `⌘/` / `Ctrl+/` |
|
||||||
|
|
||||||
|
*All shortcuts are customizable in Settings*
|
||||||
|
|
||||||
|
## Remote Access
|
||||||
|
|
||||||
|
Maestro includes a built-in web server for remote access:
|
||||||
|
|
||||||
|
1. **Local Access**: `http://localhost:8000`
|
||||||
|
2. **LAN Access**: `http://[your-ip]:8000`
|
||||||
|
3. **Public Access**: Enable ngrok/Cloudflare tunnel in Settings
|
||||||
|
|
||||||
|
### Enabling Public Tunnels
|
||||||
|
|
||||||
|
1. Get an ngrok auth token from https://ngrok.com
|
||||||
|
2. Open Settings → Network
|
||||||
|
3. Enter your ngrok API key
|
||||||
|
4. Click the tunnel button on any session
|
||||||
21
build/README.md
Normal file
21
build/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Build Assets
|
||||||
|
|
||||||
|
Place your application icons in this directory:
|
||||||
|
|
||||||
|
- **icon.icns** - macOS app icon (512x512 or 1024x1024)
|
||||||
|
- **icon.ico** - Windows app icon (256x256)
|
||||||
|
- **icon.png** - Linux app icon (512x512)
|
||||||
|
|
||||||
|
You can generate these from a single source PNG using tools like:
|
||||||
|
- https://www.electronforge.io/guides/create-and-add-icons
|
||||||
|
- https://icon.kitchen/
|
||||||
|
- ImageMagick
|
||||||
|
|
||||||
|
Example using ImageMagick:
|
||||||
|
```bash
|
||||||
|
# Generate macOS icon
|
||||||
|
png2icns icon.icns icon.png
|
||||||
|
|
||||||
|
# Generate Windows icon
|
||||||
|
convert icon.png -define icon:auto-resize=256,128,96,64,48,32,16 icon.ico
|
||||||
|
```
|
||||||
BIN
build/icon.icns
Normal file
BIN
build/icon.icns
Normal file
Binary file not shown.
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
10473
package-lock.json
generated
Normal file
10473
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
package.json
Normal file
126
package.json
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"name": "maestro",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Multi-Instance AI Coding Console - Unified IDE for managing multiple AI coding assistants",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"author": "Maestro Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/yourusername/maestro.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
|
||||||
|
"dev:main": "tsc -p tsconfig.main.json && NODE_ENV=development electron .",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"package": "npm run build && electron-builder --mac --win --linux",
|
||||||
|
"package:mac": "npm run build && electron-builder --mac",
|
||||||
|
"package:win": "npm run build && electron-builder --win",
|
||||||
|
"package:linux": "npm run build && electron-builder --linux",
|
||||||
|
"start": "electron .",
|
||||||
|
"clean": "rm -rf dist release node_modules/.vite",
|
||||||
|
"postinstall": "electron-rebuild -f -w node-pty"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.maestro.app",
|
||||||
|
"productName": "Maestro",
|
||||||
|
"directories": {
|
||||||
|
"output": "release",
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.developer-tools",
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dmg",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.icns"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.ico"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb",
|
||||||
|
"rpm"
|
||||||
|
],
|
||||||
|
"category": "Development",
|
||||||
|
"icon": "build/icon.png"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"@fastify/websocket": "^9.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"dompurify": "^3.3.0",
|
||||||
|
"electron-store": "^8.1.0",
|
||||||
|
"fastify": "^4.25.2",
|
||||||
|
"marked": "^17.0.1",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/react": "^18.2.47",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"electron": "^28.1.0",
|
||||||
|
"electron-builder": "^24.9.1",
|
||||||
|
"electron-rebuild": "^3.2.9",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11",
|
||||||
|
"vite-plugin-electron": "^0.28.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
107
src/main/agent-detector.ts
Normal file
107
src/main/agent-detector.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { execFileNoThrow } from './utils/execFile';
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
binaryName: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
available: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path'>[] = [
|
||||||
|
{
|
||||||
|
id: 'claude-code',
|
||||||
|
name: 'Claude Code',
|
||||||
|
binaryName: 'claude',
|
||||||
|
command: 'claude',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aider-gemini',
|
||||||
|
name: 'Aider (Gemini)',
|
||||||
|
binaryName: 'aider',
|
||||||
|
command: 'aider',
|
||||||
|
args: ['--model', 'gemini/gemini-2.0-flash-exp'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qwen-coder',
|
||||||
|
name: 'Qwen Coder',
|
||||||
|
binaryName: 'qwen-coder',
|
||||||
|
command: 'qwen-coder',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cli',
|
||||||
|
name: 'CLI Terminal',
|
||||||
|
binaryName: 'bash',
|
||||||
|
command: 'bash',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class AgentDetector {
|
||||||
|
private cachedAgents: AgentConfig[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which agents are available on the system
|
||||||
|
*/
|
||||||
|
async detectAgents(): Promise<AgentConfig[]> {
|
||||||
|
if (this.cachedAgents) {
|
||||||
|
return this.cachedAgents;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents: AgentConfig[] = [];
|
||||||
|
|
||||||
|
for (const agentDef of AGENT_DEFINITIONS) {
|
||||||
|
const detection = await this.checkBinaryExists(agentDef.binaryName);
|
||||||
|
|
||||||
|
agents.push({
|
||||||
|
...agentDef,
|
||||||
|
available: detection.exists,
|
||||||
|
path: detection.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedAgents = agents;
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a binary exists in PATH
|
||||||
|
*/
|
||||||
|
private async checkBinaryExists(binaryName: string): Promise<{ exists: boolean; path?: string }> {
|
||||||
|
try {
|
||||||
|
// Use 'which' on Unix-like systems, 'where' on Windows
|
||||||
|
const command = process.platform === 'win32' ? 'where' : 'which';
|
||||||
|
const result = await execFileNoThrow(command, [binaryName]);
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && result.stdout.trim()) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
path: result.stdout.trim().split('\n')[0], // First match
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: false };
|
||||||
|
} catch (error) {
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific agent by ID
|
||||||
|
*/
|
||||||
|
async getAgent(agentId: string): Promise<AgentConfig | null> {
|
||||||
|
const agents = await this.detectAgents();
|
||||||
|
return agents.find(a => a.id === agentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache (useful if PATH changes)
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.cachedAgents = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
314
src/main/index.ts
Normal file
314
src/main/index.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import { ProcessManager } from './process-manager';
|
||||||
|
import { WebServer } from './web-server';
|
||||||
|
import { AgentDetector } from './agent-detector';
|
||||||
|
import { execFileNoThrow } from './utils/execFile';
|
||||||
|
import Store from 'electron-store';
|
||||||
|
|
||||||
|
// Type definitions
|
||||||
|
interface MaestroSettings {
|
||||||
|
activeThemeId: string;
|
||||||
|
llmProvider: string;
|
||||||
|
modelSlug: string;
|
||||||
|
apiKey: string;
|
||||||
|
tunnelProvider: string;
|
||||||
|
tunnelApiKey: string;
|
||||||
|
shortcuts: Record<string, any>;
|
||||||
|
defaultAgent: string;
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
customFonts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store<MaestroSettings>({
|
||||||
|
name: 'maestro-settings',
|
||||||
|
defaults: {
|
||||||
|
activeThemeId: 'dracula',
|
||||||
|
llmProvider: 'openrouter',
|
||||||
|
modelSlug: 'anthropic/claude-3.5-sonnet',
|
||||||
|
apiKey: '',
|
||||||
|
tunnelProvider: 'ngrok',
|
||||||
|
tunnelApiKey: '',
|
||||||
|
shortcuts: {},
|
||||||
|
defaultAgent: 'claude-code',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace',
|
||||||
|
customFonts: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
let processManager: ProcessManager | null = null;
|
||||||
|
let webServer: WebServer | null = null;
|
||||||
|
let agentDetector: AgentDetector | null = null;
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1000,
|
||||||
|
minHeight: 600,
|
||||||
|
backgroundColor: '#0b0b0d',
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the app
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// Initialize core services
|
||||||
|
processManager = new ProcessManager();
|
||||||
|
webServer = new WebServer(8000);
|
||||||
|
agentDetector = new AgentDetector();
|
||||||
|
|
||||||
|
// Set up IPC handlers
|
||||||
|
setupIpcHandlers();
|
||||||
|
|
||||||
|
// Set up process event listeners
|
||||||
|
setupProcessListeners();
|
||||||
|
|
||||||
|
// Create main window
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// Start web server for remote access
|
||||||
|
webServer.start();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
// Clean up all running processes
|
||||||
|
processManager?.killAll();
|
||||||
|
webServer?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupIpcHandlers() {
|
||||||
|
// Settings management
|
||||||
|
ipcMain.handle('settings:get', async (_, key: string) => {
|
||||||
|
return store.get(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('settings:set', async (_, key: string, value: any) => {
|
||||||
|
store.set(key, value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('settings:getAll', async () => {
|
||||||
|
return store.store;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session/Process management
|
||||||
|
ipcMain.handle('process:spawn', async (_, config: {
|
||||||
|
sessionId: string;
|
||||||
|
toolType: string;
|
||||||
|
cwd: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
}) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
return processManager.spawn(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('process:write', async (_, sessionId: string, data: string) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
return processManager.write(sessionId, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('process:kill', async (_, sessionId: string) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
return processManager.kill(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('process:resize', async (_, sessionId: string, cols: number, rows: number) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
return processManager.resize(sessionId, cols, rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Git operations
|
||||||
|
ipcMain.handle('git:status', async (_, cwd: string) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
return processManager.execCommand('git status --porcelain', cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('git:diff', async (_, cwd: string, file?: string) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
const command = file ? `git diff ${file}` : 'git diff';
|
||||||
|
return processManager.execCommand(command, cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('git:isRepo', async (_, cwd: string) => {
|
||||||
|
if (!processManager) throw new Error('Process manager not initialized');
|
||||||
|
try {
|
||||||
|
await processManager.execCommand('git rev-parse --is-inside-work-tree', cwd);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File system operations
|
||||||
|
ipcMain.handle('fs:readDir', async (_, dirPath: string) => {
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
// Convert Dirent objects to plain objects for IPC serialization
|
||||||
|
return entries.map((entry: any) => ({
|
||||||
|
name: entry.name,
|
||||||
|
isDirectory: entry.isDirectory(),
|
||||||
|
isFile: entry.isFile()
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
return content;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to read file: ${error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tunnel management (placeholder - will integrate ngrok/cloudflare)
|
||||||
|
ipcMain.handle('tunnel:start', async (_event, port: number, provider: string) => {
|
||||||
|
// TODO: Implement actual tunnel spawning
|
||||||
|
console.log(`Starting tunnel on port ${port} with ${provider}`);
|
||||||
|
return {
|
||||||
|
url: `https://mock-${Math.random().toString(36).substr(2, 9)}.ngrok.io`,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('tunnel:stop', async (_event, sessionId: string) => {
|
||||||
|
// TODO: Implement tunnel cleanup
|
||||||
|
console.log(`Stopping tunnel for session ${sessionId}`);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Web server management
|
||||||
|
ipcMain.handle('webserver:getUrl', async () => {
|
||||||
|
return webServer?.getUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent management
|
||||||
|
ipcMain.handle('agents:detect', async () => {
|
||||||
|
if (!agentDetector) throw new Error('Agent detector not initialized');
|
||||||
|
return agentDetector.detectAgents();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('agents:get', async (_event, agentId: string) => {
|
||||||
|
if (!agentDetector) throw new Error('Agent detector not initialized');
|
||||||
|
return agentDetector.getAgent(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Folder selection dialog
|
||||||
|
ipcMain.handle('dialog:selectFolder', async () => {
|
||||||
|
if (!mainWindow) return null;
|
||||||
|
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
title: 'Select Working Directory',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Font detection
|
||||||
|
ipcMain.handle('fonts:detect', async () => {
|
||||||
|
try {
|
||||||
|
// Use fc-list on all platforms (faster than system_profiler on macOS)
|
||||||
|
// macOS: 0.74s (was 8.77s with system_profiler) - 11.9x faster
|
||||||
|
// Linux/Windows: 0.5-0.6s
|
||||||
|
const result = await execFileNoThrow('fc-list', [':', 'family']);
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && result.stdout) {
|
||||||
|
// Parse font list and deduplicate
|
||||||
|
const fonts = result.stdout
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line: string) => line.trim())
|
||||||
|
.filter(font => font.length > 0);
|
||||||
|
|
||||||
|
// Deduplicate fonts (fc-list can return duplicates)
|
||||||
|
return [...new Set(fonts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if fc-list not available (rare on modern systems)
|
||||||
|
return ['Monaco', 'Menlo', 'Courier New', 'Consolas', 'Roboto Mono', 'Fira Code', 'JetBrains Mono'];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Font detection error:', error);
|
||||||
|
// Return common monospace fonts as fallback
|
||||||
|
return ['Monaco', 'Menlo', 'Courier New', 'Consolas', 'Roboto Mono', 'Fira Code', 'JetBrains Mono'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shell operations
|
||||||
|
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
|
||||||
|
await shell.openExternal(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DevTools operations
|
||||||
|
ipcMain.handle('devtools:open', async () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('devtools:close', async () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.closeDevTools();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('devtools:toggle', async () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||||
|
mainWindow.webContents.closeDevTools();
|
||||||
|
} else {
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle process output streaming (set up after initialization)
|
||||||
|
function setupProcessListeners() {
|
||||||
|
if (processManager) {
|
||||||
|
processManager.on('data', (sessionId: string, data: string) => {
|
||||||
|
mainWindow?.webContents.send('process:data', sessionId, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
processManager.on('exit', (sessionId: string, code: number) => {
|
||||||
|
mainWindow?.webContents.send('process:exit', sessionId, code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/main/preload.ts
Normal file
138
src/main/preload.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
// Expose protected methods that allow the renderer process to use
|
||||||
|
// the ipcRenderer without exposing the entire object
|
||||||
|
contextBridge.exposeInMainWorld('maestro', {
|
||||||
|
// Settings API
|
||||||
|
settings: {
|
||||||
|
get: (key: string) => ipcRenderer.invoke('settings:get', key),
|
||||||
|
set: (key: string, value: any) => ipcRenderer.invoke('settings:set', key, value),
|
||||||
|
getAll: () => ipcRenderer.invoke('settings:getAll'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Process/Session API
|
||||||
|
process: {
|
||||||
|
spawn: (config: any) => ipcRenderer.invoke('process:spawn', config),
|
||||||
|
write: (sessionId: string, data: string) => ipcRenderer.invoke('process:write', sessionId, data),
|
||||||
|
kill: (sessionId: string) => ipcRenderer.invoke('process:kill', sessionId),
|
||||||
|
resize: (sessionId: string, cols: number, rows: number) =>
|
||||||
|
ipcRenderer.invoke('process:resize', sessionId, cols, rows),
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
onData: (callback: (sessionId: string, data: string) => void) => {
|
||||||
|
ipcRenderer.on('process:data', (_, sessionId, data) => callback(sessionId, data));
|
||||||
|
},
|
||||||
|
onExit: (callback: (sessionId: string, code: number) => void) => {
|
||||||
|
ipcRenderer.on('process:exit', (_, sessionId, code) => callback(sessionId, code));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Git API
|
||||||
|
git: {
|
||||||
|
status: (cwd: string) => ipcRenderer.invoke('git:status', cwd),
|
||||||
|
diff: (cwd: string, file?: string) => ipcRenderer.invoke('git:diff', cwd, file),
|
||||||
|
isRepo: (cwd: string) => ipcRenderer.invoke('git:isRepo', cwd),
|
||||||
|
},
|
||||||
|
|
||||||
|
// File System API
|
||||||
|
fs: {
|
||||||
|
readDir: (dirPath: string) => ipcRenderer.invoke('fs:readDir', dirPath),
|
||||||
|
readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tunnel API
|
||||||
|
tunnel: {
|
||||||
|
start: (port: number, provider: string) => ipcRenderer.invoke('tunnel:start', port, provider),
|
||||||
|
stop: (sessionId: string) => ipcRenderer.invoke('tunnel:stop', sessionId),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Web Server API
|
||||||
|
webserver: {
|
||||||
|
getUrl: () => ipcRenderer.invoke('webserver:getUrl'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Agent API
|
||||||
|
agents: {
|
||||||
|
detect: () => ipcRenderer.invoke('agents:detect'),
|
||||||
|
get: (agentId: string) => ipcRenderer.invoke('agents:get', agentId),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dialog API
|
||||||
|
dialog: {
|
||||||
|
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Font API
|
||||||
|
fonts: {
|
||||||
|
detect: () => ipcRenderer.invoke('fonts:detect'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shell API
|
||||||
|
shell: {
|
||||||
|
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||||
|
},
|
||||||
|
|
||||||
|
// DevTools API
|
||||||
|
devtools: {
|
||||||
|
open: () => ipcRenderer.invoke('devtools:open'),
|
||||||
|
close: () => ipcRenderer.invoke('devtools:close'),
|
||||||
|
toggle: () => ipcRenderer.invoke('devtools:toggle'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type definitions for TypeScript
|
||||||
|
export interface MaestroAPI {
|
||||||
|
settings: {
|
||||||
|
get: (key: string) => Promise<any>;
|
||||||
|
set: (key: string, value: any) => Promise<boolean>;
|
||||||
|
getAll: () => Promise<any>;
|
||||||
|
};
|
||||||
|
process: {
|
||||||
|
spawn: (config: any) => Promise<{ pid: number; success: boolean }>;
|
||||||
|
write: (sessionId: string, data: string) => Promise<boolean>;
|
||||||
|
kill: (sessionId: string) => Promise<boolean>;
|
||||||
|
resize: (sessionId: string, cols: number, rows: number) => Promise<boolean>;
|
||||||
|
onData: (callback: (sessionId: string, data: string) => void) => void;
|
||||||
|
onExit: (callback: (sessionId: string, code: number) => void) => void;
|
||||||
|
};
|
||||||
|
git: {
|
||||||
|
status: (cwd: string) => Promise<string>;
|
||||||
|
diff: (cwd: string, file?: string) => Promise<string>;
|
||||||
|
isRepo: (cwd: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
fs: {
|
||||||
|
readDir: (dirPath: string) => Promise<any[]>;
|
||||||
|
readFile: (filePath: string) => Promise<string>;
|
||||||
|
};
|
||||||
|
tunnel: {
|
||||||
|
start: (port: number, provider: string) => Promise<{ url: string; active: boolean }>;
|
||||||
|
stop: (sessionId: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
webserver: {
|
||||||
|
getUrl: () => Promise<string>;
|
||||||
|
};
|
||||||
|
agents: {
|
||||||
|
detect: () => Promise<any[]>;
|
||||||
|
get: (agentId: string) => Promise<any>;
|
||||||
|
};
|
||||||
|
dialog: {
|
||||||
|
selectFolder: () => Promise<string | null>;
|
||||||
|
};
|
||||||
|
fonts: {
|
||||||
|
detect: () => Promise<string[]>;
|
||||||
|
};
|
||||||
|
shell: {
|
||||||
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
devtools: {
|
||||||
|
open: () => Promise<void>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
toggle: () => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
maestro: MaestroAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/main/process-manager.ts
Normal file
218
src/main/process-manager.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import * as pty from 'node-pty';
|
||||||
|
import { execFileNoThrow } from './utils/execFile';
|
||||||
|
|
||||||
|
interface ProcessConfig {
|
||||||
|
sessionId: string;
|
||||||
|
toolType: string;
|
||||||
|
cwd: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedProcess {
|
||||||
|
sessionId: string;
|
||||||
|
toolType: string;
|
||||||
|
ptyProcess?: pty.IPty;
|
||||||
|
childProcess?: ChildProcess;
|
||||||
|
cwd: string;
|
||||||
|
pid: number;
|
||||||
|
isTerminal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProcessManager extends EventEmitter {
|
||||||
|
private processes: Map<string, ManagedProcess> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a new process for a session
|
||||||
|
*/
|
||||||
|
spawn(config: ProcessConfig): { pid: number; success: boolean } {
|
||||||
|
const { sessionId, toolType, cwd, command, args } = config;
|
||||||
|
|
||||||
|
// Determine if this should be a terminal (pty) or regular process
|
||||||
|
const isTerminal = toolType === 'terminal';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTerminal) {
|
||||||
|
// Use node-pty for terminal mode (full shell emulation)
|
||||||
|
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
||||||
|
const ptyProcess = pty.spawn(shell, [], {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: 80,
|
||||||
|
rows: 30,
|
||||||
|
cwd: cwd,
|
||||||
|
env: process.env as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const managedProcess: ManagedProcess = {
|
||||||
|
sessionId,
|
||||||
|
toolType,
|
||||||
|
ptyProcess,
|
||||||
|
cwd,
|
||||||
|
pid: ptyProcess.pid,
|
||||||
|
isTerminal: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.processes.set(sessionId, managedProcess);
|
||||||
|
|
||||||
|
// Handle output
|
||||||
|
ptyProcess.onData((data) => {
|
||||||
|
this.emit('data', sessionId, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
|
this.emit('exit', sessionId, exitCode);
|
||||||
|
this.processes.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pid: ptyProcess.pid, success: true };
|
||||||
|
} else {
|
||||||
|
// Use regular child_process for AI tools
|
||||||
|
const childProcess = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: false, // Explicitly disable shell to prevent injection
|
||||||
|
});
|
||||||
|
|
||||||
|
const managedProcess: ManagedProcess = {
|
||||||
|
sessionId,
|
||||||
|
toolType,
|
||||||
|
childProcess,
|
||||||
|
cwd,
|
||||||
|
pid: childProcess.pid || -1,
|
||||||
|
isTerminal: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.processes.set(sessionId, managedProcess);
|
||||||
|
|
||||||
|
// Handle stdout
|
||||||
|
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
this.emit('data', sessionId, data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle stderr
|
||||||
|
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
this.emit('data', sessionId, `[stderr] ${data.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle exit
|
||||||
|
childProcess.on('exit', (code) => {
|
||||||
|
this.emit('exit', sessionId, code || 0);
|
||||||
|
this.processes.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
childProcess.on('error', (error) => {
|
||||||
|
this.emit('data', sessionId, `[error] ${error.message}`);
|
||||||
|
this.processes.delete(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pid: childProcess.pid || -1, success: true };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to spawn process:', error);
|
||||||
|
return { pid: -1, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data to a process's stdin
|
||||||
|
*/
|
||||||
|
write(sessionId: string, data: string): boolean {
|
||||||
|
const process = this.processes.get(sessionId);
|
||||||
|
if (!process) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.isTerminal && process.ptyProcess) {
|
||||||
|
process.ptyProcess.write(data);
|
||||||
|
return true;
|
||||||
|
} else if (process.childProcess?.stdin) {
|
||||||
|
process.childProcess.stdin.write(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write to process:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize terminal (for pty processes)
|
||||||
|
*/
|
||||||
|
resize(sessionId: string, cols: number, rows: number): boolean {
|
||||||
|
const process = this.processes.get(sessionId);
|
||||||
|
if (!process || !process.isTerminal || !process.ptyProcess) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.ptyProcess.resize(cols, rows);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resize terminal:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a specific process
|
||||||
|
*/
|
||||||
|
kill(sessionId: string): boolean {
|
||||||
|
const process = this.processes.get(sessionId);
|
||||||
|
if (!process) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.isTerminal && process.ptyProcess) {
|
||||||
|
process.ptyProcess.kill();
|
||||||
|
} else if (process.childProcess) {
|
||||||
|
process.childProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
this.processes.delete(sessionId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to kill process:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill all managed processes
|
||||||
|
*/
|
||||||
|
killAll(): void {
|
||||||
|
for (const [sessionId] of this.processes) {
|
||||||
|
this.kill(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a one-off command (for git operations, etc.)
|
||||||
|
* Uses execFile for security - no shell injection
|
||||||
|
*/
|
||||||
|
async execCommand(command: string, cwd: string): Promise<string> {
|
||||||
|
// Parse command into executable and args
|
||||||
|
const parts = command.split(' ');
|
||||||
|
const executable = parts[0];
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
const result = await execFileNoThrow(executable, args, cwd);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(result.stderr || `Command failed with exit code ${result.exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active processes
|
||||||
|
*/
|
||||||
|
getAll(): ManagedProcess[] {
|
||||||
|
return Array.from(this.processes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific process
|
||||||
|
*/
|
||||||
|
get(sessionId: string): ManagedProcess | undefined {
|
||||||
|
return this.processes.get(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/utils/execFile.ts
Normal file
41
src/main/utils/execFile.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface ExecResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely execute a command without shell injection vulnerabilities
|
||||||
|
* Uses execFile instead of exec to prevent shell interpretation
|
||||||
|
*/
|
||||||
|
export async function execFileNoThrow(
|
||||||
|
command: string,
|
||||||
|
args: string[] = [],
|
||||||
|
cwd?: string
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// execFile throws on non-zero exit codes
|
||||||
|
return {
|
||||||
|
stdout: error.stdout || '',
|
||||||
|
stderr: error.stderr || error.message || '',
|
||||||
|
exitCode: error.code || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/main/web-server.ts
Normal file
110
src/main/web-server.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
export class WebServer {
|
||||||
|
private server: FastifyInstance;
|
||||||
|
private port: number;
|
||||||
|
private isRunning: boolean = false;
|
||||||
|
|
||||||
|
constructor(port: number = 8000) {
|
||||||
|
this.port = port;
|
||||||
|
this.server = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: 'info',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupMiddleware();
|
||||||
|
this.setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupMiddleware() {
|
||||||
|
// Enable CORS for web access
|
||||||
|
await this.server.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable WebSocket support
|
||||||
|
await this.server.register(websocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRoutes() {
|
||||||
|
// Health check
|
||||||
|
this.server.get('/health', async () => {
|
||||||
|
return { status: 'ok', timestamp: Date.now() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket endpoint for real-time updates
|
||||||
|
this.server.get('/ws', { websocket: true }, (connection) => {
|
||||||
|
connection.socket.on('message', (message) => {
|
||||||
|
// Echo back for now - will implement proper session handling
|
||||||
|
connection.socket.send(JSON.stringify({
|
||||||
|
type: 'echo',
|
||||||
|
data: message.toString(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.socket.send(JSON.stringify({
|
||||||
|
type: 'connected',
|
||||||
|
message: 'Connected to Maestro WebSocket',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session list endpoint
|
||||||
|
this.server.get('/api/sessions', async () => {
|
||||||
|
return {
|
||||||
|
sessions: [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session detail endpoint
|
||||||
|
this.server.get('/api/sessions/:id', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return {
|
||||||
|
sessionId: id,
|
||||||
|
status: 'idle',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log('Web server already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.server.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
|
this.isRunning = true;
|
||||||
|
console.log(`Maestro web server running on http://localhost:${this.port}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start web server:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.server.close();
|
||||||
|
this.isRunning = false;
|
||||||
|
console.log('Web server stopped');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop web server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getUrl(): string {
|
||||||
|
return `http://localhost:${this.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer(): FastifyInstance {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
}
|
||||||
3199
src/renderer/App.tsx
Normal file
3199
src/renderer/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
301
src/renderer/components/FilePreview.tsx
Normal file
301
src/renderer/components/FilePreview.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { FileCode, X, Copy, FileText, Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: { name: string; content: string; path: string } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
theme: any;
|
||||||
|
markdownRawMode: boolean;
|
||||||
|
setMarkdownRawMode: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get language from filename extension
|
||||||
|
const getLanguageFromFilename = (filename: string): string => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const languageMap: Record<string, string> = {
|
||||||
|
'ts': 'typescript',
|
||||||
|
'tsx': 'tsx',
|
||||||
|
'js': 'javascript',
|
||||||
|
'jsx': 'jsx',
|
||||||
|
'json': 'json',
|
||||||
|
'md': 'markdown',
|
||||||
|
'py': 'python',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'go': 'go',
|
||||||
|
'rs': 'rust',
|
||||||
|
'java': 'java',
|
||||||
|
'c': 'c',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'cs': 'csharp',
|
||||||
|
'php': 'php',
|
||||||
|
'html': 'html',
|
||||||
|
'css': 'css',
|
||||||
|
'scss': 'scss',
|
||||||
|
'sql': 'sql',
|
||||||
|
'sh': 'bash',
|
||||||
|
'yaml': 'yaml',
|
||||||
|
'yml': 'yaml',
|
||||||
|
'toml': 'toml',
|
||||||
|
'xml': 'xml',
|
||||||
|
};
|
||||||
|
return languageMap[ext || ''] || 'text';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if file is an image
|
||||||
|
const isImageFile = (filename: string): boolean => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase();
|
||||||
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
|
||||||
|
return imageExtensions.includes(ext || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdownRawMode }: FilePreviewProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [showCopyNotification, setShowCopyNotification] = useState(false);
|
||||||
|
const [hoveredLink, setHoveredLink] = useState<{ url: string; x: number; y: number } | null>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const language = getLanguageFromFilename(file.name);
|
||||||
|
const isMarkdown = language === 'markdown';
|
||||||
|
const isImage = isImageFile(file.name);
|
||||||
|
|
||||||
|
// Extract directory path without filename
|
||||||
|
const directoryPath = file.path.substring(0, file.path.lastIndexOf('/'));
|
||||||
|
|
||||||
|
// Keep search input focused when search is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [searchOpen, searchQuery]);
|
||||||
|
|
||||||
|
const copyPathToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(file.path);
|
||||||
|
setShowCopyNotification(true);
|
||||||
|
setTimeout(() => setShowCopyNotification(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Highlight search matches in content
|
||||||
|
const highlightMatches = (content: string): string => {
|
||||||
|
if (!searchQuery.trim()) return content;
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||||
|
return content.replace(regex, '<mark style="background-color: #ffd700; color: #000;">$1</mark>');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === '/' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchOpen(true);
|
||||||
|
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (searchOpen) {
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex flex-col outline-none"
|
||||||
|
style={{ backgroundColor: theme.colors.bgMain, zIndex: 5 }}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={(el) => el?.focus()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-16 border-b flex items-center justify-between px-6 shrink-0" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>{file.name}</div>
|
||||||
|
<div className="text-xs opacity-50" style={{ color: theme.colors.textDim }}>{directoryPath}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMarkdown && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMarkdownRawMode(!markdownRawMode)}
|
||||||
|
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||||
|
style={{ color: markdownRawMode ? theme.colors.accent : theme.colors.textDim }}
|
||||||
|
title={markdownRawMode ? "Show rendered markdown" : "Show raw markdown"}
|
||||||
|
>
|
||||||
|
{markdownRawMode ? <Eye className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={copyPathToClipboard}
|
||||||
|
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||||
|
style={{ color: theme.colors.textDim }}
|
||||||
|
title="Copy full path to clipboard"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||||
|
style={{ color: theme.colors.textDim }}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* Floating Search */}
|
||||||
|
{searchOpen && (
|
||||||
|
<div className="sticky top-0 z-10 pb-4">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search in file... (Esc to close)"
|
||||||
|
className="w-full px-3 py-2 rounded border bg-transparent outline-none text-sm"
|
||||||
|
style={{ borderColor: theme.colors.accent, color: theme.colors.textMain, backgroundColor: theme.colors.bgSidebar }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isImage ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<img
|
||||||
|
src={`file://${file.path}`}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
style={{ imageRendering: 'crisp-edges' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : searchQuery.trim() || (isMarkdown && markdownRawMode) ? (
|
||||||
|
// When searching OR in raw markdown mode, show plain text with optional highlights
|
||||||
|
<div
|
||||||
|
className="font-mono text-sm whitespace-pre-wrap"
|
||||||
|
style={{ color: theme.colors.textMain }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: searchQuery.trim() ? highlightMatches(file.content) : file.content }}
|
||||||
|
/>
|
||||||
|
) : isMarkdown ? (
|
||||||
|
<div className="prose prose-sm max-w-none" style={{ color: theme.colors.textMain }}>
|
||||||
|
<style>{`
|
||||||
|
.prose h1 { color: ${theme.colors.textMain}; font-size: 2em; font-weight: bold; margin: 0.67em 0; }
|
||||||
|
.prose h2 { color: ${theme.colors.textMain}; font-size: 1.5em; font-weight: bold; margin: 0.75em 0; }
|
||||||
|
.prose h3 { color: ${theme.colors.textMain}; font-size: 1.17em; font-weight: bold; margin: 0.83em 0; }
|
||||||
|
.prose h4 { color: ${theme.colors.textMain}; font-size: 1em; font-weight: bold; margin: 1em 0; }
|
||||||
|
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.83em; font-weight: bold; margin: 1.17em 0; }
|
||||||
|
.prose h6 { color: ${theme.colors.textMain}; font-size: 0.67em; font-weight: bold; margin: 1.33em 0; }
|
||||||
|
.prose p { color: ${theme.colors.textMain}; margin: 0.5em 0; }
|
||||||
|
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.5em 0; padding-left: 1.5em; }
|
||||||
|
.prose li { margin: 0.25em 0; }
|
||||||
|
.prose code { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
||||||
|
.prose pre { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 1em; border-radius: 6px; overflow-x: auto; }
|
||||||
|
.prose pre code { background: none; padding: 0; }
|
||||||
|
.prose blockquote { border-left: 4px solid ${theme.colors.border}; padding-left: 1em; margin: 0.5em 0; color: ${theme.colors.textDim}; }
|
||||||
|
.prose a { color: ${theme.colors.accent}; text-decoration: underline; }
|
||||||
|
.prose hr { border: none; border-top: 2px solid ${theme.colors.border}; margin: 1em 0; }
|
||||||
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||||
|
.prose th, .prose td { border: 1px solid ${theme.colors.border}; padding: 0.5em; text-align: left; }
|
||||||
|
.prose th { background-color: ${theme.colors.bgActivity}; font-weight: bold; }
|
||||||
|
.prose strong { font-weight: bold; }
|
||||||
|
.prose em { font-style: italic; }
|
||||||
|
`}</style>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
a: ({ node, href, children, ...props }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (href) {
|
||||||
|
window.maestro.shell.openExternal(href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (href) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setHoveredLink({ url: href, x: rect.left, y: rect.bottom });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoveredLink(null)}
|
||||||
|
style={{ color: theme.colors.accent, textDecoration: 'underline', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language}
|
||||||
|
style={vscDarkPlus}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '24px',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
showLineNumbers
|
||||||
|
PreTag="div"
|
||||||
|
>
|
||||||
|
{file.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy Notification Toast */}
|
||||||
|
{showCopyNotification && (
|
||||||
|
<div
|
||||||
|
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-6 py-4 rounded-lg shadow-2xl text-base font-bold animate-in fade-in zoom-in-95 duration-200 z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
File Path Copied to Clipboard
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link Hover Tooltip */}
|
||||||
|
{hoveredLink && (
|
||||||
|
<div
|
||||||
|
className="fixed px-3 py-2 rounded shadow-lg text-xs font-mono max-w-md break-all z-50"
|
||||||
|
style={{
|
||||||
|
left: `${hoveredLink.x}px`,
|
||||||
|
top: `${hoveredLink.y + 5}px`,
|
||||||
|
backgroundColor: theme.colors.bgActivity,
|
||||||
|
color: theme.colors.textDim,
|
||||||
|
border: `1px solid ${theme.colors.border}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hoveredLink.url}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/renderer/components/NewInstanceModal.tsx
Normal file
222
src/renderer/components/NewInstanceModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Folder, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewInstanceModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (agentId: string, workingDir: string, name: string) => void;
|
||||||
|
theme: any;
|
||||||
|
defaultAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgent }: NewInstanceModalProps) {
|
||||||
|
const [agents, setAgents] = useState<AgentConfig[]>([]);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState(defaultAgent);
|
||||||
|
const [workingDir, setWorkingDir] = useState('~');
|
||||||
|
const [instanceName, setInstanceName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadAgents();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
const loadAgents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const detectedAgents = await window.maestro.agents.detect();
|
||||||
|
setAgents(detectedAgents);
|
||||||
|
|
||||||
|
// Set default or first available
|
||||||
|
const defaultAvailable = detectedAgents.find((a: AgentConfig) => a.id === defaultAgent && a.available);
|
||||||
|
const firstAvailable = detectedAgents.find((a: AgentConfig) => a.available);
|
||||||
|
|
||||||
|
if (defaultAvailable) {
|
||||||
|
setSelectedAgent(defaultAgent);
|
||||||
|
} else if (firstAvailable) {
|
||||||
|
setSelectedAgent(firstAvailable.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load agents:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
const folder = await window.maestro.dialog.selectFolder();
|
||||||
|
if (folder) {
|
||||||
|
setWorkingDir(folder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const name = instanceName || agents.find(a => a.id === selectedAgent)?.name || 'New Instance';
|
||||||
|
onCreate(selectedAgent, workingDir, name);
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
setInstanceName('');
|
||||||
|
setWorkingDir('~');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Stop propagation of all keyboard events to prevent background components from handling them
|
||||||
|
if (e.key !== 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[500px] rounded-xl border shadow-2xl overflow-hidden"
|
||||||
|
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b flex items-center justify-between" style={{ borderColor: theme.colors.border }}>
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: theme.colors.textMain }}>Create New Instance</h2>
|
||||||
|
<button onClick={onClose} style={{ color: theme.colors.textDim }}>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Instance Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2" style={{ color: theme.colors.textMain }}>
|
||||||
|
Instance Name (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instanceName}
|
||||||
|
onChange={(e) => setInstanceName(e.target.value)}
|
||||||
|
placeholder="My Project Session"
|
||||||
|
className="w-full p-2 rounded border bg-transparent outline-none"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2" style={{ color: theme.colors.textMain }}>
|
||||||
|
AI Agent / Tool
|
||||||
|
</label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm opacity-50">Loading agents...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
disabled={agent.id !== 'claude-code' || !agent.available}
|
||||||
|
onClick={() => setSelectedAgent(agent.id)}
|
||||||
|
className={`w-full text-left p-3 rounded border transition-all ${
|
||||||
|
selectedAgent === agent.id ? 'ring-2' : ''
|
||||||
|
} ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10'}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: selectedAgent === agent.id ? theme.colors.accentDim : 'transparent',
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{agent.name}</div>
|
||||||
|
{agent.path && (
|
||||||
|
<div className="text-xs opacity-50 font-mono mt-1">{agent.path}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.id === 'claude-code' ? (
|
||||||
|
agent.available ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}>
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.error + '20', color: theme.colors.error }}>
|
||||||
|
Not Found
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
||||||
|
Coming Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Working Directory */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2" style={{ color: theme.colors.textMain }}>
|
||||||
|
Working Directory
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workingDir}
|
||||||
|
onChange={(e) => setWorkingDir(e.target.value)}
|
||||||
|
className="flex-1 p-2 rounded border bg-transparent outline-none font-mono text-sm"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFolder}
|
||||||
|
className="p-2 rounded border hover:bg-opacity-10"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
title="Browse folders"
|
||||||
|
>
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t flex justify-end gap-2" style={{ borderColor: theme.colors.border }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded border"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!selectedAgent || !agents.find(a => a.id === selectedAgent)?.available}
|
||||||
|
className="px-4 py-2 rounded text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{ backgroundColor: theme.colors.accent }}
|
||||||
|
>
|
||||||
|
Create Instance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
src/renderer/components/Scratchpad.tsx
Normal file
269
src/renderer/components/Scratchpad.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Eye, Edit } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ScratchpadProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
theme: any;
|
||||||
|
initialMode?: 'edit' | 'preview';
|
||||||
|
initialCursorPosition?: number;
|
||||||
|
initialEditScrollPos?: number;
|
||||||
|
initialPreviewScrollPos?: number;
|
||||||
|
onStateChange?: (state: {
|
||||||
|
mode: 'edit' | 'preview';
|
||||||
|
cursorPosition: number;
|
||||||
|
editScrollPos: number;
|
||||||
|
previewScrollPos: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Scratchpad({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
theme,
|
||||||
|
initialMode = 'edit',
|
||||||
|
initialCursorPosition = 0,
|
||||||
|
initialEditScrollPos = 0,
|
||||||
|
initialPreviewScrollPos = 0,
|
||||||
|
onStateChange
|
||||||
|
}: ScratchpadProps) {
|
||||||
|
const [mode, setMode] = useState<'edit' | 'preview'>(initialMode);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Restore cursor and scroll positions when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (textareaRef.current && initialCursorPosition > 0) {
|
||||||
|
textareaRef.current.setSelectionRange(initialCursorPosition, initialCursorPosition);
|
||||||
|
textareaRef.current.scrollTop = initialEditScrollPos;
|
||||||
|
}
|
||||||
|
if (previewRef.current && initialPreviewScrollPos > 0) {
|
||||||
|
previewRef.current.scrollTop = initialPreviewScrollPos;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Notify parent when mode changes
|
||||||
|
const toggleMode = () => {
|
||||||
|
const newMode = mode === 'edit' ? 'preview' : 'edit';
|
||||||
|
setMode(newMode);
|
||||||
|
|
||||||
|
if (onStateChange) {
|
||||||
|
onStateChange({
|
||||||
|
mode: newMode,
|
||||||
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
||||||
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
||||||
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-focus the active element after mode change
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'edit' && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
} else if (mode === 'preview' && previewRef.current) {
|
||||||
|
previewRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
// Save cursor position and scroll position when they change
|
||||||
|
const handleCursorOrScrollChange = () => {
|
||||||
|
if (onStateChange && textareaRef.current) {
|
||||||
|
onStateChange({
|
||||||
|
mode,
|
||||||
|
cursorPosition: textareaRef.current.selectionStart,
|
||||||
|
editScrollPos: textareaRef.current.scrollTop,
|
||||||
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewScroll = () => {
|
||||||
|
if (onStateChange && previewRef.current) {
|
||||||
|
onStateChange({
|
||||||
|
mode,
|
||||||
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
||||||
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
||||||
|
previewScrollPos: previewRef.current.scrollTop
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Command-E to toggle between edit and preview
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
const textarea = e.currentTarget;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const textBeforeCursor = content.substring(0, cursorPos);
|
||||||
|
const textAfterCursor = content.substring(cursorPos);
|
||||||
|
const currentLineStart = textBeforeCursor.lastIndexOf('\n') + 1;
|
||||||
|
const currentLine = textBeforeCursor.substring(currentLineStart);
|
||||||
|
|
||||||
|
// Check for list patterns
|
||||||
|
const unorderedListMatch = currentLine.match(/^(\s*)([-*])\s+/);
|
||||||
|
const orderedListMatch = currentLine.match(/^(\s*)(\d+)\.\s+/);
|
||||||
|
const taskListMatch = currentLine.match(/^(\s*)- \[([ x])\]\s+/);
|
||||||
|
|
||||||
|
if (taskListMatch) {
|
||||||
|
// Task list: continue with unchecked checkbox
|
||||||
|
const indent = taskListMatch[1];
|
||||||
|
e.preventDefault();
|
||||||
|
const newContent = textBeforeCursor + '\n' + indent + '- [ ] ' + textAfterCursor;
|
||||||
|
onChange(newContent);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newPos = cursorPos + indent.length + 7; // "\n" + indent + "- [ ] "
|
||||||
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else if (unorderedListMatch) {
|
||||||
|
// Unordered list: continue with same marker
|
||||||
|
const indent = unorderedListMatch[1];
|
||||||
|
const marker = unorderedListMatch[2];
|
||||||
|
e.preventDefault();
|
||||||
|
const newContent = textBeforeCursor + '\n' + indent + marker + ' ' + textAfterCursor;
|
||||||
|
onChange(newContent);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newPos = cursorPos + indent.length + 3; // "\n" + indent + marker + " "
|
||||||
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else if (orderedListMatch) {
|
||||||
|
// Ordered list: increment number
|
||||||
|
const indent = orderedListMatch[1];
|
||||||
|
const num = parseInt(orderedListMatch[2]);
|
||||||
|
e.preventDefault();
|
||||||
|
const newContent = textBeforeCursor + '\n' + indent + (num + 1) + '. ' + textAfterCursor;
|
||||||
|
onChange(newContent);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
const newPos = cursorPos + indent.length + (num + 1).toString().length + 3; // "\n" + indent + num + ". "
|
||||||
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full flex flex-col outline-none"
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleMode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex gap-2 mb-3 justify-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('edit')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
mode === 'edit' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: mode === 'edit' ? theme.colors.bgActivity : 'transparent',
|
||||||
|
color: mode === 'edit' ? theme.colors.textMain : theme.colors.textDim,
|
||||||
|
border: `1px solid ${mode === 'edit' ? theme.colors.accent : theme.colors.border}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('preview')}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
mode === 'preview' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: mode === 'preview' ? theme.colors.bgActivity : 'transparent',
|
||||||
|
color: mode === 'preview' ? theme.colors.textMain : theme.colors.textDim,
|
||||||
|
border: `1px solid ${mode === 'preview' ? theme.colors.accent : theme.colors.border}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{mode === 'edit' ? (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleCursorOrScrollChange}
|
||||||
|
onClick={handleCursorOrScrollChange}
|
||||||
|
onScroll={handleCursorOrScrollChange}
|
||||||
|
placeholder="Write your notes in markdown..."
|
||||||
|
className="w-full h-full border rounded p-4 bg-transparent outline-none resize-none font-mono text-sm"
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={previewRef}
|
||||||
|
className="h-full border rounded p-4 overflow-y-auto prose prose-sm max-w-none outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMode();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onScroll={handlePreviewScroll}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.prose h1 { color: ${theme.colors.textMain}; font-size: 2em; font-weight: bold; margin: 0.67em 0; }
|
||||||
|
.prose h2 { color: ${theme.colors.textMain}; font-size: 1.5em; font-weight: bold; margin: 0.75em 0; }
|
||||||
|
.prose h3 { color: ${theme.colors.textMain}; font-size: 1.17em; font-weight: bold; margin: 0.83em 0; }
|
||||||
|
.prose h4 { color: ${theme.colors.textMain}; font-size: 1em; font-weight: bold; margin: 1em 0; }
|
||||||
|
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.83em; font-weight: bold; margin: 1.17em 0; }
|
||||||
|
.prose h6 { color: ${theme.colors.textMain}; font-size: 0.67em; font-weight: bold; margin: 1.33em 0; }
|
||||||
|
.prose p { color: ${theme.colors.textMain}; margin: 0.5em 0; }
|
||||||
|
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.5em 0; padding-left: 1.5em; }
|
||||||
|
.prose li { margin: 0.25em 0; }
|
||||||
|
.prose code { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
||||||
|
.prose pre { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 1em; border-radius: 6px; overflow-x: auto; }
|
||||||
|
.prose pre code { background: none; padding: 0; }
|
||||||
|
.prose blockquote { border-left: 4px solid ${theme.colors.border}; padding-left: 1em; margin: 0.5em 0; color: ${theme.colors.textDim}; }
|
||||||
|
.prose a { color: ${theme.colors.accent}; text-decoration: underline; }
|
||||||
|
.prose hr { border: none; border-top: 2px solid ${theme.colors.border}; margin: 1em 0; }
|
||||||
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||||
|
.prose th, .prose td { border: 1px solid ${theme.colors.border}; padding: 0.5em; text-align: left; }
|
||||||
|
.prose th { background-color: ${theme.colors.bgActivity}; font-weight: bold; }
|
||||||
|
.prose strong { font-weight: bold; }
|
||||||
|
.prose em { font-style: italic; }
|
||||||
|
`}</style>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content || '*No content yet. Switch to Edit mode to start writing.*'}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
766
src/renderer/components/SettingsModal.tsx
Normal file
766
src/renderer/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { X, Key, Moon, Sun, Keyboard, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgentConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
theme: any;
|
||||||
|
themes: Record<string, any>;
|
||||||
|
activeThemeId: string;
|
||||||
|
setActiveThemeId: (id: string) => void;
|
||||||
|
llmProvider: string;
|
||||||
|
setLlmProvider: (provider: string) => void;
|
||||||
|
modelSlug: string;
|
||||||
|
setModelSlug: (slug: string) => void;
|
||||||
|
apiKey: string;
|
||||||
|
setApiKey: (key: string) => void;
|
||||||
|
tunnelProvider: string;
|
||||||
|
setTunnelProvider: (provider: string) => void;
|
||||||
|
tunnelApiKey: string;
|
||||||
|
setTunnelApiKey: (key: string) => void;
|
||||||
|
shortcuts: Record<string, any>;
|
||||||
|
setShortcuts: (shortcuts: Record<string, any>) => void;
|
||||||
|
defaultAgent: string;
|
||||||
|
setDefaultAgent: (agentId: string) => void;
|
||||||
|
fontFamily: string;
|
||||||
|
setFontFamily: (font: string) => void;
|
||||||
|
fontSize: number;
|
||||||
|
setFontSize: (size: number) => void;
|
||||||
|
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'network';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal(props: SettingsModalProps) {
|
||||||
|
const { isOpen, onClose, theme, themes, initialTab } = props;
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'llm' | 'shortcuts' | 'theme' | 'network'>('general');
|
||||||
|
const [systemFonts, setSystemFonts] = useState<string[]>([]);
|
||||||
|
const [customFontInput, setCustomFontInput] = useState('');
|
||||||
|
const [customFonts, setCustomFonts] = useState<string[]>([]);
|
||||||
|
const [fontLoading, setFontLoading] = useState(false);
|
||||||
|
const [fontsLoaded, setFontsLoaded] = useState(false);
|
||||||
|
const [agents, setAgents] = useState<AgentConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [recordingId, setRecordingId] = useState<string | null>(null);
|
||||||
|
const [shortcutsFilter, setShortcutsFilter] = useState('');
|
||||||
|
const [testingLLM, setTestingLLM] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ status: 'success' | 'error' | null; message: string }>({ status: null, message: '' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadAgents();
|
||||||
|
// Don't load fonts immediately - only when user interacts with font selector
|
||||||
|
// Set initial tab if provided, otherwise default to 'general'
|
||||||
|
setActiveTab(initialTab || 'general');
|
||||||
|
}
|
||||||
|
}, [isOpen, initialTab]);
|
||||||
|
|
||||||
|
// Tab navigation with Cmd+Shift+[ and ]
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleTabNavigation = (e: KeyboardEvent) => {
|
||||||
|
const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'network'> = ['general', 'llm', 'shortcuts', 'theme', 'network'];
|
||||||
|
const currentIndex = tabs.indexOf(activeTab);
|
||||||
|
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
|
||||||
|
setActiveTab(tabs[prevIndex]);
|
||||||
|
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === ']') {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||||
|
setActiveTab(tabs[nextIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleTabNavigation);
|
||||||
|
return () => window.removeEventListener('keydown', handleTabNavigation);
|
||||||
|
}, [isOpen, activeTab]);
|
||||||
|
|
||||||
|
const loadAgents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const detectedAgents = await window.maestro.agents.detect();
|
||||||
|
setAgents(detectedAgents);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load agents:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFonts = async () => {
|
||||||
|
if (fontsLoaded) return; // Don't reload if already loaded
|
||||||
|
|
||||||
|
setFontLoading(true);
|
||||||
|
try {
|
||||||
|
const detected = await window.maestro.fonts.detect();
|
||||||
|
setSystemFonts(detected);
|
||||||
|
|
||||||
|
const savedCustomFonts = await window.maestro.settings.get('customFonts');
|
||||||
|
if (savedCustomFonts) {
|
||||||
|
setCustomFonts(savedCustomFonts);
|
||||||
|
}
|
||||||
|
setFontsLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load fonts:', error);
|
||||||
|
} finally {
|
||||||
|
setFontLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFontInteraction = () => {
|
||||||
|
if (!fontsLoaded && !fontLoading) {
|
||||||
|
loadFonts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomFont = () => {
|
||||||
|
if (customFontInput.trim() && !customFonts.includes(customFontInput.trim())) {
|
||||||
|
const newCustomFonts = [...customFonts, customFontInput.trim()];
|
||||||
|
setCustomFonts(newCustomFonts);
|
||||||
|
window.maestro.settings.set('customFonts', newCustomFonts);
|
||||||
|
setCustomFontInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomFont = (font: string) => {
|
||||||
|
const newCustomFonts = customFonts.filter(f => f !== font);
|
||||||
|
setCustomFonts(newCustomFonts);
|
||||||
|
window.maestro.settings.set('customFonts', newCustomFonts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testLLMConnection = async () => {
|
||||||
|
setTestingLLM(true);
|
||||||
|
setTestResult({ status: null, message: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
const testPrompt = 'Respond with exactly: "Connection successful"';
|
||||||
|
|
||||||
|
if (props.llmProvider === 'openrouter') {
|
||||||
|
if (!props.apiKey) {
|
||||||
|
throw new Error('API key is required for OpenRouter');
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${props.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://maestro.local',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: props.modelSlug || 'anthropic/claude-3.5-sonnet',
|
||||||
|
messages: [{ role: 'user', content: testPrompt }],
|
||||||
|
max_tokens: 50,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.choices?.[0]?.message?.content) {
|
||||||
|
throw new Error('Invalid response from OpenRouter');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestResult({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Successfully connected to OpenRouter!',
|
||||||
|
});
|
||||||
|
} else if (props.llmProvider === 'anthropic') {
|
||||||
|
if (!props.apiKey) {
|
||||||
|
throw new Error('API key is required for Anthropic');
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-api-key': props.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: props.modelSlug || 'claude-3-5-sonnet-20241022',
|
||||||
|
max_tokens: 50,
|
||||||
|
messages: [{ role: 'user', content: testPrompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error?.message || `Anthropic API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.content?.[0]?.text) {
|
||||||
|
throw new Error('Invalid response from Anthropic');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestResult({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Successfully connected to Anthropic!',
|
||||||
|
});
|
||||||
|
} else if (props.llmProvider === 'ollama') {
|
||||||
|
response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: props.modelSlug || 'llama3:latest',
|
||||||
|
prompt: testPrompt,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ollama API error: ${response.status}. Make sure Ollama is running locally.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.response) {
|
||||||
|
throw new Error('Invalid response from Ollama');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestResult({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Successfully connected to Ollama!',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setTestResult({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message || 'Connection failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingLLM(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a font is available on the system
|
||||||
|
// Memoize normalized font set for O(1) lookup instead of O(n) array search
|
||||||
|
const normalizedFontsSet = useMemo(() => {
|
||||||
|
const normalize = (str: string) => str.toLowerCase().replace(/[\s-]/g, '');
|
||||||
|
const fontSet = new Set<string>();
|
||||||
|
systemFonts.forEach(font => {
|
||||||
|
fontSet.add(normalize(font));
|
||||||
|
// Also add the original name for exact matches
|
||||||
|
fontSet.add(font.toLowerCase());
|
||||||
|
});
|
||||||
|
return fontSet;
|
||||||
|
}, [systemFonts]);
|
||||||
|
|
||||||
|
const isFontAvailable = (fontName: string) => {
|
||||||
|
const normalize = (str: string) => str.toLowerCase().replace(/[\s-]/g, '');
|
||||||
|
const normalizedSearch = normalize(fontName);
|
||||||
|
|
||||||
|
// Fast O(1) lookup
|
||||||
|
if (normalizedFontsSet.has(normalizedSearch)) return true;
|
||||||
|
if (normalizedFontsSet.has(fontName.toLowerCase())) return true;
|
||||||
|
|
||||||
|
// Fallback to substring search (slower but comprehensive)
|
||||||
|
for (const font of normalizedFontsSet) {
|
||||||
|
if (font.includes(normalizedSearch) || normalizedSearch.includes(font)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecord = (e: React.KeyboardEvent, actionId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Escape cancels recording without saving
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setRecordingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
if (e.metaKey) keys.push('Meta');
|
||||||
|
if (e.ctrlKey) keys.push('Ctrl');
|
||||||
|
if (e.altKey) keys.push('Alt');
|
||||||
|
if (e.shiftKey) keys.push('Shift');
|
||||||
|
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||||
|
keys.push(e.key);
|
||||||
|
props.setShortcuts({
|
||||||
|
...props.shortcuts,
|
||||||
|
[actionId]: { ...props.shortcuts[actionId], keys }
|
||||||
|
});
|
||||||
|
setRecordingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const ThemePicker = () => {
|
||||||
|
const themePickerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const grouped = Object.values(themes).reduce((acc: any, t: any) => {
|
||||||
|
if (!acc[t.mode]) acc[t.mode] = [];
|
||||||
|
acc[t.mode].push(t);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Ensure focus when component mounts
|
||||||
|
React.useEffect(() => {
|
||||||
|
themePickerRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleThemePickerKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Create ordered array: dark themes first (left-to-right, top-to-bottom), then light themes
|
||||||
|
const allThemes = [...(grouped['dark'] || []), ...(grouped['light'] || [])];
|
||||||
|
const currentIndex = allThemes.findIndex((t: any) => t.id === props.activeThemeId);
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab: go backwards
|
||||||
|
const prevIndex = currentIndex === 0 ? allThemes.length - 1 : currentIndex - 1;
|
||||||
|
props.setActiveThemeId(allThemes[prevIndex].id);
|
||||||
|
} else {
|
||||||
|
// Tab: go forward
|
||||||
|
const nextIndex = (currentIndex + 1) % allThemes.length;
|
||||||
|
props.setActiveThemeId(allThemes[nextIndex].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={themePickerRef}
|
||||||
|
className="space-y-6 outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleThemePickerKeyDown}
|
||||||
|
>
|
||||||
|
{['dark', 'light'].map(mode => (
|
||||||
|
<div key={mode}>
|
||||||
|
<div className="text-xs font-bold uppercase mb-3 flex items-center gap-2" style={{ color: theme.colors.textDim }}>
|
||||||
|
{mode === 'dark' ? <Moon className="w-3 h-3" /> : <Sun className="w-3 h-3" />}
|
||||||
|
{mode} Mode
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{grouped[mode]?.map((t: any) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => props.setActiveThemeId(t.id)}
|
||||||
|
className={`p-3 rounded-lg border text-left transition-all ${props.activeThemeId === t.id ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: t.colors.bgSidebar,
|
||||||
|
ringColor: theme.colors.accent
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-bold" style={{ color: t.colors.textMain }}>{t.name}</span>
|
||||||
|
{props.activeThemeId === t.id && <Check className="w-4 h-4" style={{ color: theme.colors.accent }} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-3 rounded overflow-hidden">
|
||||||
|
<div className="flex-1" style={{ backgroundColor: t.colors.bgMain }} />
|
||||||
|
<div className="flex-1" style={{ backgroundColor: t.colors.bgActivity }} />
|
||||||
|
<div className="flex-1" style={{ backgroundColor: t.colors.accent }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
ref={(el) => el?.focus()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
// Allow all other keyboard events to propagate to child elements
|
||||||
|
// This enables shortcut recording and tab navigation
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-[600px] h-[500px] rounded-xl border shadow-2xl overflow-hidden flex flex-col"
|
||||||
|
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}>
|
||||||
|
|
||||||
|
<div className="flex border-b" style={{ borderColor: theme.colors.border }}>
|
||||||
|
<button onClick={() => setActiveTab('general')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'general' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>General</button>
|
||||||
|
<button onClick={() => setActiveTab('llm')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'llm' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>LLM</button>
|
||||||
|
<button onClick={() => setActiveTab('shortcuts')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'shortcuts' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>Shortcuts</button>
|
||||||
|
<button onClick={() => setActiveTab('theme')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'theme' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>Themes</button>
|
||||||
|
<button onClick={() => setActiveTab('network')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'network' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>Network</button>
|
||||||
|
<div className="flex-1 flex justify-end items-center pr-4">
|
||||||
|
<button onClick={onClose} tabIndex={-1}><X className="w-5 h-5 opacity-50 hover:opacity-100" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto">
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Default AI Agent</label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm opacity-50">Loading agents...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
disabled={agent.id !== 'claude-code' || !agent.available}
|
||||||
|
onClick={() => props.setDefaultAgent(agent.id)}
|
||||||
|
className={`w-full text-left p-3 rounded border transition-all ${
|
||||||
|
props.defaultAgent === agent.id ? 'ring-2' : ''
|
||||||
|
} ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10'}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: props.defaultAgent === agent.id ? theme.colors.accentDim : theme.colors.bgMain,
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{agent.name}</div>
|
||||||
|
{agent.path && (
|
||||||
|
<div className="text-xs opacity-50 font-mono mt-1">{agent.path}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.id === 'claude-code' ? (
|
||||||
|
agent.available ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}>
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.error + '20', color: theme.colors.error }}>
|
||||||
|
Not Found
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
||||||
|
Coming Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font Family */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Interface Font</label>
|
||||||
|
{fontLoading ? (
|
||||||
|
<div className="text-sm opacity-50 p-2">Loading fonts...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={props.fontFamily}
|
||||||
|
onChange={(e) => props.setFontFamily(e.target.value)}
|
||||||
|
onFocus={handleFontInteraction}
|
||||||
|
onClick={handleFontInteraction}
|
||||||
|
className="w-full p-2 rounded border bg-transparent outline-none mb-3"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
>
|
||||||
|
<optgroup label="Common Monospace Fonts">
|
||||||
|
{['Roboto Mono', 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Consolas', 'Courier New', 'SF Mono', 'Cascadia Code', 'Source Code Pro'].map(font => {
|
||||||
|
const available = fontsLoaded ? isFontAvailable(font) : true;
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
key={font}
|
||||||
|
value={font}
|
||||||
|
style={{ opacity: available ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{font} {fontsLoaded && !available && '(Not Found)'}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</optgroup>
|
||||||
|
{customFonts.length > 0 && (
|
||||||
|
<optgroup label="Custom Fonts">
|
||||||
|
{customFonts.map(font => (
|
||||||
|
<option key={font} value={font}>
|
||||||
|
{font}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customFontInput}
|
||||||
|
onChange={(e) => setCustomFontInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addCustomFont()}
|
||||||
|
placeholder="Add custom font name..."
|
||||||
|
className="flex-1 p-2 rounded border bg-transparent outline-none text-sm"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addCustomFont}
|
||||||
|
className="px-3 py-2 rounded text-xs font-bold"
|
||||||
|
style={{ backgroundColor: theme.colors.accent, color: 'white' }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customFonts.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{customFonts.map(font => (
|
||||||
|
<div
|
||||||
|
key={font}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 rounded text-xs"
|
||||||
|
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }}
|
||||||
|
>
|
||||||
|
<span style={{ color: theme.colors.textMain }}>{font}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeCustomFont(font)}
|
||||||
|
className="hover:opacity-70"
|
||||||
|
style={{ color: theme.colors.error }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font Size */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Font Size</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => props.setFontSize(12)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded border transition-all ${props.fontSize === 12 ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: props.fontSize === 12 ? theme.colors.accentDim : 'transparent',
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Small
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.setFontSize(14)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded border transition-all ${props.fontSize === 14 ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: props.fontSize === 14 ? theme.colors.accentDim : 'transparent',
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Medium
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.setFontSize(16)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded border transition-all ${props.fontSize === 16 ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: props.fontSize === 16 ? theme.colors.accentDim : 'transparent',
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Large
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.setFontSize(18)}
|
||||||
|
className={`flex-1 py-2 px-3 rounded border transition-all ${props.fontSize === 18 ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: props.fontSize === 18 ? theme.colors.accentDim : 'transparent',
|
||||||
|
ringColor: theme.colors.accent,
|
||||||
|
color: theme.colors.textMain
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
X-Large
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'llm' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">LLM Provider</label>
|
||||||
|
<select
|
||||||
|
value={props.llmProvider}
|
||||||
|
onChange={(e) => props.setLlmProvider(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border bg-transparent outline-none"
|
||||||
|
style={{ borderColor: theme.colors.border }}
|
||||||
|
>
|
||||||
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
<option value="ollama">Ollama (Local)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Model Slug</label>
|
||||||
|
<input
|
||||||
|
value={props.modelSlug}
|
||||||
|
onChange={(e) => props.setModelSlug(e.target.value)}
|
||||||
|
className="w-full p-2 rounded border bg-transparent outline-none"
|
||||||
|
style={{ borderColor: theme.colors.border }}
|
||||||
|
placeholder={props.llmProvider === 'ollama' ? 'llama3:latest' : 'anthropic/claude-3.5-sonnet'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.llmProvider !== 'ollama' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">API Key</label>
|
||||||
|
<div className="flex items-center border rounded px-3 py-2" style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}>
|
||||||
|
<Key className="w-4 h-4 mr-2 opacity-50" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={props.apiKey}
|
||||||
|
onChange={(e) => props.setApiKey(e.target.value)}
|
||||||
|
className="bg-transparent flex-1 text-sm outline-none"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] mt-2 opacity-50">Keys are stored locally in ~/.maestro/settings.json</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div className="pt-4 border-t" style={{ borderColor: theme.colors.border }}>
|
||||||
|
<button
|
||||||
|
onClick={testLLMConnection}
|
||||||
|
disabled={testingLLM || (props.llmProvider !== 'ollama' && !props.apiKey)}
|
||||||
|
className="w-full py-3 rounded-lg font-bold text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testingLLM ? 'Testing Connection...' : 'Test Connection'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{testResult.status && (
|
||||||
|
<div
|
||||||
|
className="mt-3 p-3 rounded-lg text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: testResult.status === 'success' ? theme.colors.success + '20' : theme.colors.error + '20',
|
||||||
|
color: testResult.status === 'success' ? theme.colors.success : theme.colors.error,
|
||||||
|
border: `1px solid ${testResult.status === 'success' ? theme.colors.success : theme.colors.error}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] mt-3 opacity-50 text-center">
|
||||||
|
Test sends a simple prompt to verify connectivity and configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'network' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Tunnel Provider</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-3 border rounded-lg flex items-center justify-center gap-2 ${props.tunnelProvider === 'ngrok' ? 'ring-2 ring-indigo-500 border-indigo-500' : 'opacity-50'}`}
|
||||||
|
style={{ borderColor: theme.colors.border }}
|
||||||
|
onClick={() => props.setTunnelProvider('ngrok')}
|
||||||
|
>
|
||||||
|
<div className="font-bold">ngrok</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-3 border rounded-lg flex items-center justify-center gap-2 ${props.tunnelProvider === 'cloudflare' ? 'ring-2 ring-indigo-500 border-indigo-500' : 'opacity-50'}`}
|
||||||
|
style={{ borderColor: theme.colors.border }}
|
||||||
|
onClick={() => props.setTunnelProvider('cloudflare')}
|
||||||
|
>
|
||||||
|
<div className="font-bold">Cloudflare</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold opacity-70 uppercase mb-2">Auth Token / API Key</label>
|
||||||
|
<div className="flex items-center border rounded px-3 py-2" style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}>
|
||||||
|
<Key className="w-4 h-4 mr-2 opacity-50" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={props.tunnelApiKey}
|
||||||
|
onChange={(e) => props.setTunnelApiKey(e.target.value)}
|
||||||
|
className="bg-transparent flex-1 text-sm outline-none"
|
||||||
|
placeholder={`Enter ${props.tunnelProvider} auth token...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] mt-2 opacity-50">Tokens are stored securely in your OS keychain. Tunnels will not start without a valid token.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'shortcuts' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shortcutsFilter}
|
||||||
|
onChange={(e) => setShortcutsFilter(e.target.value)}
|
||||||
|
placeholder="Filter shortcuts..."
|
||||||
|
className="w-full px-3 py-2 rounded border bg-transparent outline-none text-sm"
|
||||||
|
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 max-h-[350px] overflow-y-auto pr-2">
|
||||||
|
{Object.values(props.shortcuts)
|
||||||
|
.filter((sc: any) => sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()))
|
||||||
|
.map((sc: any) => (
|
||||||
|
<div key={sc.id} className="flex items-center justify-between p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
||||||
|
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>{sc.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setRecordingId(sc.id)}
|
||||||
|
onKeyDown={(e) => recordingId === sc.id && handleRecord(e, sc.id)}
|
||||||
|
className={`px-3 py-1.5 rounded border text-xs font-mono min-w-[80px] text-center transition-colors ${recordingId === sc.id ? 'ring-2' : ''}`}
|
||||||
|
style={{
|
||||||
|
borderColor: recordingId === sc.id ? theme.colors.accent : theme.colors.border,
|
||||||
|
backgroundColor: recordingId === sc.id ? theme.colors.accentDim : theme.colors.bgActivity,
|
||||||
|
color: recordingId === sc.id ? theme.colors.accent : theme.colors.textDim,
|
||||||
|
ringColor: theme.colors.accent
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recordingId === sc.id ? 'Press keys...' : sc.keys.join(' + ')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'theme' && <ThemePicker />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/renderer/constants/emojis.ts
Normal file
63
src/renderer/constants/emojis.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Curated list of emojis for group customization with names
|
||||||
|
export const GROUP_EMOJIS: Array<{ emoji: string; name: string }> = [
|
||||||
|
// Files & Documents
|
||||||
|
{ emoji: '📂', name: 'folder' }, { emoji: '📁', name: 'open folder' }, { emoji: '📋', name: 'clipboard' }, { emoji: '📌', name: 'pushpin' },
|
||||||
|
{ emoji: '📍', name: 'pin' }, { emoji: '📎', name: 'paperclip' }, { emoji: '🔖', name: 'bookmark' }, { emoji: '🏷️', name: 'label' },
|
||||||
|
{ emoji: '📦', name: 'package' }, { emoji: '📪', name: 'mailbox' }, { emoji: '📬', name: 'mail' }, { emoji: '📭', name: 'empty mailbox' },
|
||||||
|
// Activities
|
||||||
|
{ emoji: '🎯', name: 'target' }, { emoji: '🎨', name: 'art' }, { emoji: '🎭', name: 'theater' }, { emoji: '🎪', name: 'circus' },
|
||||||
|
{ emoji: '🎬', name: 'movie' }, { emoji: '🎮', name: 'game' }, { emoji: '🎲', name: 'dice' }, { emoji: '🎰', name: 'slot machine' },
|
||||||
|
{ emoji: '🎱', name: 'pool' }, { emoji: '🎳', name: 'bowling' }, { emoji: '🎸', name: 'guitar' }, { emoji: '🎹', name: 'piano' },
|
||||||
|
// Tools
|
||||||
|
{ emoji: '⚡', name: 'lightning' }, { emoji: '⚙️', name: 'gear' }, { emoji: '⚖️', name: 'balance' }, { emoji: '⚔️', name: 'swords' },
|
||||||
|
{ emoji: '🔧', name: 'wrench' }, { emoji: '🔨', name: 'hammer' }, { emoji: '🔩', name: 'nut and bolt' }, { emoji: '⛏️', name: 'pick' },
|
||||||
|
{ emoji: '🛠️', name: 'tools' }, { emoji: '🔬', name: 'microscope' }, { emoji: '🔭', name: 'telescope' }, { emoji: '🧪', name: 'test tube' },
|
||||||
|
// Tech
|
||||||
|
{ emoji: '💻', name: 'laptop' }, { emoji: '⌨️', name: 'keyboard' }, { emoji: '🖥️', name: 'desktop' }, { emoji: '🖨️', name: 'printer' },
|
||||||
|
{ emoji: '🖱️', name: 'mouse' }, { emoji: '💾', name: 'floppy disk' }, { emoji: '💿', name: 'cd' }, { emoji: '📱', name: 'phone' },
|
||||||
|
{ emoji: '☎️', name: 'telephone' }, { emoji: '📞', name: 'receiver' }, { emoji: '📟', name: 'pager' }, { emoji: '📠', name: 'fax' },
|
||||||
|
// World
|
||||||
|
{ emoji: '🌍', name: 'earth africa' }, { emoji: '🌎', name: 'earth americas' }, { emoji: '🌏', name: 'earth asia' }, { emoji: '🌐', name: 'globe' },
|
||||||
|
{ emoji: '🗺️', name: 'map' }, { emoji: '🧭', name: 'compass' }, { emoji: '⛰️', name: 'mountain' }, { emoji: '🏔️', name: 'snow mountain' },
|
||||||
|
{ emoji: '🗻', name: 'mount fuji' }, { emoji: '🏕️', name: 'camping' }, { emoji: '🏖️', name: 'beach' }, { emoji: '🏝️', name: 'island' },
|
||||||
|
// Buildings
|
||||||
|
{ emoji: '🏢', name: 'office' }, { emoji: '🏦', name: 'bank' }, { emoji: '🏪', name: 'store' }, { emoji: '🏬', name: 'department store' },
|
||||||
|
{ emoji: '🏭', name: 'factory' }, { emoji: '🏗️', name: 'construction' }, { emoji: '🏛️', name: 'classical building' }, { emoji: '⛪', name: 'church' },
|
||||||
|
{ emoji: '🕌', name: 'mosque' }, { emoji: '🛕', name: 'temple' }, { emoji: '🏠', name: 'house' }, { emoji: '🏡', name: 'home' },
|
||||||
|
// Transport
|
||||||
|
{ emoji: '🚀', name: 'rocket' }, { emoji: '🛸', name: 'ufo' }, { emoji: '🚁', name: 'helicopter' }, { emoji: '🛩️', name: 'small plane' },
|
||||||
|
{ emoji: '✈️', name: 'airplane' }, { emoji: '🚂', name: 'train' }, { emoji: '🚃', name: 'railway car' }, { emoji: '🚄', name: 'bullet train' },
|
||||||
|
{ emoji: '🚅', name: 'speed train' }, { emoji: '🚆', name: 'train' }, { emoji: '🚗', name: 'car' }, { emoji: '🚕', name: 'taxi' },
|
||||||
|
// Money & Objects
|
||||||
|
{ emoji: '💡', name: 'bulb' }, { emoji: '🔦', name: 'flashlight' }, { emoji: '🕯️', name: 'candle' }, { emoji: '💰', name: 'money bag' },
|
||||||
|
{ emoji: '💵', name: 'dollar' }, { emoji: '💴', name: 'yen' }, { emoji: '💶', name: 'euro' }, { emoji: '💷', name: 'pound' },
|
||||||
|
{ emoji: '💸', name: 'money flying' }, { emoji: '💳', name: 'credit card' }, { emoji: '💎', name: 'gem' }, { emoji: '⚗️', name: 'alembic' },
|
||||||
|
// Security
|
||||||
|
{ emoji: '🔐', name: 'locked key' }, { emoji: '🔒', name: 'locked' }, { emoji: '🔓', name: 'unlocked' }, { emoji: '🔑', name: 'key' },
|
||||||
|
{ emoji: '🗝️', name: 'old key' }, { emoji: '🛡️', name: 'shield' }, { emoji: '🔱', name: 'trident' }, { emoji: '⚜️', name: 'fleur de lis' },
|
||||||
|
{ emoji: '🔰', name: 'beginner' }, { emoji: '⚠️', name: 'warning' }, { emoji: '🚫', name: 'prohibited' }, { emoji: '🔞', name: 'no under 18' },
|
||||||
|
// Symbols
|
||||||
|
{ emoji: '✅', name: 'check' }, { emoji: '❌', name: 'x' }, { emoji: '❗', name: 'exclamation' }, { emoji: '❓', name: 'question' },
|
||||||
|
{ emoji: '⭐', name: 'star' }, { emoji: '🌟', name: 'glowing star' }, { emoji: '✨', name: 'sparkles' }, { emoji: '💫', name: 'dizzy' },
|
||||||
|
{ emoji: '🔥', name: 'fire' }, { emoji: '💧', name: 'droplet' }, { emoji: '🌊', name: 'wave' }, { emoji: '☀️', name: 'sun' },
|
||||||
|
// Nature
|
||||||
|
{ emoji: '🌙', name: 'moon' }, { emoji: '⭐', name: 'star' }, { emoji: '🌈', name: 'rainbow' }, { emoji: '☁️', name: 'cloud' },
|
||||||
|
{ emoji: '⛅', name: 'partly cloudy' }, { emoji: '⛈️', name: 'storm' }, { emoji: '🌩️', name: 'lightning cloud' }, { emoji: '❄️', name: 'snowflake' },
|
||||||
|
{ emoji: '☃️', name: 'snowman' }, { emoji: '🌸', name: 'cherry blossom' }, { emoji: '🌺', name: 'hibiscus' }, { emoji: '🌻', name: 'sunflower' },
|
||||||
|
// Animals
|
||||||
|
{ emoji: '🐶', name: 'dog' }, { emoji: '🐱', name: 'cat' }, { emoji: '🐭', name: 'mouse' }, { emoji: '🐹', name: 'hamster' },
|
||||||
|
{ emoji: '🐰', name: 'rabbit' }, { emoji: '🦊', name: 'fox' }, { emoji: '🐻', name: 'bear' }, { emoji: '🐼', name: 'panda' },
|
||||||
|
{ emoji: '🐨', name: 'koala' }, { emoji: '🐯', name: 'tiger' }, { emoji: '🦁', name: 'lion' }, { emoji: '🐮', name: 'cow' },
|
||||||
|
// Food
|
||||||
|
{ emoji: '🍎', name: 'apple' }, { emoji: '🍊', name: 'orange' }, { emoji: '🍋', name: 'lemon' }, { emoji: '🍌', name: 'banana' },
|
||||||
|
{ emoji: '🍉', name: 'watermelon' }, { emoji: '🍇', name: 'grapes' }, { emoji: '🍓', name: 'strawberry' }, { emoji: '🍒', name: 'cherries' },
|
||||||
|
{ emoji: '🍑', name: 'peach' }, { emoji: '🍕', name: 'pizza' }, { emoji: '🍔', name: 'burger' }, { emoji: '🍟', name: 'fries' },
|
||||||
|
// Faces
|
||||||
|
{ emoji: '😀', name: 'grin' }, { emoji: '😃', name: 'smile' }, { emoji: '😄', name: 'happy' }, { emoji: '😁', name: 'grinning' },
|
||||||
|
{ emoji: '😅', name: 'sweat smile' }, { emoji: '😂', name: 'joy' }, { emoji: '🤣', name: 'rofl' }, { emoji: '😊', name: 'blush' },
|
||||||
|
{ emoji: '😇', name: 'innocent' }, { emoji: '🙂', name: 'slightly smiling' }, { emoji: '🙃', name: 'upside down' }, { emoji: '😉', name: 'wink' },
|
||||||
|
// Hearts
|
||||||
|
{ emoji: '❤️', name: 'heart' }, { emoji: '🧡', name: 'orange heart' }, { emoji: '💛', name: 'yellow heart' }, { emoji: '💚', name: 'green heart' },
|
||||||
|
{ emoji: '💙', name: 'blue heart' }, { emoji: '💜', name: 'purple heart' }, { emoji: '🖤', name: 'black heart' }, { emoji: '🤍', name: 'white heart' },
|
||||||
|
{ emoji: '🤎', name: 'brown heart' }, { emoji: '💖', name: 'sparkling heart' }, { emoji: '💗', name: 'growing heart' }, { emoji: '💓', name: 'beating heart' }
|
||||||
|
];
|
||||||
17
src/renderer/constants/shortcuts.ts
Normal file
17
src/renderer/constants/shortcuts.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Shortcut } from '../types';
|
||||||
|
|
||||||
|
export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
|
||||||
|
toggleSidebar: { id: 'toggleSidebar', label: 'Toggle Sidebar', keys: ['Meta', 'b'] },
|
||||||
|
toggleRightPanel: { id: 'toggleRightPanel', label: 'Toggle Right Panel', keys: ['Meta', '\\'] },
|
||||||
|
cyclePrev: { id: 'cyclePrev', label: 'Previous Instance', keys: ['Meta', 'Shift', '{'] },
|
||||||
|
cycleNext: { id: 'cycleNext', label: 'Next Instance', keys: ['Meta', 'Shift', '}'] },
|
||||||
|
newInstance: { id: 'newInstance', label: 'New Instance', keys: ['Meta', 'n'] },
|
||||||
|
killInstance: { id: 'killInstance', label: 'Kill Instance', keys: ['Meta', 'Shift', 'Backspace'] },
|
||||||
|
toggleMode: { id: 'toggleMode', label: 'Switch AI/Shell Mode', keys: ['Meta', 'j'] },
|
||||||
|
quickAction: { id: 'quickAction', label: 'Quick Actions', keys: ['Meta', 'k'] },
|
||||||
|
help: { id: 'help', label: 'Show Shortcuts', keys: ['Meta', '/'] },
|
||||||
|
settings: { id: 'settings', label: 'Open Settings', keys: ['Meta', ','] },
|
||||||
|
goToFiles: { id: 'goToFiles', label: 'Go to Files Tab', keys: ['Meta', 'Shift', 'f'] },
|
||||||
|
goToHistory: { id: 'goToHistory', label: 'Go to History Tab', keys: ['Meta', 'Shift', 'h'] },
|
||||||
|
goToScratchpad: { id: 'goToScratchpad', label: 'Go to Scratchpad Tab', keys: ['Meta', 'Shift', 's'] },
|
||||||
|
};
|
||||||
156
src/renderer/constants/themes.ts
Normal file
156
src/renderer/constants/themes.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { Theme, ThemeId } from '../types';
|
||||||
|
|
||||||
|
export const THEMES: Record<ThemeId, Theme> = {
|
||||||
|
dracula: {
|
||||||
|
id: 'dracula',
|
||||||
|
name: 'Dracula',
|
||||||
|
mode: 'dark',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#0b0b0d',
|
||||||
|
bgSidebar: '#111113',
|
||||||
|
bgActivity: '#1c1c1f',
|
||||||
|
border: '#27272a',
|
||||||
|
textMain: '#e4e4e7',
|
||||||
|
textDim: '#a1a1aa',
|
||||||
|
accent: '#6366f1',
|
||||||
|
accentDim: 'rgba(99, 102, 241, 0.2)',
|
||||||
|
accentText: '#a5b4fc',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#eab308',
|
||||||
|
error: '#ef4444'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
monokai: {
|
||||||
|
id: 'monokai',
|
||||||
|
name: 'Monokai',
|
||||||
|
mode: 'dark',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#272822',
|
||||||
|
bgSidebar: '#1e1f1c',
|
||||||
|
bgActivity: '#3e3d32',
|
||||||
|
border: '#49483e',
|
||||||
|
textMain: '#f8f8f2',
|
||||||
|
textDim: '#8f908a',
|
||||||
|
accent: '#fd971f',
|
||||||
|
accentDim: 'rgba(253, 151, 31, 0.2)',
|
||||||
|
accentText: '#fdbf6f',
|
||||||
|
success: '#a6e22e',
|
||||||
|
warning: '#e6db74',
|
||||||
|
error: '#f92672'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'github-light': {
|
||||||
|
id: 'github-light',
|
||||||
|
name: 'GitHub',
|
||||||
|
mode: 'light',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#ffffff',
|
||||||
|
bgSidebar: '#f6f8fa',
|
||||||
|
bgActivity: '#eff2f5',
|
||||||
|
border: '#d0d7de',
|
||||||
|
textMain: '#24292f',
|
||||||
|
textDim: '#57606a',
|
||||||
|
accent: '#0969da',
|
||||||
|
accentDim: 'rgba(9, 105, 218, 0.1)',
|
||||||
|
accentText: '#0969da',
|
||||||
|
success: '#1a7f37',
|
||||||
|
warning: '#9a6700',
|
||||||
|
error: '#cf222e'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'solarized-light': {
|
||||||
|
id: 'solarized-light',
|
||||||
|
name: 'Solarized',
|
||||||
|
mode: 'light',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#fdf6e3',
|
||||||
|
bgSidebar: '#eee8d5',
|
||||||
|
bgActivity: '#e6dfc8',
|
||||||
|
border: '#d3cbb7',
|
||||||
|
textMain: '#657b83',
|
||||||
|
textDim: '#93a1a1',
|
||||||
|
accent: '#2aa198',
|
||||||
|
accentDim: 'rgba(42, 161, 152, 0.1)',
|
||||||
|
accentText: '#2aa198',
|
||||||
|
success: '#859900',
|
||||||
|
warning: '#b58900',
|
||||||
|
error: '#dc322f'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nord: {
|
||||||
|
id: 'nord',
|
||||||
|
name: 'Nord',
|
||||||
|
mode: 'dark',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#2e3440',
|
||||||
|
bgSidebar: '#3b4252',
|
||||||
|
bgActivity: '#434c5e',
|
||||||
|
border: '#4c566a',
|
||||||
|
textMain: '#eceff4',
|
||||||
|
textDim: '#d8dee9',
|
||||||
|
accent: '#88c0d0',
|
||||||
|
accentDim: 'rgba(136, 192, 208, 0.2)',
|
||||||
|
accentText: '#8fbcbb',
|
||||||
|
success: '#a3be8c',
|
||||||
|
warning: '#ebcb8b',
|
||||||
|
error: '#bf616a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'tokyo-night': {
|
||||||
|
id: 'tokyo-night',
|
||||||
|
name: 'Tokyo Night',
|
||||||
|
mode: 'dark',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#1a1b26',
|
||||||
|
bgSidebar: '#16161e',
|
||||||
|
bgActivity: '#24283b',
|
||||||
|
border: '#414868',
|
||||||
|
textMain: '#c0caf5',
|
||||||
|
textDim: '#9aa5ce',
|
||||||
|
accent: '#7aa2f7',
|
||||||
|
accentDim: 'rgba(122, 162, 247, 0.2)',
|
||||||
|
accentText: '#7dcfff',
|
||||||
|
success: '#9ece6a',
|
||||||
|
warning: '#e0af68',
|
||||||
|
error: '#f7768e'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'one-light': {
|
||||||
|
id: 'one-light',
|
||||||
|
name: 'One Light',
|
||||||
|
mode: 'light',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#fafafa',
|
||||||
|
bgSidebar: '#f0f0f0',
|
||||||
|
bgActivity: '#e5e5e6',
|
||||||
|
border: '#d0d0d0',
|
||||||
|
textMain: '#383a42',
|
||||||
|
textDim: '#a0a1a7',
|
||||||
|
accent: '#4078f2',
|
||||||
|
accentDim: 'rgba(64, 120, 242, 0.1)',
|
||||||
|
accentText: '#4078f2',
|
||||||
|
success: '#50a14f',
|
||||||
|
warning: '#c18401',
|
||||||
|
error: '#e45649'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gruvbox-light': {
|
||||||
|
id: 'gruvbox-light',
|
||||||
|
name: 'Gruvbox Light',
|
||||||
|
mode: 'light',
|
||||||
|
colors: {
|
||||||
|
bgMain: '#fbf1c7',
|
||||||
|
bgSidebar: '#ebdbb2',
|
||||||
|
bgActivity: '#d5c4a1',
|
||||||
|
border: '#bdae93',
|
||||||
|
textMain: '#3c3836',
|
||||||
|
textDim: '#7c6f64',
|
||||||
|
accent: '#458588',
|
||||||
|
accentDim: 'rgba(69, 133, 136, 0.1)',
|
||||||
|
accentText: '#076678',
|
||||||
|
success: '#98971a',
|
||||||
|
warning: '#d79921',
|
||||||
|
error: '#cc241d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
65
src/renderer/index.css
Normal file
65
src/renderer/index.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-in {
|
||||||
|
animation: fade-in 0.2s ease-in;
|
||||||
|
}
|
||||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Maestro - Multi-Instance AI Coding Console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
src/renderer/main.tsx
Normal file
10
src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import MaestroConsole from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<MaestroConsole />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
104
src/renderer/types/index.ts
Normal file
104
src/renderer/types/index.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Type definitions for Maestro renderer
|
||||||
|
|
||||||
|
export type ToolType = 'claude' | 'aider' | 'opencode' | 'terminal';
|
||||||
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
||||||
|
export type FileChangeType = 'modified' | 'added' | 'deleted';
|
||||||
|
export type RightPanelTab = 'files' | 'history' | 'scratchpad';
|
||||||
|
export type ScratchPadMode = 'raw' | 'preview' | 'wysiwyg';
|
||||||
|
export type ThemeId = 'dracula' | 'monokai' | 'github-light' | 'solarized-light' | 'nord' | 'tokyo-night' | 'one-light' | 'gruvbox-light';
|
||||||
|
export type FocusArea = 'sidebar' | 'main' | 'right';
|
||||||
|
export type LLMProvider = 'openrouter' | 'anthropic' | 'ollama';
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: ThemeId;
|
||||||
|
name: string;
|
||||||
|
mode: 'light' | 'dark';
|
||||||
|
colors: {
|
||||||
|
bgMain: string;
|
||||||
|
bgSidebar: string;
|
||||||
|
bgActivity: string;
|
||||||
|
border: string;
|
||||||
|
textMain: string;
|
||||||
|
textDim: string;
|
||||||
|
accent: string;
|
||||||
|
accentDim: string;
|
||||||
|
accentText: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shortcut {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileArtifact {
|
||||||
|
path: string;
|
||||||
|
type: FileChangeType;
|
||||||
|
linesAdded?: number;
|
||||||
|
linesRemoved?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
source: 'stdout' | 'stderr' | 'system' | 'user';
|
||||||
|
text: string;
|
||||||
|
interactive?: boolean;
|
||||||
|
options?: string[];
|
||||||
|
images?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkLogItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: number;
|
||||||
|
relatedFiles?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
groupId?: string;
|
||||||
|
name: string;
|
||||||
|
toolType: ToolType;
|
||||||
|
state: SessionState;
|
||||||
|
cwd: string;
|
||||||
|
fullPath: string;
|
||||||
|
aiLogs: LogEntry[];
|
||||||
|
shellLogs: LogEntry[];
|
||||||
|
workLog: WorkLogItem[];
|
||||||
|
scratchPadContent: string;
|
||||||
|
contextUsage: number;
|
||||||
|
inputMode: 'terminal' | 'ai';
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
tunnelActive: boolean;
|
||||||
|
tunnelUrl?: string;
|
||||||
|
changedFiles: FileArtifact[];
|
||||||
|
isGitRepo: boolean;
|
||||||
|
// File Explorer per-session state
|
||||||
|
fileTree: any[];
|
||||||
|
fileExplorerExpanded: string[];
|
||||||
|
fileExplorerScrollPos: number;
|
||||||
|
fileTreeError?: string;
|
||||||
|
// Shell state tracking
|
||||||
|
shellCwd: string;
|
||||||
|
// Command history
|
||||||
|
commandHistory: string[];
|
||||||
|
// Scratchpad state tracking
|
||||||
|
scratchPadCursorPosition?: number;
|
||||||
|
scratchPadEditScrollPos?: number;
|
||||||
|
scratchPadPreviewScrollPos?: number;
|
||||||
|
scratchPadMode?: 'edit' | 'preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
2
src/renderer/utils/ids.ts
Normal file
2
src/renderer/utils/ids.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Generate a random unique identifier
|
||||||
|
export const generateId = () => Math.random().toString(36).substr(2, 9);
|
||||||
15
src/renderer/utils/search.ts
Normal file
15
src/renderer/utils/search.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Fuzzy search matching - returns true if all characters in query appear in text in order
|
||||||
|
export const fuzzyMatch = (text: string, query: string): boolean => {
|
||||||
|
if (!query) return true;
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
let queryIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
|
||||||
|
if (lowerText[i] === lowerQuery[queryIndex]) {
|
||||||
|
queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryIndex === lowerQuery.length;
|
||||||
|
};
|
||||||
17
src/renderer/utils/theme.ts
Normal file
17
src/renderer/utils/theme.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Theme, SessionState } from '../types';
|
||||||
|
|
||||||
|
// Get color based on context usage percentage
|
||||||
|
export const getContextColor = (usage: number, theme: Theme): string => {
|
||||||
|
if (usage >= 80) return theme.colors.error;
|
||||||
|
if (usage >= 60) return theme.colors.warning;
|
||||||
|
return theme.colors.success;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get color based on session state
|
||||||
|
export const getStatusColor = (state: SessionState, theme: Theme): string => {
|
||||||
|
switch (state) {
|
||||||
|
case 'busy': return theme.colors.error;
|
||||||
|
case 'waiting_input': return theme.colors.warning;
|
||||||
|
default: return theme.colors.success;
|
||||||
|
}
|
||||||
|
};
|
||||||
14
tailwind.config.mjs
Normal file
14
tailwind.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./src/renderer/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"Courier New"', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/renderer"]
|
||||||
|
}
|
||||||
18
tsconfig.main.json
Normal file
18
tsconfig.main.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "release"]
|
||||||
|
}
|
||||||
16
vite.config.mts
Normal file
16
vite.config.mts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: path.join(__dirname, 'src/renderer'),
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: path.join(__dirname, 'dist/renderer'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user