UX prototype complete

This commit is contained in:
Pedram Amini
2025-11-23 19:00:08 -06:00
commit ca85ff7c48
39 changed files with 18181 additions and 0 deletions

111
.github/workflows/release.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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 (0100%).
### **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/Superhumans 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
View 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
View 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

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

10473
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

126
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

107
src/main/agent-detector.ts Normal file
View 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
View 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
View 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
View 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);
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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' }
];

View 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'] },
};

View 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
View 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
View 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
View 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
View 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;
}

View File

@@ -0,0 +1,2 @@
// Generate a random unique identifier
export const generateId = () => Math.random().toString(36).substr(2, 9);

View 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;
};

View 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
View 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
View 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
View 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
View 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,
},
});