feat: add custom GitHub CLI path setting for Auto Run worktree features

- Add ghPath setting to specify custom path to gh binary (e.g., /opt/homebrew/bin/gh)
- Update git:checkGhCli and git:createPR IPC handlers to accept optional ghPath
- Add UI in Settings > General for configuring the gh path
- Pass ghPath through BatchRunnerModal and useBatchProcessor for PR creation
- Include test infrastructure setup (vitest) and misc updates

Claude ID: 295a322c-974c-4b49-b31d-f7be18819332
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-07 13:13:44 -06:00
parent 1cd8c1447e
commit ac67385047
18 changed files with 2176 additions and 56 deletions

View File

@@ -75,6 +75,21 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Using version: $VERSION"
# Update package.json with the release version
# This ensures CLI and other components read the correct version
- name: Update package.json version
shell: bash
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = process.env.VERSION;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Updated package.json to version $VERSION"
- name: Install dependencies
run: npm ci

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Tests
do-wishlist.sh
do-housekeeping.sh
coverage/
# Dependencies
node_modules/

1849
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "maestro",
"version": "0.1.0",
"version": "0.7.0",
"description": "Multi-Instance AI Coding Console - Unified IDE for managing multiple AI coding assistants",
"main": "dist/main/index.js",
"author": {
@@ -33,7 +33,10 @@
"package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux",
"start": "electron .",
"clean": "rm -rf dist release node_modules/.vite",
"postinstall": "electron-rebuild -f -w node-pty"
"postinstall": "electron-rebuild -f -w node-pty",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"build": {
"appId": "com.maestro.app",
@@ -146,6 +149,8 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/canvas-confetti": "^1.9.0",
@@ -156,6 +161,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.16",
"canvas": "^3.2.0",
"concurrently": "^8.2.2",
@@ -163,6 +169,7 @@
"electron-builder": "^24.9.1",
"electron-rebuild": "^3.2.9",
"esbuild": "^0.24.2",
"jsdom": "^27.2.0",
"lucide-react": "^0.303.0",
"postcss": "^8.4.33",
"react": "^18.2.0",
@@ -170,6 +177,7 @@
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vite-plugin-electron": "^0.28.2"
"vite-plugin-electron": "^0.28.2",
"vitest": "^4.0.15"
}
}

92
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1,92 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock window.matchMedia for components that use media queries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock window.maestro API (Electron IPC bridge)
const mockMaestro = {
settings: {
get: vi.fn().mockResolvedValue(undefined),
set: vi.fn().mockResolvedValue(undefined),
getAll: vi.fn().mockResolvedValue({}),
},
sessions: {
get: vi.fn().mockResolvedValue([]),
save: vi.fn().mockResolvedValue(undefined),
},
groups: {
get: vi.fn().mockResolvedValue([]),
save: vi.fn().mockResolvedValue(undefined),
},
process: {
spawn: vi.fn().mockResolvedValue({ pid: 12345 }),
write: vi.fn().mockResolvedValue(undefined),
kill: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
onOutput: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {}),
},
git: {
status: vi.fn().mockResolvedValue({ files: [], branch: 'main' }),
diff: vi.fn().mockResolvedValue(''),
isRepo: vi.fn().mockResolvedValue(true),
numstat: vi.fn().mockResolvedValue([]),
},
fs: {
readDir: vi.fn().mockResolvedValue([]),
readFile: vi.fn().mockResolvedValue(''),
},
agents: {
detect: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
config: vi.fn().mockResolvedValue({}),
},
claude: {
listSessions: vi.fn().mockResolvedValue([]),
readSession: vi.fn().mockResolvedValue(null),
searchSessions: vi.fn().mockResolvedValue([]),
getGlobalStats: vi.fn().mockResolvedValue(null),
},
logger: {
log: vi.fn(),
error: vi.fn(),
},
dialog: {
selectFolder: vi.fn().mockResolvedValue(null),
},
shells: {
detect: vi.fn().mockResolvedValue([]),
},
};
Object.defineProperty(window, 'maestro', {
writable: true,
value: mockMaestro,
});

