diff --git a/.gitignore b/.gitignore index 01765745..00992677 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ Auto\ Run\ Docs/ Work\ Trees/ community-data/ .mcp.json +specs/ # Tests coverage/ diff --git a/src/__tests__/integration/provider-integration.test.ts b/src/__tests__/integration/provider-integration.test.ts index cd32b289..d947cb71 100644 --- a/src/__tests__/integration/provider-integration.test.ts +++ b/src/__tests__/integration/provider-integration.test.ts @@ -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 diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d343b0ac..975cb320 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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([]); @@ -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() { 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} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 5abfd7ef..306c9f70 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -169,6 +169,8 @@ interface SettingsModalProps { setApiKey: (key: string) => void; shortcuts: Record; setShortcuts: (shortcuts: Record) => void; + tabShortcuts: Record; + setTabShortcuts: (shortcuts: Record) => 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 }) => ( +
+ {sc.label} + +
+ ); + return (
{props.hasNoAgents && ( @@ -1419,37 +1465,30 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro

Not all shortcuts can be modified. Press ⌘/ from the main interface to view the full list of keyboard shortcuts.

-
- {filteredShortcuts.map((sc: Shortcut) => ( -
- {sc.label} - +
+ {/* General Shortcuts Section */} + {generalShortcuts.length > 0 && ( +
+

+ General +

+
+ {generalShortcuts.map(renderShortcutItem)} +
- ))} + )} + + {/* AI Tab Shortcuts Section */} + {tabShortcutsFiltered.length > 0 && ( +
+

+ AI Tab +

+
+ {tabShortcutsFiltered.map(renderShortcutItem)} +
+
+ )}
); diff --git a/src/renderer/components/ShortcutsHelpModal.tsx b/src/renderer/components/ShortcutsHelpModal.tsx index 54578d3b..87a1e1cc 100644 --- a/src/renderer/components/ShortcutsHelpModal.tsx +++ b/src/renderer/components/ShortcutsHelpModal.tsx @@ -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; + tabShortcuts: Record; 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(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) diff --git a/src/renderer/hooks/useKeyboardShortcutHelpers.ts b/src/renderer/hooks/useKeyboardShortcutHelpers.ts index b11b36d2..ac66c951 100644 --- a/src/renderer/hooks/useKeyboardShortcutHelpers.ts +++ b/src/renderer/hooks/useKeyboardShortcutHelpers.ts @@ -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; + /** User-configurable tab shortcuts (from useSettings) */ + tabShortcuts: Record; } /** @@ -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 }; } diff --git a/src/renderer/hooks/useMainKeyboardHandler.ts b/src/renderer/hooks/useMainKeyboardHandler.ts index ef101cc7..01ccc821 100644 --- a/src/renderer/hooks/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/useMainKeyboardHandler.ts @@ -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) { diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index 3f4b5431..4130619f 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -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; setShortcuts: (value: Record) => void; + tabShortcuts: Record; + setTabShortcuts: (value: Record) => void; // Custom AI Commands customAICommands: CustomAICommand[]; @@ -286,6 +288,7 @@ export function useSettings(): UseSettingsReturn { // Shortcuts const [shortcuts, setShortcutsState] = useState>(DEFAULT_SHORTCUTS); + const [tabShortcuts, setTabShortcutsState] = useState>(TAB_SHORTCUTS); // Custom AI Commands const [customAICommands, setCustomAICommandsState] = useState(DEFAULT_AI_COMMANDS); @@ -426,6 +429,11 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('shortcuts', value); }, []); + const setTabShortcuts = useCallback((value: Record) => { + 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 = { + '¬': 'l', 'Ļ€': 'p', '†': 't', '∫': 'b', 'āˆ‚': 'd', 'ʒ': 'f', + 'Ā©': 'g', 'Ė™': 'h', 'ˆ': 'i', 'āˆ†': 'j', '˚': 'k', 'ĀÆ': 'm', + '˜': 'n', 'Ćø': 'o', 'Ā®': 'r', 'ß': 's', '√': 'v', 'āˆ‘': 'w', + 'ā‰ˆ': 'x', 'Ā„': 'y', 'Ī©': 'z', + }; + + const migratedTabShortcuts: Record = {}; + let needsTabMigration = false; + + for (const [id, shortcut] of Object.entries(savedTabShortcuts as Record)) { + 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 = {}; + 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, diff --git a/src/shared/synopsis.ts b/src/shared/synopsis.ts index 5a9ad152..1b90bfcd 100644 --- a/src/shared/synopsis.ts +++ b/src/shared/synopsis.ts @@ -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;