## CHANGES

- INPUT missing—share release notes, commits, or changelog for summary please! 📌
This commit is contained in:
Pedram Amini
2025-12-23 10:54:38 -06:00
parent 6fd22daa28
commit 67ca76262a
9 changed files with 469 additions and 58 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ Auto\ Run\ Docs/
Work\ Trees/
community-data/
.mcp.json
specs/
# Tests
coverage/

View File

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

View File

@@ -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}

View File

@@ -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);
props.setShortcuts({
...props.shortcuts,
[actionId]: { ...props.shortcuts[actionId], keys }
});
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>
);

View File

@@ -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)

View File

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

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;