View File

@@ -3,6 +3,8 @@
// Command-line interface for Maestro
import { Command } from 'commander';
import * as fs from 'fs';
import * as path from 'path';
import { listGroups } from './commands/list-groups';
import { listAgents } from './commands/list-agents';
import { listPlaybooks } from './commands/list-playbooks';
@@ -10,12 +12,24 @@ import { showPlaybook } from './commands/show-playbook';
import { showAgent } from './commands/show-agent';
import { runPlaybook } from './commands/run-playbook';
// Read version from package.json at runtime
function getVersion(): string {
try {
// When bundled, __dirname points to dist/cli, so go up to project root
const packagePath = path.resolve(__dirname, '../../package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
return packageJson.version;
} catch {
return '0.0.0';
}
}
const program = new Command();
program
.name('maestro-cli')
.description('Command-line interface for Maestro')
.version('0.1.0');
.version(getVersion());
// List commands
const list = program.command('list').description('List resources');

View File

@@ -1472,8 +1472,12 @@ function setupIpcHandlers() {
});
// Create a PR from the worktree branch to a base branch
ipcMain.handle('git:createPR', async (_, worktreePath: string, baseBranch: string, title: string, body: string) => {
// ghPath parameter allows specifying custom path to gh binary
ipcMain.handle('git:createPR', async (_, worktreePath: string, baseBranch: string, title: string, body: string, ghPath?: string) => {
try {
// Use custom path if provided, otherwise fall back to 'gh' (expects it in PATH)
const ghCommand = ghPath || 'gh';
// First, push the current branch to origin
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', 'HEAD'], worktreePath);
if (pushResult.exitCode !== 0) {
@@ -1481,7 +1485,7 @@ function setupIpcHandlers() {
}
// Create the PR using gh CLI
const prResult = await execFileNoThrow('gh', [
const prResult = await execFileNoThrow(ghCommand, [
'pr', 'create',
'--base', baseBranch,
'--title', title,
@@ -1505,16 +1509,20 @@ function setupIpcHandlers() {
});
// Check if GitHub CLI (gh) is installed and authenticated
ipcMain.handle('git:checkGhCli', async () => {
// ghPath parameter allows specifying custom path to gh binary (e.g., /opt/homebrew/bin/gh)
ipcMain.handle('git:checkGhCli', async (_, ghPath?: string) => {
try {
// Use custom path if provided, otherwise fall back to 'gh' (expects it in PATH)
const ghCommand = ghPath || 'gh';
// Check if gh is installed by running gh --version
const versionResult = await execFileNoThrow('gh', ['--version']);
const versionResult = await execFileNoThrow(ghCommand, ['--version']);
if (versionResult.exitCode !== 0) {
return { installed: false, authenticated: false };
}
// Check if gh is authenticated by running gh auth status
const authResult = await execFileNoThrow('gh', ['auth', 'status']);
const authResult = await execFileNoThrow(ghCommand, ['auth', 'status']);
const authenticated = authResult.exitCode === 0;
return { installed: true, authenticated };

View File

@@ -255,8 +255,8 @@ contextBridge.exposeInMainWorld('maestro', {
hasUncommittedChanges: boolean;
error?: string;
}>,
createPR: (worktreePath: string, baseBranch: string, title: string, body: string) =>
ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body) as Promise<{
createPR: (worktreePath: string, baseBranch: string, title: string, body: string, ghPath?: string) =>
ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body, ghPath) as Promise<{
success: boolean;
prUrl?: string;
error?: string;
@@ -267,8 +267,8 @@ contextBridge.exposeInMainWorld('maestro', {
branch?: string;
error?: string;
}>,
checkGhCli: () =>
ipcRenderer.invoke('git:checkGhCli') as Promise<{
checkGhCli: (ghPath?: string) =>
ipcRenderer.invoke('git:checkGhCli', ghPath) as Promise<{
installed: boolean;
authenticated: boolean;
}>,
@@ -701,7 +701,7 @@ export interface MaestroAPI {
hasUncommittedChanges: boolean;
error?: string;
}>;
createPR: (worktreePath: string, baseBranch: string, title: string, body: string) => Promise<{
createPR: (worktreePath: string, baseBranch: string, title: string, body: string, ghPath?: string) => Promise<{
success: boolean;
prUrl?: string;
error?: string;
@@ -711,7 +711,7 @@ export interface MaestroAPI {
branch?: string;
error?: string;
}>;
checkGhCli: () => Promise<{
checkGhCli: (ghPath?: string) => Promise<{
installed: boolean;
authenticated: boolean;
}>;

View File

@@ -130,11 +130,13 @@ export default function MaestroConsole() {
apiKey, setApiKey,
defaultAgent, setDefaultAgent,
defaultShell, setDefaultShell,
ghPath, setGhPath,
fontFamily, setFontFamily,
fontSize, setFontSize,
activeThemeId, setActiveThemeId,
enterToSendAI, setEnterToSendAI,
enterToSendTerminal, setEnterToSendTerminal,
defaultSaveToHistory, setDefaultSaveToHistory,
leftSidebarWidth, setLeftSidebarWidth,
rightPanelWidth, setRightPanelWidth,
markdownRawMode, setMarkdownRawMode,
@@ -1703,7 +1705,7 @@ export default function MaestroConsole() {
if (s.id !== sessionId) return s;
// Use createTab helper
const result = createTab(s);
const result = createTab(s, { saveToHistory: defaultSaveToHistory });
newTabId = result.tab.id;
return result.session;
}));
@@ -1840,7 +1842,7 @@ export default function MaestroConsole() {
return unsubscribe;
}, []);
// Combine built-in slash commands with custom AI commands for autocomplete
// Combine built-in slash commands with custom AI commands AND Claude Code commands for autocomplete
// Filter out isSystemCommand entries since those are already in slashCommands with execute functions
const allSlashCommands = useMemo(() => {
const customCommandsAsSlash = customAICommands
@@ -1851,8 +1853,14 @@ export default function MaestroConsole() {
aiOnly: true, // Custom AI commands are only available in AI mode
prompt: cmd.prompt, // Include prompt for execution
}));
return [...slashCommands, ...customCommandsAsSlash];
}, [customAICommands]);
// Include Claude Code commands from the active session
const claudeCommands = (activeSession?.claudeCommands || []).map(cmd => ({
command: cmd.command,
description: cmd.description,
aiOnly: true, // Claude commands are only available in AI mode
}));
return [...slashCommands, ...customCommandsAsSlash, ...claudeCommands];
}, [customAICommands, activeSession?.claudeCommands]);
// Derive current input value and setter based on active session mode
// For AI mode: use active tab's inputValue (stored per-tab)
@@ -2775,7 +2783,8 @@ export default function MaestroConsole() {
claudeSessionId,
logs: messages,
name,
starred: isStarred
starred: isStarred,
saveToHistory: defaultSaveToHistory
});
console.log('[handleResumeSession] Created tab:', newTab.id, 'with', newTab.logs.length, 'logs, activeTabId:', updatedSession.activeTabId);
@@ -3432,7 +3441,7 @@ export default function MaestroConsole() {
}
if (ctx.isTabShortcut(e, 'newTab')) {
e.preventDefault();
const result = ctx.createTab(ctx.activeSession);
const result = ctx.createTab(ctx.activeSession, { saveToHistory: ctx.defaultSaveToHistory });
ctx.setSessions(prev => prev.map(s =>
s.id === ctx.activeSession!.id ? result.session : s
));
@@ -3838,7 +3847,8 @@ export default function MaestroConsole() {
inputValue: '',
stagedImages: [],
createdAt: Date.now(),
state: 'idle'
state: 'idle',
saveToHistory: defaultSaveToHistory
};
const newSession: Session = {
@@ -4039,7 +4049,7 @@ export default function MaestroConsole() {
processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen,
renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview,
gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups,
bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, markdownRawMode,
bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, markdownRawMode, defaultSaveToHistory,
setLeftSidebarOpen, setRightPanelOpen, addNewSession, deleteSession, setQuickActionInitialMode,
setQuickActionOpen, cycleSession, toggleInputMode, setShortcutsHelpOpen, setSettingsModalOpen,
setSettingsTab, setActiveRightTab, handleSetActiveRightTab, setActiveFocus, setBookmarksCollapsed, setGroups,
@@ -4231,6 +4241,7 @@ export default function MaestroConsole() {
if (result.success) {
const newFiles = result.files || [];
setAutoRunDocumentList(newFiles);
setAutoRunDocumentTree((result.tree as Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>) || []);
setAutoRunIsLoadingDocuments(false);
// Show flash notification with result
@@ -6329,7 +6340,7 @@ export default function MaestroConsole() {
if (activeSession) {
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
const result = createTab(s);
const result = createTab(s, { saveToHistory: defaultSaveToHistory });
return result.session;
}));
setActiveClaudeSessionId(null);
@@ -6526,7 +6537,7 @@ export default function MaestroConsole() {
// Use functional setState to compute from fresh state (avoids stale closure issues)
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
const result = createTab(s);
const result = createTab(s, { saveToHistory: defaultSaveToHistory });
return result.session;
}));
}}
@@ -6781,6 +6792,7 @@ export default function MaestroConsole() {
onRefreshDocuments={handleAutoRunRefresh}
sessionId={activeSession.id}
sessionCwd={activeSession.cwd}
ghPath={ghPath}
/>
)}
@@ -6877,10 +6889,14 @@ export default function MaestroConsole() {
setDefaultAgent={setDefaultAgent}
defaultShell={defaultShell}
setDefaultShell={setDefaultShell}
ghPath={ghPath}
setGhPath={setGhPath}
enterToSendAI={enterToSendAI}
setEnterToSendAI={setEnterToSendAI}
enterToSendTerminal={enterToSendTerminal}
setEnterToSendTerminal={setEnterToSendTerminal}
defaultSaveToHistory={defaultSaveToHistory}
setDefaultSaveToHistory={setDefaultSaveToHistory}
fontFamily={fontFamily}
setFontFamily={setFontFamily}
fontSize={fontSize}

View File

@@ -87,6 +87,8 @@ interface BatchRunnerModalProps {
sessionId: string;
// Session cwd for git worktree support
sessionCwd: string;
// Custom path to gh CLI binary (optional, for worktree features)
ghPath?: string;
}
// Helper function to count unchecked tasks in scratchpad content
@@ -129,7 +131,8 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
getDocumentTaskCount,
onRefreshDocuments,
sessionId,
sessionCwd
sessionCwd,
ghPath
} = props;
// Document list state
@@ -270,7 +273,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
if (isRepo) {
const [branchResult, ghResult] = await Promise.all([
window.maestro.git.branches(sessionCwd),
window.maestro.git.checkGhCli()
window.maestro.git.checkGhCli(ghPath || undefined)
]);
if (branchResult.branches && branchResult.branches.length > 0) {
@@ -295,7 +298,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
};
checkGitRepo();
}, [sessionCwd]);
}, [sessionCwd, ghPath]);
// Validate worktree path when it changes (debounced 500ms)
useEffect(() => {
@@ -544,7 +547,8 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
path: worktreePath,
branchName,
createPROnCompletion,
prTargetBranch
prTargetBranch,
ghPath: ghPath || undefined
};
}

View File

@@ -265,15 +265,16 @@ export function GitStatusWidget({ cwd, isGitRepo, theme, onViewDiff }: GitStatus
);
})}
</div>
<div
className="text-[10px] p-2 text-center border-t"
<button
onClick={onViewDiff}
className="text-[10px] p-2 text-center border-t w-full hover:bg-white/5 transition-colors cursor-pointer"
style={{
color: theme.colors.textDim,
borderColor: theme.colors.border
}}
>
Click to view full diff
</div>
View Full Diff
</button>
</div>
</>
)}

View File

@@ -123,21 +123,11 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
? (shellHistory.length > 0 ? shellHistory : legacyHistory)
: (aiHistory.length > 0 ? aiHistory : legacyHistory);
// Combine built-in slash commands with Claude-specific commands (for AI mode only)
// Memoize to avoid recreating arrays on every render
const allSlashCommands = useMemo(() => {
const claudeCommands: SlashCommand[] = (session.claudeCommands || []).map(cmd => ({
command: cmd.command,
description: cmd.description,
aiOnly: true, // Claude commands are only available in AI mode
}));
return [...slashCommands, ...claudeCommands];
}, [session.claudeCommands, slashCommands]);
// Use the slash commands passed from App.tsx (already includes custom + Claude commands)
// Memoize filtered slash commands to avoid filtering on every render
const inputValueLower = inputValue.toLowerCase();
const filteredSlashCommands = useMemo(() => {
return allSlashCommands.filter(cmd => {
return slashCommands.filter(cmd => {
// Check if command is only available in terminal mode
if (cmd.terminalOnly && !isTerminalMode) return false;
// Check if command is only available in AI mode
@@ -145,7 +135,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
// Check if command matches input
return cmd.command.toLowerCase().startsWith(inputValueLower);
});
}, [allSlashCommands, isTerminalMode, inputValueLower]);
}, [slashCommands, isTerminalMode, inputValueLower]);
// Ensure selectedSlashCommandIndex is valid for the filtered list
const safeSelectedIndex = Math.min(
@@ -184,7 +174,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
block: 'nearest',
});
}
}, [safeSelectedIndex, slashCommandOpen]);
}, [safeSelectedIndex, slashCommandOpen, selectedSlashCommandIndex]);
// Scroll selected tab completion item into view when index changes
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Volume2, Square, Cpu, Clock, Settings, Palette, Sparkles } from 'lucide-react';
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Volume2, Square, Cpu, Clock, Settings, Palette, Sparkles, History } from 'lucide-react';
import type { AgentConfig, Theme, Shortcut, ShellInfo, CustomAICommand } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -42,10 +42,14 @@ interface SettingsModalProps {
setMaxOutputLines: (lines: number) => void;
defaultShell: string;
setDefaultShell: (shell: string) => void;
ghPath: string;
setGhPath: (path: string) => void;
enterToSendAI: boolean;
setEnterToSendAI: (value: boolean) => void;
enterToSendTerminal: boolean;
setEnterToSendTerminal: (value: boolean) => void;
defaultSaveToHistory: boolean;
setDefaultSaveToHistory: (value: boolean) => void;
osNotificationsEnabled: boolean;
setOsNotificationsEnabled: (value: boolean) => void;
audioFeedbackEnabled: boolean;
@@ -1166,6 +1170,39 @@ export function SettingsModal(props: SettingsModalProps) {
</p>
</div>
{/* GitHub CLI Path */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
<Terminal className="w-3 h-3" />
GitHub CLI (gh) Path
</label>
<div className="p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
<label className="block text-xs opacity-60 mb-1">Custom Path (optional)</label>
<div className="flex gap-2">
<input
type="text"
value={props.ghPath}
onChange={(e) => props.setGhPath(e.target.value)}
placeholder="/opt/homebrew/bin/gh"
className="flex-1 p-1.5 rounded border bg-transparent outline-none text-xs font-mono"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
{props.ghPath && (
<button
onClick={() => props.setGhPath('')}
className="px-2 py-1 rounded text-xs"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
>
Clear
</button>
)}
</div>
<p className="text-xs opacity-40 mt-2">
Specify the full path to the <code className="px-1 py-0.5 rounded" style={{ backgroundColor: theme.colors.bgActivity }}>gh</code> binary if it's not in your PATH. Used for Auto Run worktree features.
</p>
</div>
</div>
{/* Input Behavior Settings */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
@@ -1222,6 +1259,34 @@ export function SettingsModal(props: SettingsModalProps) {
</p>
</div>
</div>
{/* Default History Toggle */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
<History className="w-3 h-3" />
Default History Toggle
</label>
<label
className="flex items-center gap-3 p-3 rounded border cursor-pointer hover:bg-opacity-10"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
<input
type="checkbox"
checked={props.defaultSaveToHistory}
onChange={(e) => props.setDefaultSaveToHistory(e.target.checked)}
className="w-4 h-4"
style={{ accentColor: theme.colors.accent }}
/>
<div className="flex-1">
<div className="font-medium" style={{ color: theme.colors.textMain }}>
Enable "History" by default for new tabs
</div>
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
When enabled, new AI tabs will have the "History" toggle on by default, saving a synopsis after each completion
</div>
</div>
</label>
</div>
</div>
)}

View File

@@ -901,12 +901,13 @@ ${docList}
const prTitle = `Auto Run: ${documents.length} document(s) processed`;
const prBody = generatePRBody(documents, totalCompletedTasks);
// Create the PR
// Create the PR (pass ghPath if configured)
const prResult = await window.maestro.git.createPR(
effectiveCwd,
baseBranch,
prTitle,
prBody
prBody,
worktree.ghPath
);
if (prResult.success) {

View File

@@ -69,6 +69,10 @@ export interface UseSettingsReturn {
defaultShell: string;
setDefaultShell: (value: string) => void;
// GitHub CLI settings
ghPath: string;
setGhPath: (value: string) => void;
// Font settings
fontFamily: string;
fontSize: number;
@@ -84,6 +88,8 @@ export interface UseSettingsReturn {
setEnterToSendAI: (value: boolean) => void;
enterToSendTerminal: boolean;
setEnterToSendTerminal: (value: boolean) => void;
defaultSaveToHistory: boolean;
setDefaultSaveToHistory: (value: boolean) => void;
leftSidebarWidth: number;
rightPanelWidth: number;
markdownRawMode: boolean;
@@ -155,6 +161,9 @@ export function useSettings(): UseSettingsReturn {
// Shell Config
const [defaultShell, setDefaultShellState] = useState('zsh');
// GitHub CLI Config
const [ghPath, setGhPathState] = useState('');
// Font Config
const [fontFamily, setFontFamilyState] = useState('Roboto Mono, Menlo, "Courier New", monospace');
const [fontSize, setFontSizeState] = useState(14);
@@ -164,6 +173,7 @@ export function useSettings(): UseSettingsReturn {
const [activeThemeId, setActiveThemeIdState] = useState<ThemeId>('dracula');
const [enterToSendAI, setEnterToSendAIState] = useState(false); // AI mode defaults to Command+Enter
const [enterToSendTerminal, setEnterToSendTerminalState] = useState(true); // Terminal defaults to Enter
const [defaultSaveToHistory, setDefaultSaveToHistoryState] = useState(false); // History toggle defaults to off
const [leftSidebarWidth, setLeftSidebarWidthState] = useState(256);
const [rightPanelWidth, setRightPanelWidthState] = useState(384);
const [markdownRawMode, setMarkdownRawModeState] = useState(false);
@@ -225,6 +235,11 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('defaultShell', value);
};
const setGhPath = (value: string) => {
setGhPathState(value);
window.maestro.settings.set('ghPath', value);
};
const setFontFamily = (value: string) => {
setFontFamilyState(value);
window.maestro.settings.set('fontFamily', value);
@@ -255,6 +270,11 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('enterToSendTerminal', value);
};
const setDefaultSaveToHistory = (value: boolean) => {
setDefaultSaveToHistoryState(value);
window.maestro.settings.set('defaultSaveToHistory', value);
};
const setLeftSidebarWidth = (width: number) => {
setLeftSidebarWidthState(width);
window.maestro.settings.set('leftSidebarWidth', width);
@@ -458,12 +478,14 @@ export function useSettings(): UseSettingsReturn {
const loadSettings = async () => {
const savedEnterToSendAI = await window.maestro.settings.get('enterToSendAI');
const savedEnterToSendTerminal = await window.maestro.settings.get('enterToSendTerminal');
const savedDefaultSaveToHistory = await window.maestro.settings.get('defaultSaveToHistory');
const savedLlmProvider = await window.maestro.settings.get('llmProvider');
const savedModelSlug = await window.maestro.settings.get('modelSlug');
const savedApiKey = await window.maestro.settings.get('apiKey');
const savedDefaultAgent = await window.maestro.settings.get('defaultAgent');
const savedDefaultShell = await window.maestro.settings.get('defaultShell');
const savedGhPath = await window.maestro.settings.get('ghPath');
const savedFontSize = await window.maestro.settings.get('fontSize');
const savedFontFamily = await window.maestro.settings.get('fontFamily');
const savedCustomFonts = await window.maestro.settings.get('customFonts');
@@ -487,12 +509,14 @@ export function useSettings(): UseSettingsReturn {
if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI);
if (savedEnterToSendTerminal !== undefined) setEnterToSendTerminalState(savedEnterToSendTerminal);
if (savedDefaultSaveToHistory !== undefined) setDefaultSaveToHistoryState(savedDefaultSaveToHistory);
if (savedLlmProvider !== undefined) setLlmProviderState(savedLlmProvider);
if (savedModelSlug !== undefined) setModelSlugState(savedModelSlug);
if (savedApiKey !== undefined) setApiKeyState(savedApiKey);
if (savedDefaultAgent !== undefined) setDefaultAgentState(savedDefaultAgent);
if (savedDefaultShell !== undefined) setDefaultShellState(savedDefaultShell);
if (savedGhPath !== undefined) setGhPathState(savedGhPath);
if (savedFontSize !== undefined) setFontSizeState(savedFontSize);
if (savedFontFamily !== undefined) setFontFamilyState(savedFontFamily);
if (savedCustomFonts !== undefined) setCustomFontsState(savedCustomFonts);
@@ -565,6 +589,8 @@ export function useSettings(): UseSettingsReturn {
setDefaultAgent,
defaultShell,
setDefaultShell,
ghPath,
setGhPath,
fontFamily,
fontSize,
customFonts,
@@ -577,6 +603,8 @@ export function useSettings(): UseSettingsReturn {
setEnterToSendAI,
enterToSendTerminal,
setEnterToSendTerminal,
defaultSaveToHistory,
setDefaultSaveToHistory,
leftSidebarWidth,
rightPanelWidth,
markdownRawMode,

View File

@@ -110,6 +110,7 @@ export interface WorktreeConfig {
branchName: string; // Branch name to use/create
createPROnCompletion: boolean; // Create PR when Auto Run finishes
prTargetBranch: string; // Target branch for the PR (e.g., 'main')
ghPath?: string; // Custom path to gh CLI binary (optional)
}
// Configuration for starting a batch run

View File

@@ -43,6 +43,7 @@ export interface CreateTabOptions {
name?: string | null; // User-defined name (null = show UUID octet)
starred?: boolean; // Whether session is starred
usageStats?: UsageStats; // Token usage stats
saveToHistory?: boolean; // Whether to save synopsis to history after completions
}
/**
@@ -80,7 +81,8 @@ export function createTab(session: Session, options: CreateTabOptions = {}): Cre
logs = [],
name = null,
starred = false,
usageStats
usageStats,
saveToHistory = false
} = options;
// Create the new tab with default values
@@ -94,7 +96,8 @@ export function createTab(session: Session, options: CreateTabOptions = {}): Cre
stagedImages: [],
usageStats,
createdAt: Date.now(),
state: 'idle'
state: 'idle',
saveToHistory
};
// Update the session with the new tab added and set as active

32
vitest.config.mts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', 'release'],
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json', 'html'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'node_modules',
'dist',
'src/__tests__/**',
'**/*.d.ts',
'src/main/preload.ts', // Electron preload script
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});