This guide provides step-by-step instructions for adding support for new CLI agent tools (like Aider, Continue, Cursor, etc.) to Pane's architecture.
- Overview
- Architecture Summary
- Implementation Steps
- File Structure
- Code Examples
- Testing Your Implementation
- Common Patterns
- Troubleshooting
Pane's architecture is designed to be extensible, allowing new CLI agents to be added with minimal modifications to existing code. The system follows a plugin-like pattern where each CLI tool is self-contained in its own directory structure.
-
New Files (90% of the work)
- Backend manager class extending
AbstractCliManager - Frontend React panel component
- Protocol handlers (if needed)
- Utility functions specific to your CLI
- Backend manager class extending
-
Minimal Modifications (10% of the work)
- Register your tool (~4 lines)
- Update UI factory (~3 lines)
- Add type definitions (~2 lines)
┌─────────────────────────────────────────────────┐
│ Pane Application │
├─────────────────────────────────────────────────┤
│ │
│ Frontend (React) Backend (Electron) │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ YourCliPanel │◄──IPC──►│ YourCliManager │ │
│ └──────────────┘ └──────────────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │CliPanelFactory│ │AbstractCliManager│ │
│ └──────────────┘ └──────────────────┘ │
│ │ │
└────────────────────────────────────┼────────────┘
▼
┌──────────────┐
│ Your CLI │
│ (External) │
└──────────────┘
Create a new directory for your CLI in main/src/services/panels/[your-cli]/ and implement the manager class:
// main/src/services/panels/aider/aiderManager.ts
import { AbstractCliManager } from '../cli/AbstractCliManager';
export class AiderManager extends AbstractCliManager {
// Implement required abstract methods
protected getCliToolName(): string {
return 'Aider';
}
protected async testCliAvailability(customPath?: string): Promise<{
available: boolean;
error?: string;
version?: string;
path?: string;
}> {
// Test if CLI is installed
}
protected buildCommandArgs(options: any): string[] {
// Build command-line arguments
}
protected async getCliExecutablePath(): Promise<string> {
// Return path to CLI executable
}
protected parseCliOutput(data: string, panelId: string, sessionId: string): Array<any> {
// Parse CLI output into events
}
}Create React components in frontend/src/components/panels/[your-cli]/:
// frontend/src/components/panels/aider/AiderPanel.tsx
import React from 'react';
export const AiderPanel: React.FC<{ panel: ToolPanel; isActive: boolean }> = ({ panel, isActive }) => {
// Implement your panel UI
return (
<div className="h-full flex flex-col">
{/* Your UI here */}
</div>
);
};
// Make it the default export for lazy loading
export default AiderPanel;Modify main/src/services/cliManagerFactory.ts to register your tool:
// In registerBuiltInTools() method around line 149-159:
private registerBuiltInTools(): void {
// Existing registrations...
this.registerClaudeTool();
// Add your registration
this.registerAiderTool(); // ADD THIS LINE
}
// Add a new registration method:
private registerAiderTool(): void {
const aiderDefinition: CliToolDefinition = {
id: 'aider',
name: 'Aider',
description: 'AI pair programming in your terminal',
// ... other configuration
managerFactory: (sessionManager, logger, configManager) =>
new AiderManager(sessionManager, logger, configManager)
};
this.registry.registerTool(aiderDefinition);
}Modify frontend/src/components/panels/cli/CliPanelFactory.tsx:
// Add lazy import at the top (around line 82-83):
const AiderPanel = lazy(() => import('../aider/AiderPanel'));
// Add case in renderPanel() switch statement (around line 104-118):
case 'aider':
return (
<Suspense fallback={<LoadingFallback cliToolId={cliToolId} />}>
<AiderPanel panel={panel} isActive={isActive} />
</Suspense>
);
// Update supported tools list (around line 149):
const supportedTools = ['claude', 'codex', 'aider']; // ADD 'aider'Modify shared/types/panels.ts:
// Update ToolPanelType union (line 10):
export type ToolPanelType = 'terminal' | 'claude' | 'codex' | 'aider' | 'diff' | 'editor' | 'logs' | 'dashboard';
// Add panel state interface if needed (optional):
export interface AiderPanelState extends BaseAIPanelState {
// Aider-specific state
editFormat?: 'whole' | 'diff' | 'patch';
autoCommits?: boolean;
}Your implementation should follow this structure:
Pane/
├── main/src/services/panels/aider/ # Backend implementation
│ ├── aiderManager.ts # Main manager class (required)
│ ├── aiderProtocol.ts # Protocol handling (if needed)
│ ├── aiderMessageParser.ts # Output parsing utilities
│ └── types.ts # TypeScript interfaces
│
├── frontend/src/components/panels/aider/ # Frontend implementation
│ ├── AiderPanel.tsx # Main panel component (required)
│ ├── AiderOutput.tsx # Output display component
│ ├── AiderInput.tsx # Input component
│ ├── AiderToolCalls.tsx # Tool call visualization
│ └── styles.css # Component-specific styles
│
└── shared/types/ # Shared type definitions
└── aiderTypes.ts # Aider-specific types (optional)
For a basic CLI that just needs stdin/stdout:
export class SimpleCliManager extends AbstractCliManager {
protected getCliToolName(): string {
return 'SimpleCLI';
}
protected async testCliAvailability(customPath?: string): Promise<any> {
try {
const { execSync } = require('child_process');
const cmd = customPath || 'simple-cli';
const version = execSync(`${cmd} --version`, { encoding: 'utf8' });
return { available: true, version: version.trim(), path: cmd };
} catch {
return { available: false, error: 'CLI not found' };
}
}
protected buildCommandArgs(options: any): string[] {
return [options.prompt]; // Simple args
}
protected async getCliExecutablePath(): Promise<string> {
return 'simple-cli';
}
protected parseCliOutput(data: string, panelId: string, sessionId: string): Array<any> {
return [{
panelId,
sessionId,
type: 'stdout',
data,
timestamp: new Date()
}];
}
}For CLIs using JSON-RPC or similar protocols:
export class ProtocolCliManager extends AbstractCliManager {
private protocol: ProtocolHandler;
constructor(sessionManager: any, logger?: Logger, configManager?: ConfigManager) {
super(sessionManager, logger, configManager);
this.protocol = new ProtocolHandler();
}
protected parseCliOutput(data: string, panelId: string, sessionId: string): Array<any> {
try {
const message = JSON.parse(data);
return this.protocol.handleMessage(message, panelId, sessionId);
} catch {
// Fallback to plain text
return super.parseCliOutput(data, panelId, sessionId);
}
}
async sendInput(panelId: string, input: string): Promise<void> {
const message = this.protocol.createInputMessage(input);
const process = this.processes.get(panelId);
process?.write(JSON.stringify(message) + '\n');
}
}import React, { useEffect, useState } from 'react';
import { Terminal } from 'lucide-react';
export const MinimalPanel: React.FC<{ panel: any; isActive: boolean }> = ({ panel, isActive }) => {
const [output, setOutput] = useState<string[]>([]);
const [input, setInput] = useState('');
useEffect(() => {
// Listen for output
const handleOutput = (event: any, data: any) => {
if (data.panelId === panel.id) {
setOutput(prev => [...prev, data.data]);
}
};
window.electron.on('panel:output', handleOutput);
// Initialize panel when active
if (isActive && !panel.state.isInitialized) {
window.electron.invoke('panels:initialize', panel.id);
}
return () => {
window.electron.off('panel:output', handleOutput);
};
}, [panel.id, isActive]);
const handleSubmit = async () => {
if (input.trim()) {
await window.electron.invoke('panels:send-input', panel.id, input);
setInput('');
}
};
return (
<div className="h-full flex flex-col bg-surface-primary">
<div className="flex items-center gap-2 p-4 border-b border-border-primary">
<Terminal className="w-4 h-4" />
<span className="font-medium">My CLI Tool</span>
</div>
<div className="flex-1 overflow-auto p-4 font-mono text-sm">
{output.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
<div className="p-4 border-t border-border-primary">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSubmit()}
placeholder="Enter command..."
className="w-full px-3 py-2 bg-surface-secondary rounded"
/>
</div>
</div>
);
};
export default MinimalPanel;# In the Electron dev console or a test file
const factory = require('./main/src/services/cliManagerFactory');
const manager = factory.createManager('aider', { sessionManager });
const result = await manager.testCliAvailability();
console.log('Aider available:', result);// Test spawning a process
await manager.startPanel(
'test-panel-1',
'test-session-1',
'/path/to/project',
'Test prompt'
);- Run Pane in development mode
- Create a new session
- Add a tool panel and select your CLI
- Verify the panel renders and accepts input
If your CLI requires authentication:
protected async initializeCliEnvironment(options: any): Promise<Record<string, string>> {
const apiKey = this.configManager?.getConfig()?.aiderApiKey;
if (!apiKey) {
throw new Error('API key required. Please configure in Settings.');
}
return {
AIDER_API_KEY: apiKey,
...process.env
};
}For CLIs that support resuming conversations:
async continuePanel(panelId: string, sessionId: string, conversationHistory: any[]): Promise<void> {
const resumeArgs = this.buildResumeArgs(conversationHistory);
const options = {
panelId,
sessionId,
additionalArgs: resumeArgs
};
return this.spawnCliProcess(options);
}
private buildResumeArgs(history: any[]): string[] {
// Convert history to CLI-specific format
return ['--resume', JSON.stringify(history)];
}For CLIs that perform file operations:
protected parseCliOutput(data: string, panelId: string, sessionId: string): Array<any> {
const events = [];
// Detect tool calls
if (this.isToolCall(data)) {
const toolCall = this.parseToolCall(data);
events.push({
type: 'tool_call',
tool: toolCall.name,
args: toolCall.args
});
// Emit file change event if applicable
if (toolCall.name === 'write_file') {
this.emit('files:changed', {
panelId,
files: [toolCall.args.path]
});
}
}
return events;
}-
CLI not found
- Ensure the CLI is installed and in PATH
- Check
getCliExecutablePath()returns correct path - Verify
testCliAvailability()handles errors properly
-
Panel not rendering
- Verify lazy import path is correct
- Check panel is registered in
CliPanelFactory - Ensure component has default export
-
No output displayed
- Check
parseCliOutput()returns proper event format - Verify IPC event listeners are set up
- Ensure panel ID matches in events
- Check
-
Input not working
- Verify process is running (
this.processes.get(panelId)) - Check input formatting matches CLI expectations
- Ensure write permissions on process stdin
- Verify process is running (
-
Enable verbose logging:
this.logger?.debug('[YourCli] Spawning process:', { command, args });
-
Add console output in frontend:
console.log('[YourCliPanel] Received output:', data);
-
Use Chrome DevTools:
- Open DevTools in Electron (Cmd/Ctrl+Shift+I)
- Check Network tab for IPC calls
- Monitor Console for errors
-
Test CLI directly:
# Test your CLI works outside Pane your-cli --version echo "test prompt" | your-cli
- Error Handling: Always provide clear error messages
- Resource Cleanup: Implement proper cleanup in
cleanupCliResources() - State Persistence: Store CLI-specific state for session resumption
- User Feedback: Show loading states and error messages in UI
- Configuration: Allow users to customize CLI behavior via settings
- Documentation: Comment complex parsing or protocol logic
- Type Safety: Use TypeScript interfaces for all data structures
- Testing: Write unit tests for parsing and protocol logic
After implementing your CLI:
- Test thoroughly in development mode
- Add configuration options to Settings UI
- Update Help documentation
- Consider contributing back to Pane repository
- Share your implementation with the community
For more details on specific aspects: