mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- INPUT missing—share release notes, commits, or changelog for summary please! 📌
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ Auto\ Run\ Docs/
|
||||
Work\ Trees/
|
||||
community-data/
|
||||
.mcp.json
|
||||
specs/
|
||||
|
||||
# Tests
|
||||
coverage/
|
||||
|
||||
@@ -587,6 +587,280 @@ describe.skipIf(SKIP_INTEGRATION)('Provider Integration Tests', () => {
|
||||
expect(hasInputFormatWithoutImages, `${provider.name} should not include --input-format without images`).toBe(false);
|
||||
});
|
||||
|
||||
it('should separate thinking/streaming content from final response', async () => {
|
||||
// This test verifies that streaming text events (which may contain thinking/reasoning)
|
||||
// are properly separated from the final response text.
|
||||
//
|
||||
// For thinking models (Claude 3.7+, OpenAI o-series, OpenCode with reasoning):
|
||||
// - Streaming text events with isPartial=true contain reasoning/thinking
|
||||
// - Final result message contains the clean response
|
||||
//
|
||||
// This validates the fix in process-manager.ts that stopped emitting partial
|
||||
// text to 'data' channel (which was showing thinking in main output).
|
||||
|
||||
if (!providerAvailable) {
|
||||
console.log(`Skipping: ${provider.name} not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a prompt that might trigger reasoning/thinking
|
||||
const prompt = 'What is 17 * 23? Show only the final answer as a number.';
|
||||
const args = provider.buildInitialArgs(prompt);
|
||||
|
||||
console.log(`\n🧠 Testing thinking/streaming separation for ${provider.name}`);
|
||||
console.log(`🚀 Running: ${provider.command} ${args.join(' ')}`);
|
||||
|
||||
const result = await runProvider(provider, args);
|
||||
|
||||
console.log(`📤 Exit code: ${result.exitCode}`);
|
||||
|
||||
// Parse all the different event types from the output
|
||||
const events = {
|
||||
textPartial: [] as string[], // Streaming text chunks
|
||||
textFinal: [] as string[], // Final text/result
|
||||
thinking: [] as string[], // Explicit thinking blocks
|
||||
result: [] as string[], // Result messages
|
||||
};
|
||||
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
|
||||
// Claude Code events
|
||||
if (json.type === 'assistant' && json.message?.content) {
|
||||
const content = json.message.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'thinking' && block.thinking) {
|
||||
events.thinking.push(block.thinking);
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
events.textFinal.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (json.type === 'result' && json.result) {
|
||||
events.result.push(json.result);
|
||||
}
|
||||
|
||||
// OpenCode events
|
||||
if (json.type === 'text' && json.part?.text) {
|
||||
events.textPartial.push(json.part.text);
|
||||
}
|
||||
if (json.type === 'step_finish' && json.part?.reason === 'stop') {
|
||||
// OpenCode final - accumulated text becomes result
|
||||
events.result.push('step_finish:stop');
|
||||
}
|
||||
|
||||
// Codex events
|
||||
if (json.type === 'item.completed' && json.item?.type === 'agent_message') {
|
||||
if (json.item.text) {
|
||||
events.textFinal.push(json.item.text);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
}
|
||||
|
||||
console.log(`📊 Event counts:`);
|
||||
console.log(` - textPartial (streaming): ${events.textPartial.length}`);
|
||||
console.log(` - textFinal: ${events.textFinal.length}`);
|
||||
console.log(` - thinking blocks: ${events.thinking.length}`);
|
||||
console.log(` - result messages: ${events.result.length}`);
|
||||
|
||||
// Verify we got a response
|
||||
expect(
|
||||
provider.isSuccessful(result.stdout, result.exitCode),
|
||||
`${provider.name} should complete successfully`
|
||||
).toBe(true);
|
||||
|
||||
const response = provider.parseResponse(result.stdout);
|
||||
console.log(`💬 Parsed response: ${response?.substring(0, 200)}`);
|
||||
expect(response, `${provider.name} should return a response`).toBeTruthy();
|
||||
|
||||
// The response should contain the answer (391)
|
||||
expect(
|
||||
response?.includes('391'),
|
||||
`${provider.name} should calculate 17 * 23 = 391. Got: "${response}"`
|
||||
).toBe(true);
|
||||
|
||||
// If there are thinking blocks, verify they're not mixed into the final response
|
||||
if (events.thinking.length > 0) {
|
||||
console.log(`🧠 Found ${events.thinking.length} thinking blocks`);
|
||||
// Thinking content should NOT appear in the final result
|
||||
for (const thinkingText of events.thinking) {
|
||||
const thinkingPreview = thinkingText.substring(0, 100);
|
||||
// Final response should not literally contain the thinking text
|
||||
// (unless it's a very short common phrase)
|
||||
if (thinkingText.length > 50) {
|
||||
expect(
|
||||
!response?.includes(thinkingText),
|
||||
`Final response should not contain thinking block verbatim: "${thinkingPreview}..."`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, PROVIDER_TIMEOUT);
|
||||
|
||||
it('should generate valid synopsis for history', async () => {
|
||||
// This test verifies that synopsis generation works correctly for history entries.
|
||||
// It tests the flow: task completion → synopsis request → parseable response
|
||||
//
|
||||
// This validates:
|
||||
// 1. Session resume works for synopsis requests
|
||||
// 2. Response format matches expected **Summary:**/**Details:** structure
|
||||
// 3. parseSynopsis correctly extracts summary (no template placeholders)
|
||||
|
||||
if (!providerAvailable) {
|
||||
console.log(`Skipping: ${provider.name} not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
// First, do a task that we can summarize
|
||||
const taskPrompt = 'Create a simple function called "add" that adds two numbers. Just describe it, don\'t write code.';
|
||||
const taskArgs = provider.buildInitialArgs(taskPrompt);
|
||||
|
||||
console.log(`\n📝 Testing synopsis generation for ${provider.name}`);
|
||||
console.log(`🚀 Task: ${provider.command} ${taskArgs.join(' ')}`);
|
||||
|
||||
const taskResult = await runProvider(provider, taskArgs);
|
||||
|
||||
expect(
|
||||
provider.isSuccessful(taskResult.stdout, taskResult.exitCode),
|
||||
`${provider.name} task should succeed`
|
||||
).toBe(true);
|
||||
|
||||
const sessionId = provider.parseSessionId(taskResult.stdout);
|
||||
console.log(`📋 Session ID: ${sessionId}`);
|
||||
expect(sessionId, `${provider.name} should return session ID`).toBeTruthy();
|
||||
|
||||
// Now request a synopsis (this is what happens when a task completes)
|
||||
const synopsisPrompt = `Provide a brief synopsis of what you just accomplished in this task using this exact format:
|
||||
|
||||
**Summary:** [1-2 sentences describing the key outcome]
|
||||
|
||||
**Details:** [A paragraph with more specifics about what was done]
|
||||
|
||||
Rules:
|
||||
- Be specific about what was actually accomplished.
|
||||
- Focus only on meaningful work that was done.`;
|
||||
|
||||
const synopsisArgs = provider.buildResumeArgs(sessionId!, synopsisPrompt);
|
||||
|
||||
console.log(`🔄 Synopsis: ${provider.command} ${synopsisArgs.join(' ')}`);
|
||||
|
||||
const synopsisResult = await runProvider(provider, synopsisArgs);
|
||||
|
||||
console.log(`📤 Exit code: ${synopsisResult.exitCode}`);
|
||||
|
||||
expect(
|
||||
provider.isSuccessful(synopsisResult.stdout, synopsisResult.exitCode),
|
||||
`${provider.name} synopsis should succeed`
|
||||
).toBe(true);
|
||||
|
||||
const synopsisResponse = provider.parseResponse(synopsisResult.stdout);
|
||||
console.log(`💬 Synopsis response:\n${synopsisResponse?.substring(0, 500)}`);
|
||||
expect(synopsisResponse, `${provider.name} should return synopsis`).toBeTruthy();
|
||||
|
||||
// Import and use the actual parseSynopsis function
|
||||
const { parseSynopsis } = await import('../../../shared/synopsis');
|
||||
const parsed = parseSynopsis(synopsisResponse!);
|
||||
|
||||
console.log(`📊 Parsed synopsis:`);
|
||||
console.log(` - shortSummary: ${parsed.shortSummary.substring(0, 100)}`);
|
||||
console.log(` - fullSynopsis length: ${parsed.fullSynopsis.length}`);
|
||||
|
||||
// Verify the summary is NOT a template placeholder
|
||||
const templatePlaceholders = [
|
||||
'[1-2 sentences',
|
||||
'[A paragraph',
|
||||
'... (1-2 sentences)',
|
||||
'... then blank line',
|
||||
];
|
||||
|
||||
for (const placeholder of templatePlaceholders) {
|
||||
expect(
|
||||
!parsed.shortSummary.includes(placeholder),
|
||||
`${provider.name} summary should not contain template placeholder "${placeholder}". Got: "${parsed.shortSummary}"`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Summary should be meaningful (not just default fallback)
|
||||
expect(
|
||||
parsed.shortSummary !== 'Task completed',
|
||||
`${provider.name} should generate actual summary, not just fallback "Task completed"`
|
||||
).toBe(true);
|
||||
|
||||
// Summary should mention something related to the task
|
||||
const summaryLower = parsed.shortSummary.toLowerCase();
|
||||
const hasRelevantContent =
|
||||
summaryLower.includes('add') ||
|
||||
summaryLower.includes('function') ||
|
||||
summaryLower.includes('number') ||
|
||||
summaryLower.includes('describ');
|
||||
|
||||
expect(
|
||||
hasRelevantContent,
|
||||
`${provider.name} summary should be relevant to the task. Got: "${parsed.shortSummary}"`
|
||||
).toBe(true);
|
||||
}, PROVIDER_TIMEOUT * 2);
|
||||
|
||||
it('should respect read-only mode flag', async () => {
|
||||
// This test verifies that read-only mode is properly supported.
|
||||
// Read-only mode should prevent the agent from making changes.
|
||||
//
|
||||
// For agents that support read-only:
|
||||
// - Claude Code: uses --plan flag
|
||||
// - Other agents may not support this yet
|
||||
|
||||
if (!providerAvailable) {
|
||||
console.log(`Skipping: ${provider.name} not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilities = getAgentCapabilities(provider.agentId);
|
||||
if (!capabilities.supportsReadOnlyMode) {
|
||||
console.log(`Skipping: ${provider.name} does not support read-only mode`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build args with read-only flag
|
||||
// This mirrors how agent-detector.ts builds readOnlyArgs
|
||||
let readOnlyArgs: string[];
|
||||
if (provider.agentId === 'claude-code') {
|
||||
readOnlyArgs = [
|
||||
'--print',
|
||||
'--verbose',
|
||||
'--output-format', 'stream-json',
|
||||
'--dangerously-skip-permissions',
|
||||
'--plan', // Read-only flag for Claude Code
|
||||
'--',
|
||||
'What files are in this directory? Just list them briefly.',
|
||||
];
|
||||
} else {
|
||||
// Other providers would have their own read-only args
|
||||
console.log(`⚠️ Read-only args not configured for ${provider.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n🔒 Testing read-only mode for ${provider.name}`);
|
||||
console.log(`🚀 Running: ${provider.command} ${readOnlyArgs.join(' ')}`);
|
||||
|
||||
const result = await runProvider(provider, readOnlyArgs);
|
||||
|
||||
console.log(`📤 Exit code: ${result.exitCode}`);
|
||||
console.log(`📤 Stdout (first 500 chars): ${result.stdout.substring(0, 500)}`);
|
||||
|
||||
expect(
|
||||
provider.isSuccessful(result.stdout, result.exitCode),
|
||||
`${provider.name} read-only mode should succeed`
|
||||
).toBe(true);
|
||||
|
||||
const response = provider.parseResponse(result.stdout);
|
||||
console.log(`💬 Response: ${response?.substring(0, 200)}`);
|
||||
expect(response, `${provider.name} should return a response in read-only mode`).toBeTruthy();
|
||||
}, PROVIDER_TIMEOUT);
|
||||
|
||||
it('should process image and identify text content', async () => {
|
||||
// This test verifies that images are properly passed to the provider and processed.
|
||||
// It uses a test image containing the word "Maestro" and asks the provider to
|
||||
|
||||
@@ -99,7 +99,6 @@ import { THEMES } from './constants/themes';
|
||||
import { generateId } from './utils/ids';
|
||||
import { getContextColor } from './utils/theme';
|
||||
import { setActiveTab, createTab, closeTab, reopenClosedTab, getActiveTab, getWriteModeTab, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, getInitialRenameValue } from './utils/tabHelpers';
|
||||
import { TAB_SHORTCUTS } from './constants/shortcuts';
|
||||
import { shouldOpenExternally, getAllFolderPaths, flattenTree } from './utils/fileExplorer';
|
||||
import type { FileNode } from './types/fileTree';
|
||||
import { substituteTemplateVariables } from './utils/templateVariables';
|
||||
@@ -207,6 +206,7 @@ export default function MaestroConsole() {
|
||||
checkForUpdatesOnStartup, setCheckForUpdatesOnStartup,
|
||||
crashReportingEnabled, setCrashReportingEnabled,
|
||||
shortcuts, setShortcuts,
|
||||
tabShortcuts, setTabShortcuts,
|
||||
customAICommands, setCustomAICommands,
|
||||
globalStats, updateGlobalStats,
|
||||
autoRunStats, recordAutoRunComplete, updateAutoRunProgress, acknowledgeBadge, getUnacknowledgedBadgeLevel,
|
||||
@@ -218,7 +218,7 @@ export default function MaestroConsole() {
|
||||
} = settings;
|
||||
|
||||
// --- KEYBOARD SHORTCUT HELPERS ---
|
||||
const { isShortcut, isTabShortcut } = useKeyboardShortcutHelpers({ shortcuts });
|
||||
const { isShortcut, isTabShortcut } = useKeyboardShortcutHelpers({ shortcuts, tabShortcuts });
|
||||
|
||||
// --- STATE ---
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
@@ -6654,7 +6654,7 @@ export default function MaestroConsole() {
|
||||
setGitDiffPreview={setGitDiffPreview}
|
||||
setGitLogOpen={setGitLogOpen}
|
||||
isAiMode={activeSession?.inputMode === 'ai'}
|
||||
tabShortcuts={TAB_SHORTCUTS}
|
||||
tabShortcuts={tabShortcuts}
|
||||
onRenameTab={() => {
|
||||
if (activeSession?.inputMode === 'ai' && activeSession.activeTabId) {
|
||||
const activeTab = activeSession.aiTabs?.find(t => t.id === activeSession.activeTabId);
|
||||
@@ -6822,6 +6822,7 @@ export default function MaestroConsole() {
|
||||
<ShortcutsHelpModal
|
||||
theme={theme}
|
||||
shortcuts={shortcuts}
|
||||
tabShortcuts={tabShortcuts}
|
||||
onClose={() => setShortcutsHelpOpen(false)}
|
||||
hasNoAgents={hasNoAgents}
|
||||
/>
|
||||
@@ -8468,7 +8469,7 @@ export default function MaestroConsole() {
|
||||
activeTabId={activeSession.activeTabId}
|
||||
projectRoot={activeSession.projectRoot}
|
||||
agentId={activeSession.toolType}
|
||||
shortcut={TAB_SHORTCUTS.tabSwitcher}
|
||||
shortcut={tabShortcuts.tabSwitcher}
|
||||
onTabSelect={(tabId) => {
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id ? { ...s, activeTabId: tabId } : s
|
||||
@@ -8665,6 +8666,8 @@ export default function MaestroConsole() {
|
||||
setApiKey={setApiKey}
|
||||
shortcuts={shortcuts}
|
||||
setShortcuts={setShortcuts}
|
||||
tabShortcuts={tabShortcuts}
|
||||
setTabShortcuts={setTabShortcuts}
|
||||
defaultShell={defaultShell}
|
||||
setDefaultShell={setDefaultShell}
|
||||
customShellPath={customShellPath}
|
||||
|
||||
@@ -169,6 +169,8 @@ interface SettingsModalProps {
|
||||
setApiKey: (key: string) => void;
|
||||
shortcuts: Record<string, Shortcut>;
|
||||
setShortcuts: (shortcuts: Record<string, Shortcut>) => void;
|
||||
tabShortcuts: Record<string, Shortcut>;
|
||||
setTabShortcuts: (shortcuts: Record<string, Shortcut>) => void;
|
||||
fontFamily: string;
|
||||
setFontFamily: (font: string) => void;
|
||||
fontSize: number;
|
||||
@@ -536,7 +538,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecord = (e: React.KeyboardEvent, actionId: string) => {
|
||||
const handleRecord = (e: React.KeyboardEvent, actionId: string, isTabShortcut: boolean = false) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -568,10 +570,18 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
}
|
||||
}
|
||||
keys.push(mainKey);
|
||||
|
||||
if (isTabShortcut) {
|
||||
props.setTabShortcuts({
|
||||
...props.tabShortcuts,
|
||||
[actionId]: { ...props.tabShortcuts[actionId], keys }
|
||||
});
|
||||
} else {
|
||||
props.setShortcuts({
|
||||
...props.shortcuts,
|
||||
[actionId]: { ...props.shortcuts[actionId], keys }
|
||||
});
|
||||
}
|
||||
setRecordingId(null);
|
||||
};
|
||||
|
||||
@@ -1390,11 +1400,47 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
)}
|
||||
|
||||
{activeTab === 'shortcuts' && (() => {
|
||||
const totalShortcuts = Object.values(props.shortcuts).length;
|
||||
const filteredShortcuts = Object.values(props.shortcuts)
|
||||
.filter((sc: Shortcut) => sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()));
|
||||
const allShortcuts = [
|
||||
...Object.values(props.shortcuts).map(sc => ({ ...sc, isTabShortcut: false })),
|
||||
...Object.values(props.tabShortcuts).map(sc => ({ ...sc, isTabShortcut: true })),
|
||||
];
|
||||
const totalShortcuts = allShortcuts.length;
|
||||
const filteredShortcuts = allShortcuts
|
||||
.filter((sc) => sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()));
|
||||
const filteredCount = filteredShortcuts.length;
|
||||
|
||||
// Group shortcuts by category
|
||||
const generalShortcuts = filteredShortcuts.filter(sc => !sc.isTabShortcut);
|
||||
const tabShortcutsFiltered = filteredShortcuts.filter(sc => sc.isTabShortcut);
|
||||
|
||||
const renderShortcutItem = (sc: Shortcut & { isTabShortcut: boolean }) => (
|
||||
<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={(e) => {
|
||||
setRecordingId(sc.id);
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (recordingId === sc.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRecord(e, sc.id, sc.isTabShortcut);
|
||||
}
|
||||
}}
|
||||
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,
|
||||
'--tw-ring-color': theme.colors.accent
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{recordingId === sc.id ? 'Press keys...' : formatShortcutKeys(sc.keys)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ minHeight: '450px' }}>
|
||||
{props.hasNoAgents && (
|
||||
@@ -1419,37 +1465,30 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
<p className="text-xs opacity-50 mb-3" style={{ color: theme.colors.textDim }}>
|
||||
Not all shortcuts can be modified. Press <kbd className="px-1.5 py-0.5 rounded font-mono" style={{ backgroundColor: theme.colors.bgActivity }}>⌘/</kbd> from the main interface to view the full list of keyboard shortcuts.
|
||||
</p>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto pr-2 scrollbar-thin">
|
||||
{filteredShortcuts.map((sc: Shortcut) => (
|
||||
<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={(e) => {
|
||||
setRecordingId(sc.id);
|
||||
// Auto-focus the button so it immediately starts listening for keys
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (recordingId === sc.id) {
|
||||
// Prevent default in capture phase to catch all key combinations
|
||||
// (including browser/system shortcuts like Option+Arrow)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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,
|
||||
'--tw-ring-color': theme.colors.accent
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{recordingId === sc.id ? 'Press keys...' : formatShortcutKeys(sc.keys)}
|
||||
</button>
|
||||
<div className="space-y-4 flex-1 overflow-y-auto pr-2 scrollbar-thin">
|
||||
{/* General Shortcuts Section */}
|
||||
{generalShortcuts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase mb-2 px-1" style={{ color: theme.colors.textDim }}>
|
||||
General
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{generalShortcuts.map(renderShortcutItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Tab Shortcuts Section */}
|
||||
{tabShortcutsFiltered.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase mb-2 px-1" style={{ color: theme.colors.textDim }}>
|
||||
AI Tab
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{tabShortcutsFiltered.map(renderShortcutItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,27 +3,28 @@ import { X } from 'lucide-react';
|
||||
import type { Theme, Shortcut } from '../types';
|
||||
import { fuzzyMatch } from '../utils/search';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { TAB_SHORTCUTS, FIXED_SHORTCUTS } from '../constants/shortcuts';
|
||||
import { FIXED_SHORTCUTS } from '../constants/shortcuts';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import { Modal } from './ui/Modal';
|
||||
|
||||
interface ShortcutsHelpModalProps {
|
||||
theme: Theme;
|
||||
shortcuts: Record<string, Shortcut>;
|
||||
tabShortcuts: Record<string, Shortcut>;
|
||||
onClose: () => void;
|
||||
hasNoAgents?: boolean;
|
||||
}
|
||||
|
||||
export function ShortcutsHelpModal({ theme, shortcuts, onClose, hasNoAgents }: ShortcutsHelpModalProps) {
|
||||
export function ShortcutsHelpModal({ theme, shortcuts, tabShortcuts, onClose, hasNoAgents }: ShortcutsHelpModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Combine all shortcuts for display: editable + tab + fixed (non-editable)
|
||||
const allShortcuts = useMemo(() => ({
|
||||
...shortcuts,
|
||||
...TAB_SHORTCUTS,
|
||||
...tabShortcuts,
|
||||
...FIXED_SHORTCUTS,
|
||||
}), [shortcuts]);
|
||||
}), [shortcuts, tabShortcuts]);
|
||||
|
||||
const totalShortcuts = Object.values(allShortcuts).length;
|
||||
const filteredShortcuts = Object.values(allShortcuts)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { Shortcut } from '../types';
|
||||
import { TAB_SHORTCUTS } from '../constants/shortcuts';
|
||||
|
||||
/**
|
||||
* Dependencies for useKeyboardShortcutHelpers hook
|
||||
*/
|
||||
export interface UseKeyboardShortcutHelpersDeps {
|
||||
/** User-configurable shortcuts (from useSettings) */
|
||||
/** User-configurable global shortcuts (from useSettings) */
|
||||
shortcuts: Record<string, Shortcut>;
|
||||
/** User-configurable tab shortcuts (from useSettings) */
|
||||
tabShortcuts: Record<string, Shortcut>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,7 @@ export interface UseKeyboardShortcutHelpersReturn {
|
||||
export function useKeyboardShortcutHelpers(
|
||||
deps: UseKeyboardShortcutHelpersDeps
|
||||
): UseKeyboardShortcutHelpersReturn {
|
||||
const { shortcuts } = deps;
|
||||
const { shortcuts, tabShortcuts } = deps;
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut by action ID.
|
||||
@@ -94,11 +95,11 @@ export function useKeyboardShortcutHelpers(
|
||||
/**
|
||||
* Check if a keyboard event matches a tab shortcut (AI mode only).
|
||||
*
|
||||
* Checks both TAB_SHORTCUTS (fixed tab shortcuts) and editable shortcuts
|
||||
* (for prevTab/nextTab which can be customized).
|
||||
* Uses user-configurable tabShortcuts, falling back to global shortcuts
|
||||
* if a tab-specific shortcut isn't defined.
|
||||
*/
|
||||
const isTabShortcut = useCallback((e: KeyboardEvent, actionId: string): boolean => {
|
||||
const sc = TAB_SHORTCUTS[actionId] || shortcuts[actionId];
|
||||
const sc = tabShortcuts[actionId] || shortcuts[actionId];
|
||||
if (!sc) return false;
|
||||
const keys = sc.keys.map(k => k.toLowerCase());
|
||||
|
||||
@@ -128,7 +129,7 @@ export function useKeyboardShortcutHelpers(
|
||||
}
|
||||
|
||||
return key === mainKey;
|
||||
}, [shortcuts]);
|
||||
}, [tabShortcuts, shortcuts]);
|
||||
|
||||
return { isShortcut, isTabShortcut };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Session, AITab } from '../types';
|
||||
import { TAB_SHORTCUTS } from '../constants/shortcuts';
|
||||
import { getInitialRenameValue } from '../utils/tabHelpers';
|
||||
|
||||
/**
|
||||
@@ -454,7 +453,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
|
||||
// Cmd+1 through Cmd+9: Jump to specific tab by index (disabled in unread-only mode)
|
||||
if (!ctx.showUnreadOnly) {
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
if (ctx.isTabShortcut(e, `goToTab${i}` as keyof typeof TAB_SHORTCUTS)) {
|
||||
if (ctx.isTabShortcut(e, `goToTab${i}`)) {
|
||||
e.preventDefault();
|
||||
const result = ctx.navigateToTabByIndex(ctx.activeSession, i - 1);
|
||||
if (result) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { LLMProvider, ThemeId, ThemeColors, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types';
|
||||
import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes';
|
||||
import { DEFAULT_SHORTCUTS } from '../constants/shortcuts';
|
||||
import { DEFAULT_SHORTCUTS, TAB_SHORTCUTS } from '../constants/shortcuts';
|
||||
import { commitCommandPrompt } from '../../prompts';
|
||||
|
||||
// Default global stats
|
||||
@@ -167,6 +167,8 @@ export interface UseSettingsReturn {
|
||||
// Shortcuts
|
||||
shortcuts: Record<string, Shortcut>;
|
||||
setShortcuts: (value: Record<string, Shortcut>) => void;
|
||||
tabShortcuts: Record<string, Shortcut>;
|
||||
setTabShortcuts: (value: Record<string, Shortcut>) => void;
|
||||
|
||||
// Custom AI Commands
|
||||
customAICommands: CustomAICommand[];
|
||||
@@ -286,6 +288,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
// Shortcuts
|
||||
const [shortcuts, setShortcutsState] = useState<Record<string, Shortcut>>(DEFAULT_SHORTCUTS);
|
||||
const [tabShortcuts, setTabShortcutsState] = useState<Record<string, Shortcut>>(TAB_SHORTCUTS);
|
||||
|
||||
// Custom AI Commands
|
||||
const [customAICommands, setCustomAICommandsState] = useState<CustomAICommand[]>(DEFAULT_AI_COMMANDS);
|
||||
@@ -426,6 +429,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
window.maestro.settings.set('shortcuts', value);
|
||||
}, []);
|
||||
|
||||
const setTabShortcuts = useCallback((value: Record<string, Shortcut>) => {
|
||||
setTabShortcutsState(value);
|
||||
window.maestro.settings.set('tabShortcuts', value);
|
||||
}, []);
|
||||
|
||||
const setTerminalWidth = useCallback((value: number) => {
|
||||
setTerminalWidthState(value);
|
||||
window.maestro.settings.set('terminalWidth', value);
|
||||
@@ -892,6 +900,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const savedMarkdownEditMode = await window.maestro.settings.get('markdownEditMode');
|
||||
const savedShowHiddenFiles = await window.maestro.settings.get('showHiddenFiles');
|
||||
const savedShortcuts = await window.maestro.settings.get('shortcuts');
|
||||
const savedTabShortcuts = await window.maestro.settings.get('tabShortcuts');
|
||||
const savedActiveThemeId = await window.maestro.settings.get('activeThemeId');
|
||||
const savedCustomThemeColors = await window.maestro.settings.get('customThemeColors');
|
||||
const savedCustomThemeBaseId = await window.maestro.settings.get('customThemeBaseId');
|
||||
@@ -1012,6 +1021,46 @@ export function useSettings(): UseSettingsReturn {
|
||||
setShortcutsState(mergedShortcuts);
|
||||
}
|
||||
|
||||
// Merge saved tab shortcuts with defaults (in case new shortcuts were added)
|
||||
if (savedTabShortcuts !== undefined) {
|
||||
// Apply same macOS Alt+key migration
|
||||
const macAltCharMap: Record<string, string> = {
|
||||
'¬': 'l', 'π': 'p', '†': 't', '∫': 'b', '∂': 'd', 'ƒ': 'f',
|
||||
'©': 'g', '˙': 'h', 'ˆ': 'i', '∆': 'j', '˚': 'k', '¯': 'm',
|
||||
'˜': 'n', 'ø': 'o', '®': 'r', 'ß': 's', '√': 'v', '∑': 'w',
|
||||
'≈': 'x', '¥': 'y', 'Ω': 'z',
|
||||
};
|
||||
|
||||
const migratedTabShortcuts: Record<string, Shortcut> = {};
|
||||
let needsTabMigration = false;
|
||||
|
||||
for (const [id, shortcut] of Object.entries(savedTabShortcuts as Record<string, Shortcut>)) {
|
||||
const migratedKeys = shortcut.keys.map(key => {
|
||||
if (macAltCharMap[key]) {
|
||||
needsTabMigration = true;
|
||||
return macAltCharMap[key];
|
||||
}
|
||||
return key;
|
||||
});
|
||||
migratedTabShortcuts[id] = { ...shortcut, keys: migratedKeys };
|
||||
}
|
||||
|
||||
if (needsTabMigration) {
|
||||
window.maestro.settings.set('tabShortcuts', migratedTabShortcuts);
|
||||
}
|
||||
|
||||
// Merge: use default labels but preserve user's custom keys
|
||||
const mergedTabShortcuts: Record<string, Shortcut> = {};
|
||||
for (const [id, defaultShortcut] of Object.entries(TAB_SHORTCUTS)) {
|
||||
const savedShortcut = migratedTabShortcuts[id];
|
||||
mergedTabShortcuts[id] = {
|
||||
...defaultShortcut,
|
||||
keys: savedShortcut?.keys ?? defaultShortcut.keys,
|
||||
};
|
||||
}
|
||||
setTabShortcutsState(mergedTabShortcuts);
|
||||
}
|
||||
|
||||
// Merge saved AI commands with defaults (ensure built-in commands always exist)
|
||||
if (savedCustomAICommands !== undefined && Array.isArray(savedCustomAICommands)) {
|
||||
// Start with defaults, then merge saved commands (by ID to avoid duplicates)
|
||||
@@ -1163,6 +1212,8 @@ export function useSettings(): UseSettingsReturn {
|
||||
setLogViewerSelectedLevels,
|
||||
shortcuts,
|
||||
setShortcuts,
|
||||
tabShortcuts,
|
||||
setTabShortcuts,
|
||||
customAICommands,
|
||||
setCustomAICommands,
|
||||
globalStats,
|
||||
@@ -1233,6 +1284,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
crashReportingEnabled,
|
||||
logViewerSelectedLevels,
|
||||
shortcuts,
|
||||
tabShortcuts,
|
||||
customAICommands,
|
||||
globalStats,
|
||||
autoRunStats,
|
||||
@@ -1274,6 +1326,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
setCrashReportingEnabled,
|
||||
setLogViewerSelectedLevels,
|
||||
setShortcuts,
|
||||
setTabShortcuts,
|
||||
setCustomAICommands,
|
||||
setGlobalStats,
|
||||
updateGlobalStats,
|
||||
|
||||
@@ -13,6 +13,22 @@ export interface ParsedSynopsis {
|
||||
fullSynopsis: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text is a template placeholder that wasn't filled in.
|
||||
* These appear when the model outputs the format instructions literally.
|
||||
*/
|
||||
function isTemplatePlaceholder(text: string): boolean {
|
||||
const placeholderPatterns = [
|
||||
/^\[.*sentences.*\]$/i, // [1-2 sentences describing...]
|
||||
/^\[.*paragraph.*\]$/i, // [A paragraph with...]
|
||||
/^\.\.\.\s*\(/, // ... (1-2 sentences)
|
||||
/^\.\.\.\s*then\s+blank/i, // ... then blank line
|
||||
/^then\s+blank/i, // then blank line
|
||||
/^\(1-2\s+sentences\)/i, // (1-2 sentences)
|
||||
];
|
||||
return placeholderPatterns.some(pattern => pattern.test(text.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synopsis response into short summary and full synopsis.
|
||||
*
|
||||
@@ -21,6 +37,8 @@ export interface ParsedSynopsis {
|
||||
* **Details:** Detailed paragraph...
|
||||
*
|
||||
* Falls back to using the first line as summary if format not detected.
|
||||
* Filters out template placeholders that models sometimes output literally
|
||||
* (especially common with thinking/reasoning models).
|
||||
*
|
||||
* @param response - Raw AI response string (may contain ANSI codes, box drawing chars)
|
||||
* @returns Parsed synopsis with shortSummary and fullSynopsis
|
||||
@@ -36,8 +54,30 @@ export function parseSynopsis(response: string): ParsedSynopsis {
|
||||
const summaryMatch = clean.match(/\*\*Summary:\*\*\s*(.+?)(?=\*\*Details:\*\*|$)/is);
|
||||
const detailsMatch = clean.match(/\*\*Details:\*\*\s*(.+?)$/is);
|
||||
|
||||
const shortSummary = summaryMatch?.[1]?.trim() || clean.split('\n')[0]?.trim() || 'Task completed';
|
||||
const details = detailsMatch?.[1]?.trim() || '';
|
||||
let shortSummary = summaryMatch?.[1]?.trim() || '';
|
||||
let details = detailsMatch?.[1]?.trim() || '';
|
||||
|
||||
// Check if summary is a template placeholder (model output format instructions literally)
|
||||
if (!shortSummary || isTemplatePlaceholder(shortSummary)) {
|
||||
// Try to find actual content by looking for non-placeholder lines
|
||||
const lines = clean.split('\n').filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed &&
|
||||
!trimmed.startsWith('**') &&
|
||||
!isTemplatePlaceholder(trimmed) &&
|
||||
!trimmed.match(/^Rules:/i) &&
|
||||
!trimmed.match(/^-\s+Be specific/i) &&
|
||||
!trimmed.match(/^-\s+Focus only/i) &&
|
||||
!trimmed.match(/^-\s+If nothing/i) &&
|
||||
!trimmed.match(/^Provide a brief synopsis/i);
|
||||
});
|
||||
shortSummary = lines[0]?.trim() || 'Task completed';
|
||||
}
|
||||
|
||||
// Check if details is a template placeholder
|
||||
if (isTemplatePlaceholder(details)) {
|
||||
details = '';
|
||||
}
|
||||
|
||||
// Full synopsis includes both parts
|
||||
const fullSynopsis = details ? `${shortSummary}\n\n${details}` : shortSummary;
|
||||
|
||||
Reference in New Issue
Block a user