- Add real-time token usage and cost tracking from Claude Code responses - New UsageStats type with tokens, cost, and context window - Context window tooltip shows detailed token breakdown - Cost displayed in main panel header and session list - Improve input area UX with $ prefix for terminal mode - Add Cmd+L shortcut to insert markdown checkbox in Scratchpad - Add terminalOnly flag for slash commands (/jump is terminal-only) - Disable LLM Settings panel behind feature flag (documented in BACKBURNER.md) - Fix AgentSessionsBrowser to use layer stack for Escape handling - Update README with cost tracking feature and keyboard shortcut - Update CLAUDE.md with process:usage event and usageStats session field
41 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Standardized Vernacular
To maintain consistency in code, comments, and documentation, use these terms:
UI Components
- Left Bar - The left sidebar containing session list and groups (
SessionList.tsx) - Right Bar - The right sidebar with Files, History, and Scratchpad tabs (
RightPanel.tsx) - Main Window - The center workspace area (
MainPanel.tsx)- AI Terminal - The main window when in AI mode (for interacting with AI agents)
- Command Terminal - The main window when in terminal/shell mode (for running shell commands)
- System Log Viewer - Special view in main window for system logs (
LogViewer.tsx)
Session States
Session status indicators use color-coding:
- Green - Ready and waiting (idle state)
- Yellow - Agent is thinking (busy state)
- Red - No connection with agent (error state)
- Pulsing Orange - Attempting to establish connection (connecting state)
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 (Command Terminal vs AI Terminal), and remote web access capabilities.
Development Commands
Running the Application
# Development mode with hot reload
npm run dev
# Build and run production
npm run build
npm start
Building
# 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
# 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
# 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 managementprocess-manager.ts- Core primitive for spawning and managing CLI processesweb-server.ts- Fastify-based HTTP/WebSocket server for remote accessagent-detector.ts- Auto-detects available AI tools (Claude Code, Aider, etc.) via PATHpreload.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 coordinator (~1,650 lines, continuously being refactored)main.tsx- Renderer entry pointcomponents/- React components (modals, panels, UI elements)SessionList.tsx- Left Bar with sessions and groupsMainPanel.tsx- Main Window (AI Terminal, Command Terminal, System Log Viewer, Agent Sessions Browser)RightPanel.tsx- Right Bar (files, history, scratchpad)LogViewer.tsx- System Log Viewer with filtering and searchAgentSessionsBrowser.tsx- Browse and resume Claude Code sessions from~/.claude/projects/SettingsModal.tsx,NewInstanceModal.tsx,Scratchpad.tsx,FilePreview.tsx- Other UI components
hooks/- Custom React hooks for reusable state logicuseSettings.ts- Settings management and persistenceuseSessionManager.ts- Session and group CRUD operationsuseFileExplorer.ts- File tree state and operations
services/- Business logic services (clean wrappers around IPC calls)git.ts- Git operations (status, diff, isRepo)process.ts- Process management (spawn, write, kill, resize)
Process Management System
The ProcessManager class is the core architectural primitive that abstracts two process types:
-
PTY Processes (via
node-pty) - For terminal sessions with full shell emulation- Used for
toolType: 'terminal' - Supports resize, ANSI escape codes, interactive shell
- Used for
-
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()withshell: falseto prevent command injection
All process operations go through IPC handlers in src/main/index.ts:
process:spawn- Start a new processprocess:write- Send data to stdinprocess:kill- Terminate a processprocess:resize- Resize PTY terminal (Command Terminal only)
Events are emitted back to renderer via:
process:data- Stdout/stderr outputprocess:exit- Process exit codeprocess:usage- Usage statistics from AI responses (tokens, cost, context window)
Session Model
Each "session" is a unified abstraction running two processes simultaneously (dual-process architecture):
sessionId- Unique identifier (suffixed with-aior-terminalfor each process)toolType- Agent type (claude-code, aider, terminal, custom)cwd- Working directorystate- Current state (idle, busy, error)inputMode- Input routing mode ('terminal' or 'ai')aiPid- Process ID for the AI agent processterminalPid- Process ID for the terminal processusageStats- Token usage and cost statistics from AI responses (optional)
This dual-process model allows seamless switching between AI and terminal modes without restarting processes. Input is routed to the appropriate process based on inputMode.
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
- Claude sessions (browse/resume Claude Code sessions)
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 statusgit:diff- Get diff for filesgit: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:8000for LAN access
Agent Detection
AgentDetector class auto-discovers CLI tools in PATH:
- Uses
which(Unix) orwhere(Windows) viaexecFileNoThrow - Caches results for performance
- Pre-configured agents: Claude Code, Aider, Qwen Coder, CLI Terminal
- Extensible via
AGENT_DEFINITIONSarray insrc/main/agent-detector.ts
Claude Sessions API
Maestro can browse and resume Claude Code sessions stored in ~/.claude/projects/. Sessions are JSONL files containing conversation history.
IPC Handlers:
claude:listSessions- Lists all sessions for a project pathclaude:readSessionMessages- Reads messages from a session (with lazy loading via offset/limit)claude:searchSessions- Searches session content by title, user messages, assistant messages, or all
Session Path Encoding:
Claude Code encodes project paths by replacing / with -. For example:
/Users/pedram/Projects/Maestro→-Users-pedram-Projects-Maestro- Sessions stored at:
~/.claude/projects/-Users-pedram-Projects-Maestro/*.jsonl
Usage in Renderer:
// List sessions for current project
const sessions = await window.maestro.claude.listSessions(activeSession.cwd);
// Read messages with pagination (reads from end)
const { messages, total, hasMore } = await window.maestro.claude.readSessionMessages(
projectPath,
sessionId,
{ offset: 0, limit: 20 }
);
// Search across sessions
const results = await window.maestro.claude.searchSessions(
projectPath,
'search query',
'all' // 'title' | 'user' | 'assistant' | 'all'
);
UI Integration:
- Open with
Cmd+Shift+L(configurable in Settings > Shortcuts) - Available via Quick Actions (
Cmd+K→ "View Agent Sessions") - Button in main panel header (List icon)
- Shows session list with search, message preview, and resume functionality
Key Design Patterns
Slash Commands System
Maestro implements an extensible slash command system in src/renderer/slashCommands.ts:
export interface SlashCommand {
command: string; // The command string (e.g., "/clear")
description: string; // Human-readable description
terminalOnly?: boolean; // Only show in terminal mode (optional)
execute: (context: SlashCommandContext) => void; // Command handler
}
Architecture:
- Commands are defined in a single registry (
slashCommandsarray) - Autocomplete UI appears when user types
/ - Keyboard navigation with arrow keys, Tab/Enter to select
- Commands receive execution context (activeSessionId, sessions, setSessions, currentMode)
- Commands can modify session state, trigger actions, or interact with IPC
- Commands can be mode-specific using
terminalOnly: trueflag
Adding new commands:
- Add new entry to
slashCommandsarray insrc/renderer/slashCommands.ts - Implement
executefunction with desired behavior - Optionally set
terminalOnly: trueto restrict to terminal mode - Command automatically appears in autocomplete (filtered by mode)
Current commands:
/clear- Clears output history for current mode (AI or terminal)/jump- Jumps to current working directory in file tree (terminal mode only)
Layer Stack System
Maestro uses a centralized layer stack system for managing modals, overlays, and search layers with predictable Escape key handling and priority-based ordering.
Problem Solved:
- Previously had 9+ scattered Escape handlers competing for events
- Brittle modal detection with massive boolean checks
- Manual priority management via if-else chains (50+ lines)
- Inconsistent focus management
Architecture:
useLayerStackhook (src/renderer/hooks/useLayerStack.ts) - Core layer managementLayerStackContext(src/renderer/contexts/LayerStackContext.tsx) - Global Escape handler via capture-phase listenerMODAL_PRIORITIES(src/renderer/constants/modalPriorities.ts) - Explicit z-index/priority valuesLayertypes (src/renderer/types/layer.ts) - Discriminated union (ModalLayer, OverlayLayer)
Key Features:
- Single global Escape handler delegates to topmost layer
- Priority-based ordering (higher number = higher priority)
- Automatic layer registration/unregistration on mount/unmount
- Performance-optimized (handler updates don't trigger re-sorts)
- Type-safe discriminated unions
- Built-in dev tools for debugging layer stack
Modal Priority Hierarchy:
CONFIRM: 1000 // Highest - confirmation dialogs
RENAME_INSTANCE: 900
RENAME_GROUP: 850
CREATE_GROUP: 800
NEW_INSTANCE: 750
QUICK_ACTION: 700 // Command palette (Cmd+K)
AGENT_SESSIONS: 680 // Agent sessions browser (Cmd+Shift+L)
SHORTCUTS_HELP: 650
ABOUT: 600
PROCESS_MONITOR: 550
LOG_VIEWER: 500
SETTINGS: 450
GIT_DIFF: 200
LIGHTBOX: 150
FILE_PREVIEW: 100
SLASH_AUTOCOMPLETE: 50
FILE_TREE_FILTER: 30 // Lowest - inline search
Adding a New Modal:
- Choose priority - Select appropriate value from
MODAL_PRIORITIESor add new constant - Import layer stack hook:
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
- Register layer on mount (use ref pattern to avoid re-registration on callback changes):
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
// Store onClose in ref to avoid re-registering layer when callback identity changes
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (modalOpen) {
const id = registerLayer({
type: 'modal', // or 'overlay'
priority: MODAL_PRIORITIES.YOUR_MODAL,
blocksLowerLayers: true,
capturesFocus: true,
focusTrap: 'strict', // or 'lenient', 'none'
ariaLabel: 'Your Modal Name',
onEscape: () => onCloseRef.current(), // Use ref to get latest callback
});
layerIdRef.current = id;
return () => unregisterLayer(id);
}
}, [modalOpen, registerLayer, unregisterLayer]); // Note: onClose NOT in deps
- Update handler when dependencies change (only needed if handler has other dependencies):
useEffect(() => {
if (modalOpen && layerIdRef.current) {
updateLayerHandler(layerIdRef.current, () => onCloseRef.current());
}
}, [modalOpen, updateLayerHandler]); // Use ref for callbacks
Why use the ref pattern? Parent components often create new callback instances on every render. Without the ref pattern, this would cause the layer to be unregistered and re-registered unnecessarily, which can cause flickering or focus issues.
- Add ARIA attributes:
<div
role="dialog"
aria-modal="true"
aria-label="Your Modal Name"
tabIndex={-1}
ref={(el) => el?.focus()} // Auto-focus on mount
>
- Remove local Escape handlers - Let layer stack handle it
Layer Types:
// Modal - full-screen overlay that blocks interaction
type ModalLayer = {
type: 'modal';
priority: number;
blocksLowerLayers: boolean;
capturesFocus: boolean;
focusTrap: 'strict' | 'lenient' | 'none';
ariaLabel?: string;
onEscape: () => void;
onBeforeClose?: () => Promise<boolean>; // Optional confirmation
isDirty?: boolean;
parentModalId?: string;
};
// Overlay - semi-transparent overlay (file preview, search)
type OverlayLayer = {
type: 'overlay';
priority: number;
blocksLowerLayers: boolean;
capturesFocus: boolean;
focusTrap: 'strict' | 'lenient' | 'none';
ariaLabel?: string;
onEscape: () => void;
allowClickOutside: boolean;
};
Internal Search Layers:
Components like FilePreview, TerminalOutput, and LogViewer handle internal search state in their onEscape handler:
onEscape: () => {
if (searchOpen) {
setSearchOpen(false); // First Escape closes search
} else {
closePreview(); // Second Escape closes preview
}
}
Benefits:
- No more manual modal priority if-else chains
- Predictable, testable behavior
- Easy to add new modals (just set priority)
- Single source of truth for layer ordering
- Removed 100+ lines of brittle modal management code
Debugging:
In development mode, use LayerStackDevTools component (bottom-right overlay) or browser console:
window.__MAESTRO_DEBUG__.layers.list() // Show all layers
window.__MAESTRO_DEBUG__.layers.top() // Show top layer
window.__MAESTRO_DEBUG__.layers.simulate.escape() // Simulate Escape key
Dual-Mode Input Router
Sessions toggle between two input modes:
- Terminal Mode - Raw shell commands via PTY
- AI Interaction Mode - Direct communication with AI assistant
This is implemented via the isTerminal flag in ProcessManager.
Event-Driven Output Streaming
ProcessManager extends EventEmitter:
processManager.on('data', (sessionId, data) => { ... })
processManager.on('exit', (sessionId, code) => { ... })
Events are forwarded to renderer via IPC:
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:
// 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
Custom Hooks Architecture
The renderer now uses custom hooks to encapsulate reusable state logic and reduce the size of App.tsx.
useSettings (src/renderer/hooks/useSettings.ts)
Manages all application settings with automatic persistence to electron-store.
What it manages:
- LLM settings (provider, model, API key)
- Tunnel settings (provider, API key)
- Agent settings (default agent)
- Font settings (family, size, custom fonts)
- UI settings (theme, enter-to-send, panel widths, markdown mode)
- Keyboard shortcuts
Usage:
import { useSettings } from './hooks';
const settings = useSettings();
// Access: settings.llmProvider, settings.fontSize, etc.
// Update: settings.setTheme('dracula'), settings.setFontSize(16), etc.
All setter functions automatically persist to electron-store.
useSessionManager (src/renderer/hooks/useSessionManager.ts)
Manages sessions and groups with CRUD operations, drag & drop, and persistence.
What it manages:
- Sessions array and groups array
- Active session selection
- Session CRUD (create, delete, rename, toggle modes)
- Group operations (create, toggle collapse, rename)
- Drag and drop state and handlers
- Automatic persistence to electron-store
Usage:
import { useSessionManager } from './hooks';
const sessionManager = useSessionManager();
// Access: sessionManager.sessions, sessionManager.activeSession, etc.
// Operations: sessionManager.createNewSession(), sessionManager.deleteSession(), etc.
Key methods:
createNewSession(agentId, workingDir, name)- Create new sessiondeleteSession(id, showConfirmation)- Delete with confirmationtoggleInputMode()- Switch between AI and terminal modeupdateScratchPad(content)- Update session scratchpadcreateNewGroup(name, emoji, moveSession, activeSessionId)- Create group
useFileExplorer (src/renderer/hooks/useFileExplorer.ts)
Manages file tree state, expansion, navigation, and file operations.
What it manages:
- File preview state
- File tree navigation and selection
- Folder expansion/collapse
- File tree filtering
- File loading and operations
Usage:
import { useFileExplorer } from './hooks';
const fileExplorer = useFileExplorer(activeSession, setActiveFocus);
// Access: fileExplorer.previewFile, fileExplorer.filteredFileTree, etc.
// Operations: fileExplorer.handleFileClick(), fileExplorer.expandAllFolders(), etc.
Key methods:
handleFileClick(node, path, activeSession)- Open file or external apploadFileTree(dirPath, maxDepth?)- Load directory treetoggleFolder(path, activeSessionId, setSessions)- Toggle folder expansionexpandAllFolders()/collapseAllFolders()- Bulk operationsupdateSessionWorkingDirectory()- Change session CWD
Services Architecture
Services provide clean wrappers around IPC calls, abstracting away the window.maestro API details.
Git Service (src/renderer/services/git.ts)
Provides type-safe git operations.
Usage:
import { gitService } from '../services/git';
// Check if directory is a git repo
const isRepo = await gitService.isRepo(cwd);
// Get git status
const status = await gitService.getStatus(cwd);
// Returns: { files: [{ path: string, status: string }] }
// Get git diff
const diff = await gitService.getDiff(cwd, ['file1.ts', 'file2.ts']);
// Returns: { diff: string }
All methods handle errors gracefully and return safe defaults.
Process Service (src/renderer/services/process.ts)
Provides type-safe process management operations.
Usage:
import { processService } from '../services/process';
// Spawn a process
await processService.spawn(sessionId, {
cwd: '/path/to/dir',
command: 'claude-code',
args: [],
isTerminal: false
});
// Write to process stdin
await processService.write(sessionId, 'user input\n');
// Kill process
await processService.kill(sessionId);
// Listen for process events
const unsubscribeData = processService.onData((sessionId, data) => {
console.log('Process output:', data);
});
const unsubscribeExit = processService.onExit((sessionId, code) => {
console.log('Process exited:', code);
});
// Clean up listeners
unsubscribeData();
unsubscribeExit();
UI Architecture & Components
Main Application Structure (App.tsx)
The main application is structured in three columns:
- Left Sidebar - Session list, groups, new instance button
- Main Panel - Terminal/AI output, input area, toolbar
- Right Panel - Files, History, Scratchpad tabs
Key Components
MainPanel (src/renderer/components/MainPanel.tsx)
- Center workspace container that handles three states:
- LogViewer when system logs are open
- Empty state when no session is active
- Normal session view (top bar, terminal output, input area, file preview)
- Encapsulates all main panel UI logic outside of App.tsx
LogViewer (src/renderer/components/LogViewer.tsx)
- System logs viewer accessible via Cmd+K → "View System Logs"
- Color-coded log levels (Debug, Info, Warn, Error)
- Searchable with
/key, filterable by log level - Export logs to file, clear all logs
- Keyboard navigation (arrows to scroll, Cmd+arrows to jump)
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
- Log level selector with color-coded buttons (defaults to "info")
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
GitStatusWidget (src/renderer/components/GitStatusWidget.tsx)
- GitHub-style file change tracking widget in the main panel header
- Displays counts of additions (green +), deletions (red -), and modifications (orange)
- Hover tooltip shows list of all changed files with their status
- Click to view full git diff in modal overlay
- Automatically polls git status every 5 seconds
- Only renders when session is in a Git repository
- Integration: Used in MainPanel.tsx between LIVE button and Context Window
Keyboard Navigation Patterns
The app is keyboard-first with these patterns:
Focus Management:
- Cmd+. → Jump to input field (from anywhere in main interface)
- Cmd+Shift+A → Focus left sidebar (expands if collapsed) - configurable
- 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}andoutline-nonefor programmatic focus
Output Window:
/→ Open search/filter- Arrow Up/Down → Scroll output (~100px)
- Option/Alt + Arrow Up/Down → Page up/down (viewport height)
- 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 src/renderer/constants/themes.ts with structure:
{
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
accentText: string; // Accent text color
success: string; // Success state
warning: string; // Warning state
error: string; // Error state
}
}
Available themes:
- Dark mode: Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark
- Light mode: GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light
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-3for consistency - Focus states: Always add
outline-nonewhen usingtabIndex - Sticky elements: Use
sticky top-0 z-10with solid background - Overlays: Use
fixed inset-0with backdrop blur and high z-index
State Management Per Session
Each session stores:
cwd- Current working directoryfileTree- File tree structurefileExplorerExpanded- Expanded folder pathsfileExplorerScrollPos- Scroll position in file treeaiLogs/shellLogs- Output historyinputMode- '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.tsfor renderer types
Component Extraction Pattern
Principle: Keep App.tsx minimal by extracting UI sections into dedicated components.
When adding new features that would add significant complexity to App.tsx:
- Create a new component in
src/renderer/components/that encapsulates the entire UI section - Pass only necessary props - state, setters, refs, and callback functions
- Handle all conditional logic within the component (e.g., empty states, different views)
- Keep App.tsx as a coordinator - it should orchestrate state and wire components together, not contain UI logic
Example - MainPanel component:
// App.tsx - Minimal integration
<MainPanel
logViewerOpen={logViewerOpen}
activeSession={activeSession}
theme={theme}
// ... other props
setLogViewerOpen={setLogViewerOpen}
toggleInputMode={toggleInputMode}
// ... other handlers
/>
// MainPanel.tsx - Contains all UI logic
export function MainPanel(props: MainPanelProps) {
// Handles: log viewer, empty state, normal session view
if (logViewerOpen) return <LogViewer ... />;
if (!activeSession) return <EmptyState ... />;
return <SessionView ... />;
}
Benefits:
- App.tsx stays manageable and readable
- Components are self-contained and testable
- Changes to UI sections don't bloat App.tsx
- Easier code review and maintenance
Commit Message Format
Use conventional commits:
feat:- New featuresfix:- Bug fixesdocs:- Documentation changesrefactor:- Code refactoringtest:- Test additions/changeschore:- Build process or tooling changes
Security Requirements
- Use
execFileNoThrowfor all external commands - Located insrc/main/utils/execFile.ts - Context isolation - Keep enabled in BrowserWindow
- Input sanitization - Validate all user inputs
- Minimal preload exposure - Only expose necessary APIs via contextBridge
- Process spawning - Use
spawn()withshell: falseflag
Error Handling Patterns
Maestro uses different error handling strategies depending on the architectural layer:
IPC Handler Errors (Main Process)
IPC handlers in src/main/index.ts should handle errors gracefully but may throw for critical failures:
Pattern 1: Throw for critical failures
ipcMain.handle('process:spawn', async (_, config) => {
if (!processManager) throw new Error('Process manager not initialized');
return processManager.spawn(config);
});
- Use for: Initialization failures, missing required services
- Effect: Renderer will receive rejected promise
Pattern 2: Try-catch with boolean return
ipcMain.handle('git:isRepo', async (_, cwd: string) => {
try {
const result = await execFileNoThrow('git', ['rev-parse', '--is-inside-work-tree'], cwd);
return result.exitCode === 0;
} catch {
return false;
}
});
- Use for: Optional operations where false is a valid answer
- Effect: Graceful degradation (e.g., git features disabled)
Pattern 3: Return error in result object
ipcMain.handle('git:status', async (_, cwd: string) => {
const result = await execFileNoThrow('git', ['status', '--porcelain'], cwd);
return { stdout: result.stdout, stderr: result.stderr };
});
- Use for: Operations where both success and error info are valuable
- Effect: Caller can inspect both stdout and stderr
Service Layer Errors (Renderer)
Services in src/renderer/services/ wrap IPC calls and should never throw:
export const gitService = {
async isRepo(cwd: string): Promise<boolean> {
try {
const result = await window.maestro.git.isRepo(cwd);
return result;
} catch (error) {
console.error('Git isRepo error:', error);
return false;
}
},
async getStatus(cwd: string): Promise<GitStatus> {
try {
const result = await window.maestro.git.status(cwd);
return result;
} catch (error) {
console.error('Git status error:', error);
return { files: [] }; // Safe default
}
}
};
Pattern: Try-catch with safe default return
- Always catch errors from IPC calls
- Log error to console (or use logger utility)
- Return safe default value (empty array, empty string, false, etc.)
- Never throw - let the UI continue functioning
ProcessManager Errors (Main Process)
ProcessManager extends EventEmitter and uses events for runtime errors:
// Spawn errors - throw immediately
spawn(config: ProcessConfig): { pid: number; success: boolean } {
try {
// ... spawn logic
return { pid: ptyProcess.pid, success: true };
} catch (error) {
console.error('Failed to spawn process:', error);
throw error; // Propagate to IPC handler
}
}
// Runtime errors - emit events
ptyProcess.onExit(({ exitCode }) => {
this.emit('exit', sessionId, exitCode);
this.processes.delete(sessionId);
});
Pattern: Throw on spawn failure, emit events for runtime errors
- Spawn failures: Throw error (critical, can't continue)
- Process exit: Emit 'exit' event with exit code
- Process output: Emit 'data' event
- Let renderer handle process lifecycle errors
React Component Errors (Renderer)
React components use Error Boundaries to catch rendering errors:
// Wrap major UI sections in ErrorBoundary
<ErrorBoundary>
<SessionList {...props} />
</ErrorBoundary>
Pattern: Use Error Boundaries for component isolation
- Error boundaries defined in
src/renderer/components/ErrorBoundary.tsx - Wrap major UI sections (sidebar, main panel, right panel)
- Display fallback UI with error details and recovery options
- Prevents one component crash from taking down the entire app
Component-level error handling:
const handleFileLoad = async (path: string) => {
try {
const content = await window.maestro.fs.readFile(path);
setFileContent(content);
} catch (error) {
console.error('Failed to load file:', error);
setError('Failed to load file'); // Show user-friendly message
}
};
- Use try-catch for async operations
- Display user-friendly error messages in UI
- Don't crash the component - maintain partial functionality
Custom Hook Errors (Renderer)
Custom hooks should handle errors internally and expose error state:
const useFileExplorer = (activeSession, setActiveFocus) => {
const [error, setError] = useState<string | null>(null);
const loadFileTree = async (dirPath: string) => {
try {
setError(null);
const tree = await buildFileTree(dirPath);
setFileTree(tree);
} catch (err) {
console.error('Failed to load file tree:', err);
setError('Failed to load directory');
setFileTree([]); // Safe default
}
};
return { loadFileTree, error, /* ... */ };
};
Pattern: Internal try-catch with error state
- Expose error state so components can display messages
- Reset error state before retry
- Return safe defaults to keep UI functional
Utility Function Errors
Utility functions should document their error behavior clearly:
Option 1: Return default value (no throw)
// src/main/utils/execFile.ts
export async function execFileNoThrow(
command: string,
args: string[],
cwd?: string
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
// Never throws - returns exitCode !== 0 for failures
try {
// ... execution logic
} catch (error) {
return { stdout: '', stderr: String(error), exitCode: 1 };
}
}
Option 2: Throw errors (document clearly)
/**
* Load file tree recursively
* @throws {Error} If directory cannot be read
*/
export async function buildFileTree(dirPath: string): Promise<FileTreeNode[]> {
// Throws on filesystem errors - caller must handle
const entries = await fs.readdir(dirPath);
// ...
}
Summary of Patterns
| Layer | Pattern | Example |
|---|---|---|
| IPC Handlers | Throw critical failures, try-catch optional ops | git:isRepo returns false on error |
| Services | Never throw, return safe defaults | gitService.getStatus() returns { files: [] } |
| ProcessManager | Throw spawn failures, emit runtime events | emit('exit', sessionId, code) |
| React Components | Try-catch async ops, show UI errors | Display "Failed to load" message |
| Error Boundaries | Catch render errors, show fallback UI | Wrap major sections |
| Custom Hooks | Internal try-catch, expose error state | Return { error, ... } |
| Utilities | Document throw behavior clearly | JSDoc with @throws |
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
- ansi-to-html - ANSI escape code rendering for terminal output
- 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 preferencesmaestro-sessions.json- Session persistence (planned)maestro-groups.json- Session groups (planned)
Adding New Persistent Settings
To add a new setting that persists across sessions:
- Define state variable in App.tsx:
const [mySetting, setMySettingState] = useState<MyType>(defaultValue);
- Create wrapper function that persists:
const setMySetting = (value: MyType) => {
setMySettingState(value);
window.maestro.settings.set('mySetting', value);
};
- Load in useEffect:
// Inside the loadSettings useEffect
const savedMySetting = await window.maestro.settings.get('mySetting');
if (savedMySetting !== undefined) setMySettingState(savedMySetting);
- Pass wrapper to child components, not the direct setState
Current Persistent Settings:
llmProvider,modelSlug,apiKey- LLM configurationtunnelProvider,tunnelApiKey- Tunnel configurationdefaultAgent- Default AI agent selectiondefaultShell- Default terminal shell (zsh, bash, sh, fish, tcsh)fontFamily,fontSize,customFonts- UI font settingsenterToSendAI- Input behavior for AI mode (Enter vs Command+Enter to send, defaults to Command+Enter)enterToSendTerminal- Input behavior for Terminal mode (Enter vs Command+Enter to send, defaults to Enter)activeThemeId- Selected theme
Common Development Tasks
Adding a New UI Feature
- Plan the state - Determine if it's per-session or global
- Add state management - In App.tsx or component
- Create persistence - Use wrapper function pattern if global
- Implement UI - Follow Tailwind + theme color pattern
- Add keyboard shortcuts - Integrate with existing keyboard handler
- Test focus flow - Ensure Escape key navigation works
Adding a New Modal
- Create component in
src/renderer/components/ - Add state in App.tsx:
const [myModalOpen, setMyModalOpen] = useState(false) - Add Escape handler in keyboard shortcuts
- Use
fixed inset-0overlay withz-[50]or higher - Include close button and backdrop click handler
- Use
ref={(el) => el?.focus()}for immediate keyboard control
Adding Keyboard Shortcuts
Maestro has a configurable keyboard shortcut system defined in src/renderer/constants/shortcuts.ts.
To add a new shortcut:
- Add the shortcut definition to
DEFAULT_SHORTCUTSinsrc/renderer/constants/shortcuts.ts:
myShortcut: { id: 'myShortcut', label: 'My Action', keys: ['Meta', 'k'] },
- Add the handler in App.tsx keyboard event listener (around line 750):
else if (isShortcut(e, 'myShortcut')) {
e.preventDefault();
// Your handler code here
}
Supported modifiers:
Meta- Command (macOS) / Windows keyCtrl- Control keyAlt- Option (macOS) / Alt keyShift- Shift key
Arrow keys:
- Use
ArrowLeft,ArrowRight,ArrowUp,ArrowDownas key names - Example:
{ keys: ['Alt', 'Meta', 'ArrowLeft'] }for Opt+Cmd+←
Shortcut Customization UI:
- Users can customize shortcuts in Settings → Shortcuts tab
- Click a shortcut button to record new keys
- Press Escape to cancel recording (won't close the modal)
- ShortcutEditor component handles recording and validation
Working with File Tree
File tree structure is stored per-session as fileTree array of nodes:
{
name: string;
type: 'file' | 'folder';
path: string;
children?: FileTreeNode[];
}
Expanded folders tracked in session's fileExplorerExpanded: string[] as full paths.
Modifying Themes
- Find
THEMESconstant in App.tsx - Add new theme or modify existing one
- All color keys must be present (11 required colors)
- Test in both light and dark mode contexts
- Theme ID is stored in settings and persists across sessions
Adding to Settings Modal
- Add tab if needed in SettingsModal.tsx
- Create state in App.tsx with wrapper function
- Add to loadSettings useEffect
- Pass wrapper function (not setState) to SettingsModal props
- Add UI in appropriate tab section
Important File Locations
Main Process Entry Points
src/main/index.ts:81-109- IPC handler setupsrc/main/index.ts:272-282- Process event listenerssrc/main/process-manager.ts:30-116- Process spawning logic
Security-Critical Code
src/main/preload.ts:5- Context bridge API exposuresrc/main/process-manager.ts:75- Shell disabled for spawnsrc/main/utils/execFile.ts- Safe command execution wrapper
Configuration
package.json:26-86- electron-builder configtsconfig.json- Renderer TypeScript configtsconfig.main.json- Main process TypeScript configvite.config.mts- Vite bundler config
Debugging & Common Issues
Focus Not Working
If keyboard shortcuts aren't working:
- Check element has
tabIndex={0}ortabIndex={-1} - Add
outline-noneclass to hide focus ring - Use
ref={(el) => el?.focus()}oruseEffectto auto-focus - Check for
e.stopPropagation()blocking events
Settings Not Persisting
If settings don't save across sessions:
- Ensure you created a wrapper function with
window.maestro.settings.set() - Check the wrapper is passed to child components, not the direct setState
- Verify loading code exists in the
loadSettingsuseEffect - Use direct state setter (e.g.,
setMySettingState) in the loading code
Modal Escape Key Not Working
If Escape doesn't close a modal:
- Modal overlay needs
tabIndex={0} - Use
ref={(el) => el?.focus()}to focus on mount - Add
e.stopPropagation()in onKeyDown handler - Check z-index is higher than other modals
Theme Colors Not Applying
If colors appear hardcoded:
- Replace fixed colors with
style={{ color: theme.colors.textMain }} - Never use hardcoded hex colors for text/borders
- Use inline styles for theme colors, Tailwind for layout
- Check theme prop is being passed down correctly
Scroll Position Not Saving
Per-session scroll position:
- Container needs a ref:
useRef<HTMLDivElement>(null) - Add
onScrollhandler that updates session state - Add useEffect to restore scroll on session change
- Use
ref.current.scrollTopto get/set position
Running Tests
Currently no test suite implemented. When adding tests, use the test script in package.json.