- {CLAUDE_MODELS.map((option) => {
- const isSelected = selectedModel === option.id;
- const shortName = option.label.replace('Claude ', '');
- return (
-
);
}
diff --git a/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx b/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx
index 0b85c985..674d1455 100644
--- a/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx
+++ b/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx
@@ -1,14 +1,41 @@
import { Label } from '@/components/ui/label';
-import { Brain, UserCircle } from 'lucide-react';
+import { Brain, UserCircle, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
-import { AgentModel, ThinkingLevel, AIProfile } from '@/store/app-store';
+import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
+import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
+/**
+ * Get display string for a profile's model configuration
+ */
+function getProfileModelDisplay(profile: AIProfile): string {
+ if (profile.provider === 'cursor') {
+ const cursorModel = profile.cursorModel || 'auto';
+ const modelConfig = CURSOR_MODEL_MAP[cursorModel];
+ return modelConfig?.label || cursorModel;
+ }
+ // Claude
+ return profile.model || 'sonnet';
+}
+
+/**
+ * Get display string for a profile's thinking configuration
+ */
+function getProfileThinkingDisplay(profile: AIProfile): string | null {
+ if (profile.provider === 'cursor') {
+ // For Cursor, thinking is embedded in the model
+ return profileHasThinking(profile) ? 'thinking' : null;
+ }
+ // Claude
+ return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
+}
+
interface ProfileQuickSelectProps {
profiles: AIProfile[];
- selectedModel: AgentModel;
+ selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
- onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void;
+ selectedCursorModel?: string; // For detecting cursor profile selection
+ onSelect: (profile: AIProfile) => void; // Changed to pass full profile
testIdPrefix?: string;
showManageLink?: boolean;
onManageLinkClick?: () => void;
@@ -18,15 +45,30 @@ export function ProfileQuickSelect({
profiles,
selectedModel,
selectedThinkingLevel,
+ selectedCursorModel,
onSelect,
testIdPrefix = 'profile-quick-select',
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
- if (profiles.length === 0) {
+ // Show both Claude and Cursor profiles
+ const allProfiles = profiles;
+
+ if (allProfiles.length === 0) {
return null;
}
+ // Check if a profile is selected
+ const isProfileSelected = (profile: AIProfile): boolean => {
+ if (profile.provider === 'cursor') {
+ // For cursor profiles, check if cursor model matches
+ const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
+ return selectedCursorModel === profileCursorModel;
+ }
+ // For Claude profiles
+ return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
+ };
+
return (
@@ -39,15 +81,16 @@ export function ProfileQuickSelect({
- {profiles.slice(0, 6).map((profile) => {
+ {allProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
- const isSelected =
- selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
+ const isSelected = isProfileSelected(profile);
+ const isCursorProfile = profile.provider === 'cursor';
+
return (
- {/* Model Selection */}
+ {/* Provider Selection */}
-
-
- {CLAUDE_MODELS.map(({ id, label }) => (
-
handleModelChange(id)}
- className={cn(
- 'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
- formData.model === id
- ? 'bg-primary text-primary-foreground border-primary'
- : 'bg-background hover:bg-accent border-border'
- )}
- data-testid={`model-select-${id}`}
- >
- {label.replace('Claude ', '')}
-
- ))}
+
+
+ handleProviderChange('claude')}
+ className={cn(
+ 'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
+ formData.provider === 'claude'
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background hover:bg-accent border-border'
+ )}
+ data-testid="provider-select-claude"
+ >
+
+ Claude
+
+ handleProviderChange('cursor')}
+ className={cn(
+ 'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
+ formData.provider === 'cursor'
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background hover:bg-accent border-border'
+ )}
+ data-testid="provider-select-cursor"
+ >
+
+ Cursor CLI
+
- {/* Thinking Level */}
- {supportsThinking && (
+ {/* Claude Model Selection */}
+ {formData.provider === 'claude' && (
+
+
+
+ {CLAUDE_MODELS.map(({ id, label }) => (
+ handleModelChange(id)}
+ className={cn(
+ 'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
+ formData.model === id
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background hover:bg-accent border-border'
+ )}
+ data-testid={`model-select-${id}`}
+ >
+ {label.replace('Claude ', '')}
+
+ ))}
+
+
+ )}
+
+ {/* Cursor Model Selection */}
+ {formData.provider === 'cursor' && (
+
+
+
+ {enabledCursorModels.length === 0 ? (
+
+ No Cursor models enabled. Enable models in Settings → AI Providers.
+
+ ) : (
+ Object.entries(CURSOR_MODEL_MAP)
+ .filter(([id]) => enabledCursorModels.includes(id as CursorModelId))
+ .map(([id, config]) => (
+
handleCursorModelChange(id as CursorModelId)}
+ className={cn(
+ 'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
+ formData.cursorModel === id
+ ? 'bg-primary text-primary-foreground border-primary'
+ : 'bg-background hover:bg-accent border-border'
+ )}
+ data-testid={`cursor-model-select-${id}`}
+ >
+ {config.label}
+
+ {config.hasThinking && (
+
+ Thinking
+
+ )}
+
+ {config.tier}
+
+
+
+ ))
+ )}
+
+ {formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
+
+ This model has built-in extended thinking capabilities.
+
+ )}
+
+ )}
+
+ {/* Claude Thinking Level */}
+ {formData.provider === 'claude' && supportsThinking && (
{profile.description}
-
- {profile.model}
+ {/* Provider badge */}
+
+ {profile.provider === 'cursor' ? (
+
+ ) : (
+
+ )}
+ {profile.provider === 'cursor' ? 'Cursor' : 'Claude'}
- {profile.thinkingLevel !== 'none' && (
+
+ {/* Model badge */}
+
+ {profile.provider === 'cursor'
+ ? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label ||
+ profile.cursorModel ||
+ 'auto'
+ : profile.model || 'sonnet'}
+
+
+ {/* Thinking badge - works for both providers */}
+ {profileHasThinking(profile) && (
- {profile.thinkingLevel}
+ {profile.provider === 'cursor' ? 'Thinking' : profile.thinkingLevel}
)}
diff --git a/apps/ui/src/components/views/profiles-view/constants.ts b/apps/ui/src/components/views/profiles-view/constants.ts
index 403b7fc9..6158b46f 100644
--- a/apps/ui/src/components/views/profiles-view/constants.ts
+++ b/apps/ui/src/components/views/profiles-view/constants.ts
@@ -1,5 +1,5 @@
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
-import type { AgentModel, ThinkingLevel } from '@/store/app-store';
+import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
// Icon mapping for profiles
export const PROFILE_ICONS: Record
> = {
@@ -22,7 +22,7 @@ export const ICON_OPTIONS = [
];
// Model options for the form
-export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
+export const CLAUDE_MODELS: { id: ModelAlias; label: string }[] = [
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },
diff --git a/apps/ui/src/components/views/profiles-view/utils.ts b/apps/ui/src/components/views/profiles-view/utils.ts
index d6a9ce3e..f84bc222 100644
--- a/apps/ui/src/components/views/profiles-view/utils.ts
+++ b/apps/ui/src/components/views/profiles-view/utils.ts
@@ -1,6 +1,48 @@
-import type { AgentModel, ModelProvider } from '@/store/app-store';
+import type { ModelAlias, ModelProvider, AIProfile } from '@automaker/types';
+import { CURSOR_MODEL_MAP } from '@automaker/types';
-// Helper to determine provider from model
-export function getProviderFromModel(model: AgentModel): ModelProvider {
+// Helper to determine provider from model (legacy, always returns 'claude')
+export function getProviderFromModel(model: ModelAlias): ModelProvider {
return 'claude';
}
+
+/**
+ * Validate an AI profile for completeness and correctness
+ */
+export function validateProfile(profile: Partial): {
+ valid: boolean;
+ errors: string[];
+} {
+ const errors: string[] = [];
+
+ // Name is required
+ if (!profile.name?.trim()) {
+ errors.push('Profile name is required');
+ }
+
+ // Provider must be valid
+ if (!profile.provider || !['claude', 'cursor'].includes(profile.provider)) {
+ errors.push('Invalid provider');
+ }
+
+ // Claude-specific validation
+ if (profile.provider === 'claude') {
+ if (!profile.model) {
+ errors.push('Claude model is required');
+ } else if (!['haiku', 'sonnet', 'opus'].includes(profile.model)) {
+ errors.push('Invalid Claude model');
+ }
+ }
+
+ // Cursor-specific validation
+ if (profile.provider === 'cursor') {
+ if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
+ errors.push('Invalid Cursor model');
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ };
+}
diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx
index fb1ded96..17e14b25 100644
--- a/apps/ui/src/components/views/running-agents-view.tsx
+++ b/apps/ui/src/components/views/running-agents-view.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -7,6 +8,8 @@ import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
+const logger = createLogger('RunningAgentsView');
+
export function RunningAgentsView() {
const [runningAgents, setRunningAgents] = useState([]);
const [loading, setLoading] = useState(true);
@@ -25,7 +28,7 @@ export function RunningAgentsView() {
}
}
} catch (error) {
- console.error('[RunningAgentsView] Error fetching running agents:', error);
+ logger.error('Error fetching running agents:', error);
} finally {
setLoading(false);
setRefreshing(false);
@@ -78,7 +81,7 @@ export function RunningAgentsView() {
fetchRunningAgents();
}
} catch (error) {
- console.error('[RunningAgentsView] Error stopping agent:', error);
+ logger.error('Error stopping agent:', error);
}
},
[fetchRunningAgents]
diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx
index 037f6743..f1e3c2f1 100644
--- a/apps/ui/src/components/views/settings-view.tsx
+++ b/apps/ui/src/components/views/settings-view.tsx
@@ -2,23 +2,21 @@ import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
-import { useCliStatus, useSettingsView } from './settings-view/hooks';
+import { useSettingsView } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header';
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
import { SettingsNavigation } from './settings-view/components/settings-navigation';
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
-import { ClaudeUsageSection } from './settings-view/api-keys/claude-usage-section';
-import { ClaudeCliStatus } from './settings-view/cli-status/claude-cli-status';
-import { ClaudeMdSettings } from './settings-view/claude/claude-md-settings';
-import { AIEnhancementSection } from './settings-view/ai-enhancement';
+import { ModelDefaultsSection } from './settings-view/model-defaults';
import { AppearanceSection } from './settings-view/appearance/appearance-section';
import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
+import { ProviderTabs } from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
@@ -48,9 +46,6 @@ export function SettingsView() {
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
- apiKeys,
- validationModel,
- setValidationModel,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
enableSandboxMode,
@@ -61,19 +56,6 @@ export function SettingsView() {
setPromptCustomization,
} = useAppStore();
- const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
-
- // Hide usage tracking when using API key (only show for Claude Code CLI users)
- // Check both user-entered API key and environment variable ANTHROPIC_API_KEY
- // Also hide on Windows for now (CLI usage command not supported)
- // Only show if CLI has been verified/authenticated
- const isWindows =
- typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
- const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
- const isCliVerified =
- claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
- const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
-
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {
if (!project) return null;
@@ -101,9 +83,6 @@ export function SettingsView() {
}
};
- // Use CLI status hook
- const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus();
-
// Use settings view navigation hook
const { activeView, navigateTo } = useSettingsView();
@@ -113,23 +92,9 @@ export function SettingsView() {
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
- case 'claude':
- return (
-
-
-
- {showUsageTracking && }
-
- );
+ case 'providers':
+ case 'claude': // Backwards compatibility
+ return ;
case 'mcp-servers':
return ;
case 'prompts':
@@ -139,8 +104,8 @@ export function SettingsView() {
onPromptCustomizationChange={setPromptCustomization}
/>
);
- case 'ai-enhancement':
- return ;
+ case 'model-defaults':
+ return ;
case 'appearance':
return (
);
case 'danger':
diff --git a/apps/ui/src/components/views/settings-view/ai-enhancement/ai-enhancement-section.tsx b/apps/ui/src/components/views/settings-view/ai-enhancement/ai-enhancement-section.tsx
deleted file mode 100644
index 0f8995c4..00000000
--- a/apps/ui/src/components/views/settings-view/ai-enhancement/ai-enhancement-section.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Label } from '@/components/ui/label';
-import { Sparkles } from 'lucide-react';
-import { cn } from '@/lib/utils';
-import { useAppStore } from '@/store/app-store';
-import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
-
-export function AIEnhancementSection() {
- const { enhancementModel, setEnhancementModel } = useAppStore();
-
- return (
-
-
-
-
- Choose the model used when enhancing feature descriptions.
-
-
-
-
-
-
- {CLAUDE_MODELS.map(({ id, label, description, badge }) => {
- const isActive = enhancementModel === id;
- return (
-
setEnhancementModel(id)}
- className={cn(
- 'group flex flex-col items-start gap-2 px-4 py-4 rounded-xl text-left',
- 'transition-all duration-200 ease-out',
- isActive
- ? [
- 'bg-gradient-to-br from-brand-500/15 to-brand-600/10',
- 'border-2 border-brand-500/40',
- 'text-foreground',
- 'shadow-md shadow-brand-500/10',
- ]
- : [
- 'bg-accent/30 hover:bg-accent/50',
- 'border border-border/50 hover:border-border',
- 'text-muted-foreground hover:text-foreground',
- 'hover:shadow-sm',
- ],
- 'hover:scale-[1.02] active:scale-[0.98]'
- )}
- data-testid={`enhancement-model-${id}`}
- >
-
-
- {label}
-
- {badge && (
-
- {badge}
-
- )}
-
- {description}
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/apps/ui/src/components/views/settings-view/ai-enhancement/index.ts b/apps/ui/src/components/views/settings-view/ai-enhancement/index.ts
deleted file mode 100644
index 53c291fa..00000000
--- a/apps/ui/src/components/views/settings-view/ai-enhancement/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { AIEnhancementSection } from './ai-enhancement-section';
diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx
index 63d4e2e7..e0261e97 100644
--- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx
+++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx
@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Settings, Trash2, Loader2 } from 'lucide-react';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
-import { AuthenticationStatusDisplay } from './authentication-status-display';
import { SecurityNotice } from './security-notice';
import { useApiKeyManagement } from './hooks/use-api-key-management';
import { cn } from '@/lib/utils';
@@ -19,7 +18,7 @@ export function ApiKeysSection() {
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const navigate = useNavigate();
- const { providerConfigParams, apiKeyStatus, handleSave, saved } = useApiKeyManagement();
+ const { providerConfigParams, handleSave, saved } = useApiKeyManagement();
const providerConfigs = buildProviderConfigs(providerConfigParams);
@@ -84,13 +83,6 @@ export function ApiKeysSection() {
))}
- {/* Authentication Status Display */}
-
-
{/* Security Notice */}
diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts
index cd0a4c9c..d2f12839 100644
--- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts
+++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts
@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
+
+const logger = createLogger('ApiKeyManagement');
import { getElectronAPI } from '@/lib/electron';
import type { ProviderConfigParams } from '@/config/api-providers';
@@ -60,7 +63,7 @@ export function useApiKeyManagement() {
});
}
} catch (error) {
- console.error('Failed to check API key status:', error);
+ logger.error('Failed to check API key status:', error);
}
}
};
@@ -69,12 +72,22 @@ export function useApiKeyManagement() {
// Test Anthropic/Claude connection
const handleTestAnthropicConnection = async () => {
+ // Validate input first
+ if (!anthropicKey || anthropicKey.trim().length === 0) {
+ setTestResult({
+ success: false,
+ message: 'Please enter an API key to test.',
+ });
+ return;
+ }
+
setTestingConnection(true);
setTestResult(null);
try {
const api = getElectronAPI();
- const data = await api.setup.verifyClaudeAuth('api_key');
+ // Pass the current input value to test unsaved keys
+ const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey);
if (data.success && data.authenticated) {
setTestResult({
diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx
index 8046d451..c808c37a 100644
--- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx
+++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx
@@ -1,16 +1,86 @@
import { Button } from '@/components/ui/button';
-import { Terminal, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
+import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
+import type { ClaudeAuthStatus } from '@/store/setup-store';
interface CliStatusProps {
status: CliStatus | null;
+ authStatus?: ClaudeAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
-export function ClaudeCliStatus({ status, isChecking, onRefresh }: CliStatusProps) {
- if (!status) return null;
+function getAuthMethodLabel(method: string): string {
+ switch (method) {
+ case 'oauth_token':
+ return 'OAuth Token (Subscription)';
+ case 'oauth_token_env':
+ return 'OAuth Token (Environment)';
+ case 'api_key':
+ return 'API Key';
+ case 'api_key_env':
+ return 'API Key (Environment)';
+ case 'credentials_file':
+ return 'Credentials File';
+ case 'cli_authenticated':
+ return 'CLI Authentication';
+ default:
+ return method || 'Unknown';
+ }
+}
+
+function SkeletonPulse({ className }: { className?: string }) {
+ return ;
+}
+
+function ClaudeCliStatusSkeleton() {
+ return (
+
+
+
+ {/* Installation status skeleton */}
+
+ {/* Auth status skeleton */}
+
+
+
+ );
+}
+
+export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: CliStatusProps) {
+ if (!status) return ;
return (
+ {/* Authentication Status */}
+ {authStatus?.authenticated ? (
+
+
+
+
+
+
Authenticated
+
+
+ Method:{' '}
+ {getAuthMethodLabel(authStatus.method)}
+
+
+
+
+ ) : (
+
+
+
+
+
+
Not Authenticated
+
+ Run claude login{' '}
+ or set an API key to authenticate.
+
+
+
+ )}
+
{status.recommendation && (
{status.recommendation}
)}
diff --git a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx
new file mode 100644
index 00000000..ebcec5ab
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx
@@ -0,0 +1,324 @@
+import { Button } from '@/components/ui/button';
+import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface CursorStatus {
+ installed: boolean;
+ version?: string;
+ authenticated: boolean;
+ method?: string;
+}
+
+interface CursorCliStatusProps {
+ status: CursorStatus | null;
+ isChecking: boolean;
+ onRefresh: () => void;
+}
+
+function SkeletonPulse({ className }: { className?: string }) {
+ return
;
+}
+
+export function CursorCliStatusSkeleton() {
+ return (
+
+
+
+ {/* Installation status skeleton */}
+
+ {/* Auth status skeleton */}
+
+
+
+ );
+}
+
+export function CursorPermissionsSkeleton() {
+ return (
+
+
+
+ {/* Security Warning skeleton */}
+
+ {/* Permission Profiles skeleton */}
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Config File Locations skeleton */}
+
+
+
+ );
+}
+
+export function ModelConfigSkeleton() {
+ return (
+
+
+
+ {/* Default Model skeleton */}
+
+
+
+
+ {/* Available Models skeleton */}
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStatusProps) {
+ if (!status) return
;
+
+ return (
+
+
+
+
+ Cursor CLI enables AI-powered code editing using Cursor's models.
+
+
+
+ {status.installed ? (
+
+ {/* Installation Status - Success */}
+
+
+
+
+
+
Cursor CLI Installed
+
+ {status.version && (
+
+ Version: {status.version}
+
+ )}
+
+
+
+
+ {/* Authentication Status */}
+ {status.authenticated ? (
+
+
+
+
+
+
Authenticated
+
+
+ Method:{' '}
+
+ {status.method === 'api_key' ? 'API Key' : 'Browser Login'}
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
Not Authenticated
+
+ Run cursor auth{' '}
+ to authenticate with Cursor.
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+
Cursor CLI Not Detected
+
+ Install Cursor CLI to use Cursor models in AutoMaker.
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts
index 879bb470..afffb92a 100644
--- a/apps/ui/src/components/views/settings-view/config/navigation.ts
+++ b/apps/ui/src/components/views/settings-view/config/navigation.ts
@@ -1,14 +1,14 @@
import type { LucideIcon } from 'lucide-react';
import {
Key,
- Terminal,
+ Bot,
SquareTerminal,
Palette,
Settings2,
Volume2,
FlaskConical,
Trash2,
- Sparkles,
+ Workflow,
Plug,
MessageSquareText,
} from 'lucide-react';
@@ -23,10 +23,10 @@ export interface NavigationItem {
// Navigation items for the settings side panel
export const NAV_ITEMS: NavigationItem[] = [
{ id: 'api-keys', label: 'API Keys', icon: Key },
- { id: 'claude', label: 'Claude', icon: Terminal },
+ { id: 'providers', label: 'AI Providers', icon: Bot },
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
- { id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
+ { id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'appearance', label: 'Appearance', icon: Palette },
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
index d924c676..24ebe15b 100644
--- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
+++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
@@ -12,7 +12,6 @@ import {
ScrollText,
ShieldCheck,
User,
- Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -23,7 +22,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
-import type { AgentModel } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -36,7 +34,6 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
- validationModel: AgentModel;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -44,7 +41,6 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
- onValidationModelChange: (value: AgentModel) => void;
}
export function FeatureDefaultsSection({
@@ -56,7 +52,6 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
- validationModel,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
@@ -64,7 +59,6 @@ export function FeatureDefaultsSection({
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
- onValidationModelChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
@@ -233,45 +227,6 @@ export function FeatureDefaultsSection({
{/* Separator */}
- {/* Issue Validation Model */}
-
-
-
-
-
-
-
-
-
-
- Model used for validating GitHub issues. Opus provides the most thorough analysis,
- while Haiku is faster and more cost-effective.
-
-
-
-
- {/* Separator */}
-
-
{/* Profiles Only Setting */}
{
+ const api = getElectronAPI();
+ if (!api?.setup?.getClaudeStatus) return;
+
+ try {
+ const result = await api.setup.getClaudeStatus();
+ if (result.success && result.auth) {
+ // Cast to extended type that includes server-added fields
+ const auth = result.auth as typeof result.auth & {
+ oauthTokenValid?: boolean;
+ apiKeyValid?: boolean;
+ };
+ // Map server method names to client method types
+ // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
+ const validMethods = [
+ 'oauth_token_env',
+ 'oauth_token',
+ 'api_key',
+ 'api_key_env',
+ 'credentials_file',
+ 'cli_authenticated',
+ 'none',
+ ] as const;
+ type AuthMethod = (typeof validMethods)[number];
+ const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
+ ? (auth.method as AuthMethod)
+ : auth.authenticated
+ ? 'api_key'
+ : 'none'; // Default authenticated to api_key, not none
+ const authStatus = {
+ authenticated: auth.authenticated,
+ method,
+ hasCredentialsFile: auth.hasCredentialsFile ?? false,
+ oauthTokenValid:
+ auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
+ apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
+ hasEnvOAuthToken: auth.hasEnvOAuthToken,
+ hasEnvApiKey: auth.hasEnvApiKey,
+ };
+ setClaudeAuthStatus(authStatus);
+ }
+ } catch (error) {
+ logger.error('Failed to refresh Claude auth status:', error);
+ }
+ }, [setClaudeAuthStatus]);
+
// Check CLI status on mount
useEffect(() => {
const checkCliStatus = async () => {
@@ -40,59 +90,18 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
} catch (error) {
- console.error('Failed to check Claude CLI status:', error);
+ logger.error('Failed to check Claude CLI status:', error);
}
}
// Check Claude auth status (re-fetch on mount to ensure persistence)
- if (api?.setup?.getClaudeStatus) {
- try {
- const result = await api.setup.getClaudeStatus();
- if (result.success && result.auth) {
- // Cast to extended type that includes server-added fields
- const auth = result.auth as typeof result.auth & {
- oauthTokenValid?: boolean;
- apiKeyValid?: boolean;
- };
- // Map server method names to client method types
- // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
- const validMethods = [
- 'oauth_token_env',
- 'oauth_token',
- 'api_key',
- 'api_key_env',
- 'credentials_file',
- 'cli_authenticated',
- 'none',
- ] as const;
- type AuthMethod = (typeof validMethods)[number];
- const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
- ? (auth.method as AuthMethod)
- : auth.authenticated
- ? 'api_key'
- : 'none'; // Default authenticated to api_key, not none
- const authStatus = {
- authenticated: auth.authenticated,
- method,
- hasCredentialsFile: auth.hasCredentialsFile ?? false,
- oauthTokenValid:
- auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
- apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
- hasEnvOAuthToken: auth.hasEnvOAuthToken,
- hasEnvApiKey: auth.hasEnvApiKey,
- };
- setClaudeAuthStatus(authStatus);
- }
- } catch (error) {
- console.error('Failed to check Claude auth status:', error);
- }
- }
+ await refreshAuthStatus();
};
checkCliStatus();
- }, [setClaudeAuthStatus]);
+ }, [refreshAuthStatus]);
- // Refresh Claude CLI status
+ // Refresh Claude CLI status and auth status
const handleRefreshClaudeCli = useCallback(async () => {
setIsCheckingClaudeCli(true);
try {
@@ -101,12 +110,14 @@ export function useCliStatus() {
const status = await api.checkClaudeCli();
setClaudeCliStatus(status);
}
+ // Also refresh auth status
+ await refreshAuthStatus();
} catch (error) {
- console.error('Failed to refresh Claude CLI status:', error);
+ logger.error('Failed to refresh Claude CLI status:', error);
} finally {
setIsCheckingClaudeCli(false);
}
- }, []);
+ }, [refreshAuthStatus]);
return {
claudeCliStatus,
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts
new file mode 100644
index 00000000..a911892e
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts
@@ -0,0 +1,106 @@
+import { useState, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
+import { toast } from 'sonner';
+
+const logger = createLogger('CursorPermissions');
+import { getHttpApiClient } from '@/lib/http-api-client';
+import type { CursorPermissionProfile } from '@automaker/types';
+
+export interface PermissionsData {
+ activeProfile: CursorPermissionProfile | null;
+ effectivePermissions: { allow: string[]; deny: string[] } | null;
+ hasProjectConfig: boolean;
+ availableProfiles: Array<{
+ id: string;
+ name: string;
+ description: string;
+ permissions: { allow: string[]; deny: string[] };
+ }>;
+}
+
+/**
+ * Custom hook for managing Cursor CLI permissions
+ * Handles loading permissions data, applying profiles, and copying configs
+ */
+export function useCursorPermissions(projectPath?: string) {
+ const [permissions, setPermissions] = useState(null);
+ const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
+ const [isSavingPermissions, setIsSavingPermissions] = useState(false);
+ const [copiedConfig, setCopiedConfig] = useState(false);
+
+ // Load permissions data
+ const loadPermissions = useCallback(async () => {
+ setIsLoadingPermissions(true);
+ try {
+ const api = getHttpApiClient();
+ const result = await api.setup.getCursorPermissions(projectPath);
+
+ if (result.success) {
+ setPermissions({
+ activeProfile: result.activeProfile || null,
+ effectivePermissions: result.effectivePermissions || null,
+ hasProjectConfig: result.hasProjectConfig || false,
+ availableProfiles: result.availableProfiles || [],
+ });
+ }
+ } catch (error) {
+ logger.error('Failed to load Cursor permissions:', error);
+ } finally {
+ setIsLoadingPermissions(false);
+ }
+ }, [projectPath]);
+
+ // Apply a permission profile
+ const applyProfile = useCallback(
+ async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
+ setIsSavingPermissions(true);
+ try {
+ const api = getHttpApiClient();
+ const result = await api.setup.applyCursorPermissionProfile(
+ profileId,
+ scope,
+ scope === 'project' ? projectPath : undefined
+ );
+
+ if (result.success) {
+ toast.success(result.message || `Applied ${profileId} profile`);
+ await loadPermissions();
+ } else {
+ toast.error(result.error || 'Failed to apply profile');
+ }
+ } catch (error) {
+ toast.error('Failed to apply profile');
+ } finally {
+ setIsSavingPermissions(false);
+ }
+ },
+ [projectPath, loadPermissions]
+ );
+
+ // Copy example config to clipboard
+ const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
+ try {
+ const api = getHttpApiClient();
+ const result = await api.setup.getCursorExampleConfig(profileId);
+
+ if (result.success && result.config) {
+ await navigator.clipboard.writeText(result.config);
+ setCopiedConfig(true);
+ toast.success('Config copied to clipboard');
+ setTimeout(() => setCopiedConfig(false), 2000);
+ }
+ } catch (error) {
+ toast.error('Failed to copy config');
+ }
+ }, []);
+
+ return {
+ permissions,
+ isLoadingPermissions,
+ isSavingPermissions,
+ copiedConfig,
+ loadPermissions,
+ applyProfile,
+ copyConfig,
+ };
+}
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts
new file mode 100644
index 00000000..a082e71b
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts
@@ -0,0 +1,70 @@
+import { useState, useEffect, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
+import { toast } from 'sonner';
+
+const logger = createLogger('CursorStatus');
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { useSetupStore } from '@/store/setup-store';
+
+export interface CursorStatus {
+ installed: boolean;
+ version?: string;
+ authenticated: boolean;
+ method?: string;
+}
+
+/**
+ * Custom hook for managing Cursor CLI status
+ * Handles checking CLI installation, authentication, and refresh functionality
+ */
+export function useCursorStatus() {
+ const { setCursorCliStatus } = useSetupStore();
+
+ const [status, setStatus] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const loadData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const api = getHttpApiClient();
+ const statusResult = await api.setup.getCursorStatus();
+
+ if (statusResult.success) {
+ const newStatus = {
+ installed: statusResult.installed ?? false,
+ version: statusResult.version ?? undefined,
+ authenticated: statusResult.auth?.authenticated ?? false,
+ method: statusResult.auth?.method,
+ };
+ setStatus(newStatus);
+
+ // Also update the global setup store so other components can access the status
+ setCursorCliStatus({
+ installed: newStatus.installed,
+ version: newStatus.version,
+ auth: newStatus.authenticated
+ ? {
+ authenticated: true,
+ method: newStatus.method || 'unknown',
+ }
+ : undefined,
+ });
+ }
+ } catch (error) {
+ logger.error('Failed to load Cursor settings:', error);
+ toast.error('Failed to load Cursor settings');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [setCursorCliStatus]);
+
+ useEffect(() => {
+ loadData();
+ }, [loadData]);
+
+ return {
+ status,
+ isLoading,
+ loadData,
+ };
+}
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
index da7d4f0a..a645a659 100644
--- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
+++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
@@ -3,9 +3,10 @@ import { useState, useCallback } from 'react';
export type SettingsViewId =
| 'api-keys'
| 'claude'
+ | 'providers'
| 'mcp-servers'
| 'prompts'
- | 'ai-enhancement'
+ | 'model-defaults'
| 'appearance'
| 'terminal'
| 'keyboard'
diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts
index db49d81d..6903ba40 100644
--- a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts
+++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts
@@ -1,4 +1,3 @@
export { MCPServerHeader } from './mcp-server-header';
-export { MCPPermissionSettings } from './mcp-permission-settings';
export { MCPToolsWarning } from './mcp-tools-warning';
export { MCPServerCard } from './mcp-server-card';
diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx
deleted file mode 100644
index e65e25bb..00000000
--- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { ShieldAlert } from 'lucide-react';
-import { Label } from '@/components/ui/label';
-import { Switch } from '@/components/ui/switch';
-import { syncSettingsToServer } from '@/hooks/use-settings-migration';
-import { cn } from '@/lib/utils';
-
-interface MCPPermissionSettingsProps {
- mcpAutoApproveTools: boolean;
- mcpUnrestrictedTools: boolean;
- onAutoApproveChange: (checked: boolean) => void;
- onUnrestrictedChange: (checked: boolean) => void;
-}
-
-export function MCPPermissionSettings({
- mcpAutoApproveTools,
- mcpUnrestrictedTools,
- onAutoApproveChange,
- onUnrestrictedChange,
-}: MCPPermissionSettingsProps) {
- const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
-
- return (
-
-
-
-
{
- onAutoApproveChange(checked);
- await syncSettingsToServer();
- }}
- data-testid="mcp-auto-approve-toggle"
- className="mt-0.5"
- />
-
-
-
- When enabled, the AI agent can use MCP tools without permission prompts.
-
- {mcpAutoApproveTools && (
-
-
- Bypasses normal permission checks
-
- )}
-
-
-
-
-
{
- onUnrestrictedChange(checked);
- await syncSettingsToServer();
- }}
- data-testid="mcp-unrestricted-toggle"
- className="mt-0.5"
- />
-
-
-
- When enabled, the AI agent can use any tool, not just the default set.
-
- {mcpUnrestrictedTools && (
-
-
- Agent has full tool access including file writes and bash
-
- )}
-
-
-
- {hasAnyEnabled && (
-
-
Security Note
-
- These settings reduce security restrictions for MCP tool usage. Only enable if you
- trust all configured MCP servers.
-
-
- )}
-
-
- );
-}
diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts
index a6cd83b4..4102a69c 100644
--- a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts
+++ b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts
@@ -1,5 +1,8 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
+
+const logger = createLogger('MCPServers');
import { toast } from 'sonner';
import type { MCPServerConfig } from '@automaker/types';
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
@@ -21,16 +24,7 @@ interface PendingServerData {
}
export function useMCPServers() {
- const {
- mcpServers,
- addMCPServer,
- updateMCPServer,
- removeMCPServer,
- mcpAutoApproveTools,
- mcpUnrestrictedTools,
- setMcpAutoApproveTools,
- setMcpUnrestrictedTools,
- } = useAppStore();
+ const { mcpServers, addMCPServer, updateMCPServer, removeMCPServer } = useAppStore();
// State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -72,7 +66,7 @@ export function useMCPServers() {
// Auto-load MCP servers from settings file on mount
useEffect(() => {
loadMCPServersFromServer().catch((error) => {
- console.error('Failed to load MCP servers on mount:', error);
+ logger.error('Failed to load MCP servers on mount:', error);
});
}, []);
@@ -431,7 +425,7 @@ export function useMCPServers() {
if (serverData.type === 'stdio') {
if (!serverConfig.command) {
- console.warn(`Skipping ${name}: no command specified`);
+ logger.warn(`Skipping ${name}: no command specified`);
return null;
}
@@ -458,7 +452,7 @@ export function useMCPServers() {
}
} else {
if (!serverConfig.url) {
- console.warn(`Skipping ${name}: no url specified`);
+ logger.warn(`Skipping ${name}: no url specified`);
return null;
}
serverData.url = serverConfig.url as string;
@@ -491,7 +485,7 @@ export function useMCPServers() {
const name = config.name as string;
if (!name) {
- console.warn('Skipping server: no name specified');
+ logger.warn('Skipping server: no name specified');
skippedCount++;
continue;
}
@@ -938,10 +932,6 @@ export function useMCPServers() {
return {
// Store state
mcpServers,
- mcpAutoApproveTools,
- mcpUnrestrictedTools,
- setMcpAutoApproveTools,
- setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,
diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx
index 0cec3af4..5c06adbe 100644
--- a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx
+++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx
@@ -1,12 +1,7 @@
import { Plug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMCPServers } from './hooks';
-import {
- MCPServerHeader,
- MCPPermissionSettings,
- MCPToolsWarning,
- MCPServerCard,
-} from './components';
+import { MCPServerHeader, MCPToolsWarning, MCPServerCard } from './components';
import {
AddEditServerDialog,
DeleteServerDialog,
@@ -20,10 +15,6 @@ export function MCPServersSection() {
const {
// Store state
mcpServers,
- mcpAutoApproveTools,
- mcpUnrestrictedTools,
- setMcpAutoApproveTools,
- setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,
@@ -98,15 +89,6 @@ export function MCPServersSection() {
onAdd={handleOpenAddDialog}
/>
- {mcpServers.length > 0 && (
-
- )}
-
{showToolsWarning && }
diff --git a/apps/ui/src/components/views/settings-view/model-defaults/index.ts b/apps/ui/src/components/views/settings-view/model-defaults/index.ts
new file mode 100644
index 00000000..d60f3e75
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/model-defaults/index.ts
@@ -0,0 +1,2 @@
+export { ModelDefaultsSection } from './model-defaults-section';
+export { PhaseModelSelector } from './phase-model-selector';
diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx
new file mode 100644
index 00000000..e03a1d52
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx
@@ -0,0 +1,161 @@
+import { Workflow, RotateCcw } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import { PhaseModelSelector } from './phase-model-selector';
+import type { PhaseModelKey } from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
+
+interface PhaseConfig {
+ key: PhaseModelKey;
+ label: string;
+ description: string;
+}
+
+const QUICK_TASKS: PhaseConfig[] = [
+ {
+ key: 'enhancementModel',
+ label: 'Feature Enhancement',
+ description: 'Improves feature names and descriptions',
+ },
+ {
+ key: 'fileDescriptionModel',
+ label: 'File Descriptions',
+ description: 'Generates descriptions for context files',
+ },
+ {
+ key: 'imageDescriptionModel',
+ label: 'Image Descriptions',
+ description: 'Analyzes and describes context images',
+ },
+];
+
+const VALIDATION_TASKS: PhaseConfig[] = [
+ {
+ key: 'validationModel',
+ label: 'GitHub Issue Validation',
+ description: 'Validates and improves GitHub issues',
+ },
+];
+
+const GENERATION_TASKS: PhaseConfig[] = [
+ {
+ key: 'specGenerationModel',
+ label: 'App Specification',
+ description: 'Generates full application specifications',
+ },
+ {
+ key: 'featureGenerationModel',
+ label: 'Feature Generation',
+ description: 'Creates features from specifications',
+ },
+ {
+ key: 'backlogPlanningModel',
+ label: 'Backlog Planning',
+ description: 'Reorganizes and prioritizes backlog',
+ },
+ {
+ key: 'projectAnalysisModel',
+ label: 'Project Analysis',
+ description: 'Analyzes project structure for suggestions',
+ },
+ {
+ key: 'suggestionsModel',
+ label: 'AI Suggestions',
+ description: 'Model for feature, refactoring, security, and performance suggestions',
+ },
+];
+
+function PhaseGroup({
+ title,
+ subtitle,
+ phases,
+}: {
+ title: string;
+ subtitle: string;
+ phases: PhaseConfig[];
+}) {
+ const { phaseModels, setPhaseModel } = useAppStore();
+
+ return (
+
+
+
+ {phases.map((phase) => (
+
setPhaseModel(phase.key, model)}
+ />
+ ))}
+
+
+ );
+}
+
+export function ModelDefaultsSection() {
+ const { resetPhaseModels } = useAppStore();
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ Model Defaults
+
+
+ Configure which AI model to use for each application task
+
+
+
+
+
+ Reset to Defaults
+
+
+
+
+ {/* Content */}
+
+ {/* Quick Tasks */}
+
+
+ {/* Validation Tasks */}
+
+
+ {/* Generation Tasks */}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx
new file mode 100644
index 00000000..8294c9fb
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx
@@ -0,0 +1,666 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import type {
+ ModelAlias,
+ CursorModelId,
+ GroupedModel,
+ PhaseModelEntry,
+ ThinkingLevel,
+} from '@automaker/types';
+import {
+ stripProviderPrefix,
+ STANDALONE_CURSOR_MODELS,
+ getModelGroup,
+ isGroupSelected,
+ getSelectedVariant,
+ isCursorModel,
+} from '@automaker/types';
+import {
+ CLAUDE_MODELS,
+ CURSOR_MODELS,
+ THINKING_LEVELS,
+ THINKING_LEVEL_LABELS,
+} from '@/components/views/board-view/shared/model-constants';
+import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+
+interface PhaseModelSelectorProps {
+ /** Label shown in full mode */
+ label?: string;
+ /** Description shown in full mode */
+ description?: string;
+ /** Current model selection */
+ value: PhaseModelEntry;
+ /** Callback when model is selected */
+ onChange: (entry: PhaseModelEntry) => void;
+ /** Compact mode - just shows the button trigger without label/description wrapper */
+ compact?: boolean;
+ /** Custom trigger class name */
+ triggerClassName?: string;
+ /** Popover alignment */
+ align?: 'start' | 'end';
+ /** Disabled state */
+ disabled?: boolean;
+}
+
+export function PhaseModelSelector({
+ label,
+ description,
+ value,
+ onChange,
+ compact = false,
+ triggerClassName,
+ align = 'end',
+ disabled = false,
+}: PhaseModelSelectorProps) {
+ const [open, setOpen] = React.useState(false);
+ const [expandedGroup, setExpandedGroup] = React.useState
(null);
+ const [expandedClaudeModel, setExpandedClaudeModel] = React.useState(null);
+ const commandListRef = React.useRef(null);
+ const expandedTriggerRef = React.useRef(null);
+ const expandedClaudeTriggerRef = React.useRef(null);
+ const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
+
+ // Extract model and thinking level from value
+ const selectedModel = value.model;
+ const selectedThinkingLevel = value.thinkingLevel || 'none';
+
+ // Close expanded group when trigger scrolls out of view
+ React.useEffect(() => {
+ const triggerElement = expandedTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedGroup) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedGroup(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1, // Close when less than 10% visible
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedGroup]);
+
+ // Close expanded Claude model popover when trigger scrolls out of view
+ React.useEffect(() => {
+ const triggerElement = expandedClaudeTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedClaudeModel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedClaudeModel(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1,
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedClaudeModel]);
+
+ // Filter Cursor models to only show enabled ones
+ const availableCursorModels = CURSOR_MODELS.filter((model) => {
+ const cursorId = stripProviderPrefix(model.id) as CursorModelId;
+ return enabledCursorModels.includes(cursorId);
+ });
+
+ // Helper to find current selected model details
+ const currentModel = React.useMemo(() => {
+ const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
+ if (claudeModel) {
+ // Add thinking level to label if not 'none'
+ const thinkingLabel =
+ selectedThinkingLevel !== 'none'
+ ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
+ : '';
+ return {
+ ...claudeModel,
+ label: `${claudeModel.label}${thinkingLabel}`,
+ icon: Brain,
+ };
+ }
+
+ const cursorModel = availableCursorModels.find(
+ (m) => stripProviderPrefix(m.id) === selectedModel
+ );
+ if (cursorModel) return { ...cursorModel, icon: Sparkles };
+
+ // Check if selectedModel is part of a grouped model
+ const group = getModelGroup(selectedModel as CursorModelId);
+ if (group) {
+ const variant = getSelectedVariant(group, selectedModel as CursorModelId);
+ return {
+ id: selectedModel,
+ label: `${group.label} (${variant?.label || 'Unknown'})`,
+ description: group.description,
+ provider: 'cursor' as const,
+ icon: Sparkles,
+ };
+ }
+
+ return null;
+ }, [selectedModel, selectedThinkingLevel, availableCursorModels]);
+
+ // Compute grouped vs standalone Cursor models
+ const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
+ const grouped: GroupedModel[] = [];
+ const standalone: typeof CURSOR_MODELS = [];
+ const seenGroups = new Set();
+
+ availableCursorModels.forEach((model) => {
+ const cursorId = stripProviderPrefix(model.id) as CursorModelId;
+
+ // Check if this model is standalone
+ if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
+ standalone.push(model);
+ return;
+ }
+
+ // Check if this model belongs to a group
+ const group = getModelGroup(cursorId);
+ if (group && !seenGroups.has(group.baseId)) {
+ // Filter variants to only include enabled models
+ const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id));
+ if (enabledVariants.length > 0) {
+ grouped.push({
+ ...group,
+ variants: enabledVariants,
+ });
+ seenGroups.add(group.baseId);
+ }
+ }
+ });
+
+ return { groupedModels: grouped, standaloneCursorModels: standalone };
+ }, [availableCursorModels, enabledCursorModels]);
+
+ // Group models
+ const { favorites, claude, cursor } = React.useMemo(() => {
+ const favs: typeof CLAUDE_MODELS = [];
+ const cModels: typeof CLAUDE_MODELS = [];
+ const curModels: typeof CURSOR_MODELS = [];
+
+ // Process Claude Models
+ CLAUDE_MODELS.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ cModels.push(model);
+ }
+ });
+
+ // Process Cursor Models
+ availableCursorModels.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ curModels.push(model);
+ }
+ });
+
+ return { favorites: favs, claude: cModels, cursor: curModels };
+ }, [favoriteModels, availableCursorModels]);
+
+ // Render Cursor model item (no thinking level needed)
+ const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
+ const modelValue = stripProviderPrefix(model.id);
+ const isSelected = selectedModel === modelValue;
+ const isFavorite = favoriteModels.includes(model.id);
+
+ return (
+ {
+ onChange({ model: modelValue as CursorModelId });
+ setOpen(false);
+ }}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+ {model.description}
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+ );
+ };
+
+ // Render Claude model item with secondary popover for thinking level
+ const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => {
+ const isSelected = selectedModel === model.id;
+ const isFavorite = favoriteModels.includes(model.id);
+ const isExpanded = expandedClaudeModel === model.id;
+ const currentThinking = isSelected ? selectedThinkingLevel : 'none';
+
+ return (
+ setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedClaudeModel(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {model.label}
+
+
+ {isSelected && currentThinking !== 'none'
+ ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
+ : model.description}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ Thinking Level
+
+ {THINKING_LEVELS.map((level) => (
+
{
+ onChange({
+ model: model.id as ModelAlias,
+ thinkingLevel: level,
+ });
+ setExpandedClaudeModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {THINKING_LEVEL_LABELS[level]}
+
+ {level === 'none' && 'No extended thinking'}
+ {level === 'low' && 'Light reasoning (1k tokens)'}
+ {level === 'medium' && 'Moderate reasoning (10k tokens)'}
+ {level === 'high' && 'Deep reasoning (16k tokens)'}
+ {level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
+
+
+ {isSelected && currentThinking === level && (
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ };
+
+ // Render a grouped model with secondary popover for variant selection
+ const renderGroupedModelItem = (group: GroupedModel) => {
+ const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId);
+ const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId);
+ const isExpanded = expandedGroup === group.baseId;
+
+ const variantTypeLabel =
+ group.variantType === 'compute'
+ ? 'Compute Level'
+ : group.variantType === 'thinking'
+ ? 'Reasoning Mode'
+ : 'Capacity Options';
+
+ return (
+ setExpandedGroup(isExpanded ? null : group.baseId)}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedGroup(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {group.label}
+
+
+ {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
+
+
+
+
+
+ {groupIsSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ {variantTypeLabel}
+
+ {group.variants.map((variant) => (
+
{
+ onChange({ model: variant.id });
+ setExpandedGroup(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ selectedModel === variant.id && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {variant.label}
+ {variant.description && (
+ {variant.description}
+ )}
+
+
+ {variant.badge && (
+
+ {variant.badge}
+
+ )}
+ {selectedModel === variant.id && }
+
+
+ ))}
+
+
+
+
+ );
+ };
+
+ // Compact trigger button (for agent view etc.)
+ const compactTrigger = (
+
+ {currentModel?.icon && }
+
+ {currentModel?.label?.replace('Claude ', '') || 'Select model...'}
+
+
+
+ );
+
+ // Full trigger button (for settings view)
+ const fullTrigger = (
+
+
+ {currentModel?.icon && }
+ {currentModel?.label || 'Select model...'}
+
+
+
+ );
+
+ // The popover content (shared between both modes)
+ const popoverContent = (
+ e.stopPropagation()}
+ onPointerDownOutside={(e) => e.preventDefault()}
+ >
+
+
+
+ No model found.
+
+ {favorites.length > 0 && (
+ <>
+
+ {(() => {
+ const renderedGroups = new Set();
+ return favorites.map((model) => {
+ // Check if this favorite is part of a grouped model
+ if (model.provider === 'cursor') {
+ const cursorId = stripProviderPrefix(model.id) as CursorModelId;
+ const group = getModelGroup(cursorId);
+ if (group) {
+ // Skip if we already rendered this group
+ if (renderedGroups.has(group.baseId)) {
+ return null;
+ }
+ renderedGroups.add(group.baseId);
+ // Find the group in groupedModels (which has filtered variants)
+ const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId);
+ if (filteredGroup) {
+ return renderGroupedModelItem(filteredGroup);
+ }
+ }
+ // Standalone Cursor model
+ return renderCursorModelItem(model);
+ }
+ // Claude model
+ return renderClaudeModelItem(model);
+ });
+ })()}
+
+
+ >
+ )}
+
+ {claude.length > 0 && (
+
+ {claude.map((model) => renderClaudeModelItem(model))}
+
+ )}
+
+ {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
+
+ {/* Grouped models with secondary popover */}
+ {groupedModels.map((group) => renderGroupedModelItem(group))}
+ {/* Standalone models */}
+ {standaloneCursorModels.map((model) => renderCursorModelItem(model))}
+
+ )}
+
+
+
+ );
+
+ // Compact mode - just the popover with compact trigger
+ if (compact) {
+ return (
+
+ {compactTrigger}
+ {popoverContent}
+
+ );
+ }
+
+ // Full mode - with label and description wrapper
+ return (
+
+ {/* Label and Description */}
+
+
{label}
+
{description}
+
+
+ {/* Model Selection Popover */}
+
+ {fullTrigger}
+ {popoverContent}
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx
new file mode 100644
index 00000000..57b2fe97
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx
@@ -0,0 +1,50 @@
+import { useAppStore } from '@/store/app-store';
+import { useSetupStore } from '@/store/setup-store';
+import { useCliStatus } from '../hooks/use-cli-status';
+import { ClaudeCliStatus } from '../cli-status/claude-cli-status';
+import { ClaudeMdSettings } from '../claude/claude-md-settings';
+import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
+import { Info } from 'lucide-react';
+
+export function ClaudeSettingsTab() {
+ const { apiKeys, autoLoadClaudeMd, setAutoLoadClaudeMd } = useAppStore();
+ const { claudeAuthStatus } = useSetupStore();
+
+ // Use CLI status hook
+ const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus();
+
+ // Hide usage tracking when using API key (only show for Claude Code CLI users)
+ // Also hide on Windows for now (CLI usage command not supported)
+ const isWindows =
+ typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
+ const showUsageTracking = !apiKeys.anthropic && !isWindows;
+
+ return (
+
+ {/* Usage Info */}
+
+
+
+
Primary Provider
+
+ Claude is used throughout the app including chat, analysis, and agent tasks.
+
+
+
+
+
+
+ {showUsageTracking &&
}
+
+ );
+}
+
+export default ClaudeSettingsTab;
diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx
new file mode 100644
index 00000000..99a27be4
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx
@@ -0,0 +1,128 @@
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Terminal } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { CursorModelId, CursorModelConfig } from '@automaker/types';
+import { CURSOR_MODEL_MAP } from '@automaker/types';
+
+interface CursorModelConfigurationProps {
+ enabledCursorModels: CursorModelId[];
+ cursorDefaultModel: CursorModelId;
+ isSaving: boolean;
+ onDefaultModelChange: (model: CursorModelId) => void;
+ onModelToggle: (model: CursorModelId, enabled: boolean) => void;
+}
+
+export function CursorModelConfiguration({
+ enabledCursorModels,
+ cursorDefaultModel,
+ isSaving,
+ onDefaultModelChange,
+ onModelToggle,
+}: CursorModelConfigurationProps) {
+ // All available models from the model map
+ const availableModels: CursorModelConfig[] = Object.values(CURSOR_MODEL_MAP);
+
+ return (
+
+
+
+
+
+
+
+ Model Configuration
+
+
+
+ Configure which Cursor models are available in the feature modal
+
+
+
+ {/* Default Model */}
+
+
+
+
+
+ {/* Enabled Models */}
+
+
+
+ {availableModels.map((model) => {
+ const isEnabled = enabledCursorModels.includes(model.id);
+ const isAuto = model.id === 'auto';
+
+ return (
+
+
+
onModelToggle(model.id, !!checked)}
+ disabled={isSaving || isAuto}
+ />
+
+
+ {model.label}
+ {model.hasThinking && (
+
+ Thinking
+
+ )}
+
+
{model.description}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx
new file mode 100644
index 00000000..29be25b3
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/cursor-permissions-section.tsx
@@ -0,0 +1,253 @@
+import { useState, useEffect } from 'react';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { Shield, ShieldCheck, ShieldAlert, ChevronDown, Copy, Check } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { CursorStatus } from '../hooks/use-cursor-status';
+import type { PermissionsData } from '../hooks/use-cursor-permissions';
+
+interface CursorPermissionsSectionProps {
+ status: CursorStatus | null;
+ permissions: PermissionsData | null;
+ isLoadingPermissions: boolean;
+ isSavingPermissions: boolean;
+ copiedConfig: boolean;
+ currentProject?: { path: string } | null;
+ onApplyProfile: (
+ profileId: 'strict' | 'development',
+ scope: 'global' | 'project'
+ ) => Promise;
+ onCopyConfig: (profileId: 'strict' | 'development') => Promise;
+ onLoadPermissions: () => Promise;
+}
+
+export function CursorPermissionsSection({
+ status,
+ permissions,
+ isLoadingPermissions,
+ isSavingPermissions,
+ copiedConfig,
+ currentProject,
+ onApplyProfile,
+ onCopyConfig,
+ onLoadPermissions,
+}: CursorPermissionsSectionProps) {
+ const [permissionsExpanded, setPermissionsExpanded] = useState(false);
+
+ // Load permissions when section is expanded
+ useEffect(() => {
+ if (permissionsExpanded && status?.installed && !permissions) {
+ onLoadPermissions();
+ }
+ }, [permissionsExpanded, status?.installed, permissions, onLoadPermissions]);
+
+ if (!status?.installed) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ CLI Permissions
+
+
Configure what Cursor CLI can do
+
+
+
+ {permissions?.activeProfile && (
+
+ {permissions.activeProfile === 'strict' && (
+
+ )}
+ {permissions.activeProfile === 'development' && (
+
+ )}
+ {permissions.activeProfile}
+
+ )}
+
+
+
+
+
+
+
+ {/* Security Warning */}
+
+
+
+
Security Notice
+
+ Cursor CLI can execute shell commands based on its permission config. For
+ overnight automation, consider using the Strict profile to limit what commands can
+ run.
+
+
+
+
+ {isLoadingPermissions ? (
+
+ ) : (
+ <>
+ {/* Permission Profiles */}
+
+
+
+ {permissions?.availableProfiles.map((profile) => (
+
+
+
+
+ {profile.id === 'strict' ? (
+
+ ) : (
+
+ )}
+ {profile.name}
+ {permissions.activeProfile === profile.id && (
+
+ Active
+
+ )}
+
+
+ {profile.description}
+
+
+
+ {profile.permissions.allow.length} allowed
+
+ |
+
+ {profile.permissions.deny.length} denied
+
+
+
+
+
+ onApplyProfile(profile.id as 'strict' | 'development', 'global')
+ }
+ >
+ Apply Globally
+
+ {currentProject && (
+
+ onApplyProfile(profile.id as 'strict' | 'development', 'project')
+ }
+ >
+ Apply to Project
+
+ )}
+
+
+
+ ))}
+
+
+
+ {/* Config File Location */}
+
+
+
+
+
+
Global Config
+
+ ~/.cursor/cli-config.json
+
+
+
onCopyConfig('development')}>
+ {copiedConfig ? (
+
+ ) : (
+
+ )}
+
+
+
+
Project Config
+
+ <project>/.cursor/cli.json
+
+ {permissions?.hasProjectConfig && (
+
+ Project override active
+
+ )}
+
+
+
+
+ {/* Documentation Link */}
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx
new file mode 100644
index 00000000..c9b77375
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/cursor-settings-tab.tsx
@@ -0,0 +1,106 @@
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useAppStore } from '@/store/app-store';
+import type { CursorModelId } from '@automaker/types';
+import {
+ CursorCliStatus,
+ CursorCliStatusSkeleton,
+ CursorPermissionsSkeleton,
+ ModelConfigSkeleton,
+} from '../cli-status/cursor-cli-status';
+import { useCursorStatus } from '../hooks/use-cursor-status';
+import { useCursorPermissions } from '../hooks/use-cursor-permissions';
+import { CursorPermissionsSection } from './cursor-permissions-section';
+import { CursorModelConfiguration } from './cursor-model-configuration';
+
+export function CursorSettingsTab() {
+ // Global settings from store
+ const {
+ enabledCursorModels,
+ cursorDefaultModel,
+ setCursorDefaultModel,
+ toggleCursorModel,
+ currentProject,
+ } = useAppStore();
+
+ // Custom hooks for data fetching
+ const { status, isLoading, loadData } = useCursorStatus();
+ const {
+ permissions,
+ isLoadingPermissions,
+ isSavingPermissions,
+ copiedConfig,
+ loadPermissions,
+ applyProfile,
+ copyConfig,
+ } = useCursorPermissions(currentProject?.path);
+
+ // Local state for model configuration saving
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleDefaultModelChange = (model: CursorModelId) => {
+ setIsSaving(true);
+ try {
+ setCursorDefaultModel(model);
+ toast.success('Default model updated');
+ } catch (error) {
+ toast.error('Failed to update default model');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleModelToggle = (model: CursorModelId, enabled: boolean) => {
+ setIsSaving(true);
+ try {
+ toggleCursorModel(model, enabled);
+ } catch (error) {
+ toast.error('Failed to update models');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* CLI Status */}
+
+
+ {/* CLI Permissions Section */}
+
+
+ {/* Model Configuration - Always show (global settings) */}
+ {status?.installed && (
+
+ )}
+
+ );
+}
+
+export default CursorSettingsTab;
diff --git a/apps/ui/src/components/views/settings-view/providers/index.ts b/apps/ui/src/components/views/settings-view/providers/index.ts
new file mode 100644
index 00000000..c9284867
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/index.ts
@@ -0,0 +1,3 @@
+export { ProviderTabs } from './provider-tabs';
+export { ClaudeSettingsTab } from './claude-settings-tab';
+export { CursorSettingsTab } from './cursor-settings-tab';
diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx
new file mode 100644
index 00000000..dc97cf2f
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Bot, Terminal } from 'lucide-react';
+import { CursorSettingsTab } from './cursor-settings-tab';
+import { ClaudeSettingsTab } from './claude-settings-tab';
+
+interface ProviderTabsProps {
+ defaultTab?: 'claude' | 'cursor';
+}
+
+export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
+ return (
+
+
+
+
+ Claude
+
+
+
+ Cursor
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ProviderTabs;
diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx
index 051bcd12..6a109213 100644
--- a/apps/ui/src/components/views/setup-view.tsx
+++ b/apps/ui/src/components/views/setup-view.tsx
@@ -1,3 +1,4 @@
+import { createLogger } from '@automaker/utils/logger';
import { useSetupStore } from '@/store/setup-store';
import { StepIndicator } from './setup-view/components';
import {
@@ -5,50 +6,58 @@ import {
ThemeStep,
CompleteStep,
ClaudeSetupStep,
+ CursorSetupStep,
GitHubSetupStep,
} from './setup-view/steps';
import { useNavigate } from '@tanstack/react-router';
+const logger = createLogger('SetupView');
+
// Main Setup View
export function SetupView() {
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
const navigate = useNavigate();
- const steps = ['welcome', 'theme', 'claude', 'github', 'complete'] as const;
+ const steps = ['welcome', 'theme', 'claude', 'cursor', 'github', 'complete'] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
if (currentStep === 'welcome') return 'welcome';
if (currentStep === 'theme') return 'theme';
+ if (currentStep === 'cursor') return 'cursor';
if (currentStep === 'github') return 'github';
return 'complete';
};
const currentIndex = steps.indexOf(getStepName());
const handleNext = (from: string) => {
- console.log('[Setup Flow] handleNext called from:', from, 'currentStep:', currentStep);
+ logger.debug('[Setup Flow] handleNext called from:', from, 'currentStep:', currentStep);
switch (from) {
case 'welcome':
- console.log('[Setup Flow] Moving to theme step');
+ logger.debug('[Setup Flow] Moving to theme step');
setCurrentStep('theme');
break;
case 'theme':
- console.log('[Setup Flow] Moving to claude_detect step');
+ logger.debug('[Setup Flow] Moving to claude_detect step');
setCurrentStep('claude_detect');
break;
case 'claude':
- console.log('[Setup Flow] Moving to github step');
+ logger.debug('[Setup Flow] Moving to cursor step');
+ setCurrentStep('cursor');
+ break;
+ case 'cursor':
+ logger.debug('[Setup Flow] Moving to github step');
setCurrentStep('github');
break;
case 'github':
- console.log('[Setup Flow] Moving to complete step');
+ logger.debug('[Setup Flow] Moving to complete step');
setCurrentStep('complete');
break;
}
};
const handleBack = (from: string) => {
- console.log('[Setup Flow] handleBack called from:', from);
+ logger.debug('[Setup Flow] handleBack called from:', from);
switch (from) {
case 'theme':
setCurrentStep('welcome');
@@ -56,27 +65,35 @@ export function SetupView() {
case 'claude':
setCurrentStep('theme');
break;
- case 'github':
+ case 'cursor':
setCurrentStep('claude_detect');
break;
+ case 'github':
+ setCurrentStep('cursor');
+ break;
}
};
const handleSkipClaude = () => {
- console.log('[Setup Flow] Skipping Claude setup');
+ logger.debug('[Setup Flow] Skipping Claude setup');
setSkipClaudeSetup(true);
+ setCurrentStep('cursor');
+ };
+
+ const handleSkipCursor = () => {
+ logger.debug('[Setup Flow] Skipping Cursor setup');
setCurrentStep('github');
};
const handleSkipGithub = () => {
- console.log('[Setup Flow] Skipping GitHub setup');
+ logger.debug('[Setup Flow] Skipping GitHub setup');
setCurrentStep('complete');
};
const handleFinish = () => {
- console.log('[Setup Flow] handleFinish called - completing setup');
+ logger.debug('[Setup Flow] handleFinish called - completing setup');
completeSetup();
- console.log('[Setup Flow] Setup completed, redirecting to welcome view');
+ logger.debug('[Setup Flow] Setup completed, redirecting to welcome view');
navigate({ to: '/' });
};
@@ -114,6 +131,14 @@ export function SetupView() {
/>
)}
+ {currentStep === 'cursor' && (
+ handleNext('cursor')}
+ onBack={() => handleBack('cursor')}
+ onSkip={handleSkipCursor}
+ />
+ )}
+
{currentStep === 'github' && (
handleNext('github')}
diff --git a/apps/ui/src/components/views/setup-view/hooks/use-cli-installation.ts b/apps/ui/src/components/views/setup-view/hooks/use-cli-installation.ts
index e745ddc4..aeb57d53 100644
--- a/apps/ui/src/components/views/setup-view/hooks/use-cli-installation.ts
+++ b/apps/ui/src/components/views/setup-view/hooks/use-cli-installation.ts
@@ -1,5 +1,8 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
+import { createLogger } from '@automaker/utils/logger';
+
+const logger = createLogger('CliInstallation');
interface UseCliInstallationOptions {
cliType: 'claude';
@@ -82,7 +85,7 @@ export function useCliInstallation({
toast.error('Installation failed', { description: result.error });
}
} catch (error) {
- console.error(`Failed to install ${cliType}:`, error);
+ logger.error(`Failed to install ${cliType}:`, error);
toast.error('Installation failed');
} finally {
setIsInstalling(false);
diff --git a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts
index fbe365d1..f543f34f 100644
--- a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts
+++ b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
interface UseCliStatusOptions {
cliType: 'claude';
@@ -7,6 +8,9 @@ interface UseCliStatusOptions {
setAuthStatus: (status: any) => void;
}
+// Create logger once outside the hook to prevent infinite re-renders
+const logger = createLogger('CliStatus');
+
export function useCliStatus({
cliType,
statusApi,
@@ -16,11 +20,11 @@ export function useCliStatus({
const [isChecking, setIsChecking] = useState(false);
const checkStatus = useCallback(async () => {
- console.log(`[${cliType} Setup] Starting status check...`);
+ logger.info(`Starting status check for ${cliType}...`);
setIsChecking(true);
try {
const result = await statusApi();
- console.log(`[${cliType} Setup] Raw status result:`, result);
+ logger.info(`Raw status result for ${cliType}:`, result);
if (result.success) {
const cliStatus = {
@@ -29,7 +33,7 @@ export function useCliStatus({
version: result.version || null,
method: result.method || 'none',
};
- console.log(`[${cliType} Setup] CLI Status:`, cliStatus);
+ logger.info(`CLI Status for ${cliType}:`, cliStatus);
setCliStatus(cliStatus);
if (result.auth) {
@@ -60,7 +64,7 @@ export function useCliStatus({
}
}
} catch (error) {
- console.error(`[${cliType} Setup] Failed to check status:`, error);
+ logger.error(`Failed to check status for ${cliType}:`, error);
} finally {
setIsChecking(false);
}
diff --git a/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts b/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts
index 57dcc93d..8168f445 100644
--- a/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts
+++ b/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts
@@ -1,6 +1,9 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
+import { createLogger } from '@automaker/utils/logger';
+
+const logger = createLogger('TokenSave');
interface UseTokenSaveOptions {
provider: string; // e.g., "anthropic_oauth_token", "anthropic", "openai"
@@ -24,7 +27,7 @@ export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) {
if (setupApi?.storeApiKey) {
const result = await setupApi.storeApiKey(provider, tokenValue);
- console.log(`[Token Save] Store result for ${provider}:`, result);
+ logger.info(`Store result for ${provider}:`, result);
if (result.success) {
const tokenType = provider.includes('oauth') ? 'subscription token' : 'API key';
@@ -42,7 +45,7 @@ export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) {
return true;
}
} catch (error) {
- console.error(`[Token Save] Failed to save ${provider}:`, error);
+ logger.error(`Failed to save ${provider}:`, error);
toast.error('Failed to save token');
return false;
} finally {
diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx
index 2b66a764..529cfc02 100644
--- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx
+++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx
@@ -312,7 +312,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
- API Key Setup
+ Claude Code Setup
Configure for code generation
diff --git a/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx
new file mode 100644
index 00000000..bb7c26bd
--- /dev/null
+++ b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx
@@ -0,0 +1,371 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { createLogger } from '@automaker/utils/logger';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { useSetupStore } from '@/store/setup-store';
+import { getElectronAPI } from '@/lib/electron';
+import {
+ CheckCircle2,
+ Loader2,
+ ArrowRight,
+ ArrowLeft,
+ ExternalLink,
+ Copy,
+ RefreshCw,
+ AlertTriangle,
+ Terminal,
+ XCircle,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import { StatusBadge } from '../components';
+
+const logger = createLogger('CursorSetupStep');
+
+interface CursorSetupStepProps {
+ onNext: () => void;
+ onBack: () => void;
+ onSkip: () => void;
+}
+
+interface CursorCliStatus {
+ installed: boolean;
+ version?: string | null;
+ path?: string | null;
+ auth?: {
+ authenticated: boolean;
+ method: string;
+ };
+ installCommand?: string;
+ loginCommand?: string;
+}
+
+export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps) {
+ const { cursorCliStatus, setCursorCliStatus } = useSetupStore();
+ const [isChecking, setIsChecking] = useState(false);
+ const [isLoggingIn, setIsLoggingIn] = useState(false);
+ const pollIntervalRef = useRef(null);
+
+ const checkStatus = useCallback(async () => {
+ setIsChecking(true);
+ try {
+ const api = getElectronAPI();
+ if (!api.setup?.getCursorStatus) {
+ return;
+ }
+ const result = await api.setup.getCursorStatus();
+ if (result.success) {
+ const status: CursorCliStatus = {
+ installed: result.installed ?? false,
+ version: result.version,
+ path: result.path,
+ auth: result.auth,
+ installCommand: result.installCommand,
+ loginCommand: result.loginCommand,
+ };
+ setCursorCliStatus(status);
+
+ if (result.auth?.authenticated) {
+ toast.success('Cursor CLI is ready!');
+ }
+ }
+ } catch (error) {
+ logger.error('Failed to check Cursor status:', error);
+ } finally {
+ setIsChecking(false);
+ }
+ }, [setCursorCliStatus]);
+
+ useEffect(() => {
+ checkStatus();
+ // Cleanup polling on unmount
+ return () => {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ }
+ };
+ }, [checkStatus]);
+
+ const copyCommand = (command: string) => {
+ navigator.clipboard.writeText(command);
+ toast.success('Command copied to clipboard');
+ };
+
+ const handleLogin = async () => {
+ setIsLoggingIn(true);
+
+ try {
+ // Copy login command to clipboard and show instructions
+ const loginCommand = cursorCliStatus?.loginCommand || 'cursor-agent login';
+ await navigator.clipboard.writeText(loginCommand);
+ toast.info('Login command copied! Paste in terminal to authenticate.');
+
+ // Poll for auth status
+ let attempts = 0;
+ const maxAttempts = 60; // 2 minutes with 2s interval
+
+ pollIntervalRef.current = setInterval(async () => {
+ attempts++;
+
+ try {
+ const api = getElectronAPI();
+ if (!api.setup?.getCursorStatus) {
+ return;
+ }
+ const result = await api.setup.getCursorStatus();
+
+ if (result.auth?.authenticated) {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ pollIntervalRef.current = null;
+ }
+ setCursorCliStatus({
+ ...cursorCliStatus,
+ installed: result.installed ?? true,
+ version: result.version,
+ path: result.path,
+ auth: result.auth,
+ } as CursorCliStatus);
+ setIsLoggingIn(false);
+ toast.success('Successfully logged in to Cursor!');
+ }
+ } catch {
+ // Ignore polling errors
+ }
+
+ if (attempts >= maxAttempts) {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ pollIntervalRef.current = null;
+ }
+ setIsLoggingIn(false);
+ toast.error('Login timed out. Please try again.');
+ }
+ }, 2000);
+ } catch (error) {
+ logger.error('Login failed:', error);
+ toast.error('Failed to start login process');
+ setIsLoggingIn(false);
+ }
+ };
+
+ const isReady = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
+
+ const getStatusBadge = () => {
+ if (isChecking) {
+ return ;
+ }
+ if (cursorCliStatus?.auth?.authenticated) {
+ return ;
+ }
+ if (cursorCliStatus?.installed) {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+
+
+
+
+
+
Cursor CLI Setup
+
Optional - Use Cursor as an AI provider
+
+
+ {/* Info Banner */}
+
+
+
+
+
+
This step is optional
+
+ Configure Cursor CLI as an alternative AI provider. You can skip this and use Claude
+ instead, or configure it later in Settings.
+
+
+
+
+
+
+ {/* Status Card */}
+
+
+
+
+
+ Cursor CLI Status
+
+ Optional
+
+
+
+ {getStatusBadge()}
+
+
+
+
+
+
+ {cursorCliStatus?.installed
+ ? cursorCliStatus.auth?.authenticated
+ ? `Authenticated via ${cursorCliStatus.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}${cursorCliStatus.version ? ` (v${cursorCliStatus.version})` : ''}`
+ : 'Installed but not authenticated'
+ : 'Not installed on your system'}
+
+
+
+ {/* Success State */}
+ {isReady && (
+
+
+
+
Cursor CLI is ready!
+
+ You can use Cursor models for AI tasks.
+ {cursorCliStatus?.version && (
+ Version: {cursorCliStatus.version}
+ )}
+
+
+
+ )}
+
+ {/* Not Installed */}
+ {!cursorCliStatus?.installed && !isChecking && (
+
+
+
+
+
Cursor CLI not found
+
+ Install the Cursor CLI to use Cursor models.
+
+
+
+
+
+
Install Cursor CLI:
+
+
+ {cursorCliStatus?.installCommand ||
+ 'curl https://cursor.com/install -fsS | bash'}
+
+
+ copyCommand(
+ cursorCliStatus?.installCommand ||
+ 'curl https://cursor.com/install -fsS | bash'
+ )
+ }
+ >
+
+
+
+
+ View installation docs
+
+
+
+
+ )}
+
+ {/* Installed but not authenticated */}
+ {cursorCliStatus?.installed && !cursorCliStatus?.auth?.authenticated && !isChecking && (
+
+
+
+
+
Cursor CLI not authenticated
+
+ Run the login command to authenticate with Cursor.
+
+
+
+
+
+
+ Run the login command in your terminal, then complete authentication in your
+ browser:
+
+
+
+ {cursorCliStatus?.loginCommand || 'cursor-agent login'}
+
+
+ copyCommand(cursorCliStatus?.loginCommand || 'cursor-agent login')
+ }
+ >
+
+
+
+
+ {isLoggingIn ? (
+ <>
+
+ Waiting for login...
+ >
+ ) : (
+ 'Copy Command & Wait for Login'
+ )}
+
+
+
+ )}
+
+ {/* Loading State */}
+ {isChecking && (
+
+
+
+
Checking Cursor CLI status...
+
+
+ )}
+
+
+
+ {/* Navigation */}
+
+
+
+ Back
+
+
+
+ {isReady ? 'Skip' : 'Skip for now'}
+
+
+ {isReady ? 'Continue' : 'Continue without Cursor'}
+
+
+
+
+
+ {/* Info note */}
+
+ You can always configure Cursor later in Settings
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx
index 05b70e53..fcccb618 100644
--- a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx
+++ b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useSetupStore } from '@/store/setup-store';
@@ -18,6 +19,8 @@ import {
import { toast } from 'sonner';
import { StatusBadge } from '../components';
+const logger = createLogger('GitHubSetupStep');
+
interface GitHubSetupStepProps {
onNext: () => void;
onBack: () => void;
@@ -46,7 +49,7 @@ export function GitHubSetupStep({ onNext, onBack, onSkip }: GitHubSetupStepProps
});
}
} catch (error) {
- console.error('Failed to check gh status:', error);
+ logger.error('Failed to check gh status:', error);
} finally {
setIsChecking(false);
}
diff --git a/apps/ui/src/components/views/setup-view/steps/index.ts b/apps/ui/src/components/views/setup-view/steps/index.ts
index 28bf064c..8293eda1 100644
--- a/apps/ui/src/components/views/setup-view/steps/index.ts
+++ b/apps/ui/src/components/views/setup-view/steps/index.ts
@@ -3,4 +3,5 @@ export { WelcomeStep } from './welcome-step';
export { ThemeStep } from './theme-step';
export { CompleteStep } from './complete-step';
export { ClaudeSetupStep } from './claude-setup-step';
+export { CursorSetupStep } from './cursor-setup-step';
export { GitHubSetupStep } from './github-setup-step';
diff --git a/apps/ui/src/components/views/setup-view/steps/theme-step.tsx b/apps/ui/src/components/views/setup-view/steps/theme-step.tsx
index 835678ef..2698ca7c 100644
--- a/apps/ui/src/components/views/setup-view/steps/theme-step.tsx
+++ b/apps/ui/src/components/views/setup-view/steps/theme-step.tsx
@@ -11,7 +11,7 @@ interface ThemeStepProps {
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
- const { theme, setTheme, setPreviewTheme } = useAppStore();
+ const { theme, setTheme, setPreviewTheme, currentProject, setProjectTheme } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const handleThemeHover = (themeValue: string) => {
@@ -24,6 +24,11 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
+ // Also update the current project's theme if one exists
+ // This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme
+ if (currentProject) {
+ setProjectTheme(currentProject.id, themeValue as typeof theme);
+ }
setPreviewTheme(null);
};
diff --git a/apps/ui/src/components/views/spec-view.tsx b/apps/ui/src/components/views/spec-view.tsx
index 1a2d24ca..189e0f9a 100644
--- a/apps/ui/src/components/views/spec-view.tsx
+++ b/apps/ui/src/components/views/spec-view.tsx
@@ -14,7 +14,7 @@ export function SpecView() {
const { currentProject, appSpec } = useAppStore();
// Loading state
- const { isLoading, specExists, loadSpec } = useSpecLoading();
+ const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
// Save state
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
@@ -82,15 +82,20 @@ export function SpecView() {
);
}
- // Empty state - no spec exists
- if (!specExists) {
+ // Empty state - no spec exists or generation is running
+ // When generation is running, we skip loading the spec to avoid 500 errors,
+ // so we show the empty state with generation indicator
+ if (!specExists || isGenerationRunning) {
+ // If generation is running (from loading hook check), ensure we show the generating UI
+ const showAsGenerating = isCreating || isGenerationRunning;
+
return (
<>
setShowCreateDialog(true)}
/>
diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
index 4e792853..7507ce2c 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
@@ -1,5 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
+
+const logger = createLogger('SpecGeneration');
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { CheckCircle2 } from 'lucide-react';
@@ -79,7 +82,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
}
const status = await api.specRegeneration.status();
- console.log(
+ logger.debug(
'[useSpecGeneration] Status check on mount:',
status,
'for project:',
@@ -87,7 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
);
if (status.success && status.isRunning) {
- console.log(
+ logger.debug(
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
);
@@ -103,7 +106,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
clearTimeout(pendingStatusTimeoutRef.current);
}
pendingStatusTimeoutRef.current = setTimeout(() => {
- console.log(
+ logger.debug(
'[useSpecGeneration] No events received for current project - clearing tentative state'
);
setIsCreating(false);
@@ -118,7 +121,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
stateRestoredRef.current = false;
}
} catch (error) {
- console.error('[useSpecGeneration] Failed to check status:', error);
+ logger.error('[useSpecGeneration] Failed to check status:', error);
} finally {
statusCheckRef.current = false;
}
@@ -141,10 +144,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
- console.log('[useSpecGeneration] Visibility change - status check:', status);
+ logger.debug('[useSpecGeneration] Visibility change - status check:', status);
if (!status.isRunning) {
- console.log(
+ logger.debug(
'[useSpecGeneration] Visibility change: Backend indicates generation complete - clearing state'
);
setIsCreating(false);
@@ -157,7 +160,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setCurrentPhase(status.currentPhase);
}
} catch (error) {
- console.error('[useSpecGeneration] Failed to check status on visibility change:', error);
+ logger.error('[useSpecGeneration] Failed to check status on visibility change:', error);
}
}
};
@@ -180,7 +183,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const status = await api.specRegeneration.status();
if (!status.isRunning) {
- console.log(
+ logger.debug(
'[useSpecGeneration] Periodic check: Backend indicates generation complete - clearing state'
);
setIsCreating(false);
@@ -190,14 +193,14 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
stateRestoredRef.current = false;
loadSpec();
} else if (status.currentPhase && status.currentPhase !== currentPhase) {
- console.log('[useSpecGeneration] Periodic check: Phase updated from backend', {
+ logger.debug('[useSpecGeneration] Periodic check: Phase updated from backend', {
old: currentPhase,
new: status.currentPhase,
});
setCurrentPhase(status.currentPhase);
}
} catch (error) {
- console.error('[useSpecGeneration] Periodic status check error:', error);
+ logger.error('[useSpecGeneration] Periodic status check error:', error);
}
}, STATUS_CHECK_INTERVAL_MS);
@@ -214,7 +217,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
- console.log(
+ logger.debug(
'[useSpecGeneration] Regeneration event:',
event.type,
'for project:',
@@ -224,14 +227,14 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
);
if (event.projectPath !== currentProject?.path) {
- console.log('[useSpecGeneration] Ignoring event - not for current project');
+ logger.debug('[useSpecGeneration] Ignoring event - not for current project');
return;
}
if (pendingStatusTimeoutRef.current) {
clearTimeout(pendingStatusTimeoutRef.current);
pendingStatusTimeoutRef.current = null;
- console.log(
+ logger.debug(
'[useSpecGeneration] Event confirmed this is for current project - clearing timeout'
);
}
@@ -244,10 +247,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
- console.log(`[useSpecGeneration] Phase updated: ${phase}`);
+ logger.debug(`[useSpecGeneration] Phase updated: ${phase}`);
if (phase === 'complete') {
- console.log('[useSpecGeneration] Phase is complete - clearing state');
+ logger.debug('[useSpecGeneration] Phase is complete - clearing state');
setIsCreating(false);
setIsRegenerating(false);
stateRestoredRef.current = false;
@@ -261,7 +264,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
event.content.includes('All tasks completed') ||
event.content.includes('✓ All tasks completed')
) {
- console.log(
+ logger.debug(
'[useSpecGeneration] Detected completion in progress message - clearing state'
);
setIsCreating(false);
@@ -276,7 +279,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
- console.log('[useSpecGeneration] Progress:', event.content.substring(0, 100));
+ logger.debug('[useSpecGeneration] Progress:', event.content.substring(0, 100));
if (errorMessage) {
setErrorMessage('');
@@ -292,7 +295,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setCurrentPhase('feature_generation');
setIsCreating(true);
setIsRegenerating(true);
- console.log(
+ logger.debug(
'[useSpecGeneration] Detected feature creation tool - setting phase to feature_generation'
);
}
@@ -305,7 +308,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const newLog = logsRef.current + toolLog;
logsRef.current = newLog;
setLogs(newLog);
- console.log('[useSpecGeneration] Tool:', event.tool, event.input);
+ logger.debug('[useSpecGeneration] Tool:', event.tool, event.input);
} else if (event.type === 'spec_regeneration_complete') {
const completionLog = logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
@@ -328,7 +331,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
(isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
if (shouldComplete) {
- console.log('[useSpecGeneration] Final completion detected - clearing state', {
+ logger.debug('[useSpecGeneration] Final completion detected - clearing state', {
isFinalCompletionMessage,
hasCompletePhase,
message: event.message,
@@ -367,12 +370,12 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsCreating(true);
setIsRegenerating(true);
setCurrentPhase('feature_generation');
- console.log(
+ logger.debug(
'[useSpecGeneration] Intermediate completion, continuing with feature generation'
);
}
- console.log('[useSpecGeneration] Spec generation event:', event.message);
+ logger.debug('[useSpecGeneration] Spec generation event:', event.message);
} else if (event.type === 'spec_regeneration_error') {
setIsRegenerating(false);
setIsCreating(false);
@@ -383,7 +386,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
- console.error('[useSpecGeneration] Regeneration error:', event.error);
+ logger.error('[useSpecGeneration] Regeneration error:', event.error);
}
});
@@ -402,11 +405,11 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setErrorMessage('');
logsRef.current = '';
setLogs('');
- console.log('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
+ logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
- console.error('[useSpecGeneration] Spec regeneration not available');
+ logger.error('[useSpecGeneration] Spec regeneration not available');
setIsCreating(false);
return;
}
@@ -420,7 +423,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
- console.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
@@ -430,7 +433,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
- console.error('[useSpecGeneration] Failed to create spec:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
@@ -455,14 +458,14 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setErrorMessage('');
logsRef.current = '';
setLogs('');
- console.log(
+ logger.debug(
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
generateFeaturesOnRegenerate
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
- console.error('[useSpecGeneration] Spec regeneration not available');
+ logger.error('[useSpecGeneration] Spec regeneration not available');
setIsRegenerating(false);
return;
}
@@ -476,7 +479,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
- console.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
@@ -486,7 +489,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
- console.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
@@ -511,11 +514,11 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setErrorMessage('');
logsRef.current = '';
setLogs('');
- console.log('[useSpecGeneration] Starting feature generation from existing spec');
+ logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
- console.error('[useSpecGeneration] Spec regeneration not available');
+ logger.error('[useSpecGeneration] Spec regeneration not available');
setIsGeneratingFeatures(false);
return;
}
@@ -523,7 +526,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
- console.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
@@ -533,7 +536,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
- console.error('[useSpecGeneration] Failed to generate features:', errorMsg);
+ logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
index e716f847..52826020 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
@@ -1,11 +1,15 @@
import { useEffect, useState, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
+
+const logger = createLogger('SpecLoading');
import { getElectronAPI } from '@/lib/electron';
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [specExists, setSpecExists] = useState(true);
+ const [isGenerationRunning, setIsGenerationRunning] = useState(false);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
@@ -13,6 +17,21 @@ export function useSpecLoading() {
setIsLoading(true);
try {
const api = getElectronAPI();
+
+ // Check if spec generation is running before trying to load
+ // This prevents showing "No App Specification Found" during generation
+ if (api.specRegeneration) {
+ const status = await api.specRegeneration.status();
+ if (status.success && status.isRunning) {
+ logger.debug('Spec generation is running, skipping load');
+ setIsGenerationRunning(true);
+ setIsLoading(false);
+ return;
+ }
+ }
+ // Always reset when generation is not running (handles edge case where api.specRegeneration might not be available)
+ setIsGenerationRunning(false);
+
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
if (result.success && result.content) {
@@ -24,7 +43,7 @@ export function useSpecLoading() {
setSpecExists(false);
}
} catch (error) {
- console.error('Failed to load spec:', error);
+ logger.error('Failed to load spec:', error);
setSpecExists(false);
} finally {
setIsLoading(false);
@@ -39,6 +58,7 @@ export function useSpecLoading() {
isLoading,
specExists,
setSpecExists,
+ isGenerationRunning,
loadSpec,
};
}
diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
index b8ba3351..5b0bbb47 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
@@ -1,5 +1,8 @@
import { useState } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
+
+const logger = createLogger('SpecSave');
import { getElectronAPI } from '@/lib/electron';
export function useSpecSave() {
@@ -16,7 +19,7 @@ export function useSpecSave() {
await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec);
setHasChanges(false);
} catch (error) {
- console.error('Failed to save spec:', error);
+ logger.error('Failed to save spec:', error);
} finally {
setIsSaving(false);
}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx
index 1b0c19b7..0cad0408 100644
--- a/apps/ui/src/components/views/terminal-view.tsx
+++ b/apps/ui/src/components/views/terminal-view.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import {
Terminal as TerminalIcon,
Plus,
@@ -50,6 +51,8 @@ import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
+const logger = createLogger('Terminal');
+
interface TerminalStatus {
enabled: boolean;
passwordRequired: boolean;
@@ -301,7 +304,7 @@ export function TerminalView() {
headers['X-Terminal-Token'] = terminalState.authToken;
}
- console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`);
+ logger.info(`Killing ${sessionIds.length} sessions on server`);
// Kill all sessions in parallel
await Promise.allSettled(
@@ -309,7 +312,7 @@ export function TerminalView() {
try {
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
- console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
+ logger.error(`Failed to kill session ${sessionId}:`, err);
}
})
);
@@ -320,7 +323,7 @@ export function TerminalView() {
const canCreateTerminal = (debounceMessage: string): boolean => {
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
- console.log(debounceMessage);
+ logger.debug(debounceMessage);
return false;
}
lastCreateTimeRef.current = now;
@@ -447,7 +450,7 @@ export function TerminalView() {
}
} catch (err) {
setError('Failed to connect to server');
- console.error('[Terminal] Status fetch error:', err);
+ logger.error('Status fetch error:', err);
} finally {
setLoading(false);
}
@@ -469,7 +472,7 @@ export function TerminalView() {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
}
} catch (err) {
- console.error('[Terminal] Failed to fetch server settings:', err);
+ logger.error('Failed to fetch server settings:', err);
}
}, [terminalState.isUnlocked, terminalState.authToken]);
@@ -573,7 +576,7 @@ export function TerminalView() {
// If no saved layout or no tabs, we're done - terminal starts fresh for this project
if (!savedLayout || savedLayout.tabs.length === 0) {
- console.log('[Terminal] No saved layout for project, starting fresh');
+ logger.info('No saved layout for project, starting fresh');
return;
}
@@ -585,7 +588,7 @@ export function TerminalView() {
const restoreLayout = async () => {
// Check if we're still restoring the same project (user may have switched)
if (restoringProjectPathRef.current !== currentPath) {
- console.log('[Terminal] Restore cancelled - project changed');
+ logger.info('Restore cancelled - project changed');
return;
}
@@ -623,7 +626,7 @@ export function TerminalView() {
);
return data.success && data.data ? data.data.id : null;
} catch (err) {
- console.error('[Terminal] Failed to create terminal session:', err);
+ logger.error('Failed to create terminal session:', err);
return null;
}
};
@@ -691,7 +694,7 @@ export function TerminalView() {
for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) {
// Check if project changed during restore - bail out early
if (restoringProjectPathRef.current !== currentPath) {
- console.log('[Terminal] Restore cancelled mid-loop - project changed');
+ logger.info('Restore cancelled mid-loop - project changed');
return;
}
@@ -730,7 +733,7 @@ export function TerminalView() {
});
}
} catch (err) {
- console.error('[Terminal] Failed to restore terminal layout:', err);
+ logger.error('Failed to restore terminal layout:', err);
toast.error('Failed to restore terminals', {
description: 'Could not restore terminal layout. Please try creating new terminals.',
duration: 5000,
@@ -806,7 +809,7 @@ export function TerminalView() {
}
} catch (err) {
setAuthError('Failed to authenticate');
- console.error('[Terminal] Auth error:', err);
+ logger.error('Auth error:', err);
} finally {
setAuthLoading(false);
}
@@ -851,14 +854,14 @@ export function TerminalView() {
`Please close unused terminals. Limit: ${data.maxSessions || 'unknown'}`,
});
} else {
- console.error('[Terminal] Failed to create session:', data.error);
+ logger.error('Failed to create session:', data.error);
toast.error('Failed to create terminal', {
description: data.error || 'Unknown error',
});
}
}
} catch (err) {
- console.error('[Terminal] Create session error:', err);
+ logger.error('Create session error:', err);
toast.error('Failed to create terminal', {
description: 'Could not connect to server',
});
@@ -915,7 +918,7 @@ export function TerminalView() {
}
}
} catch (err) {
- console.error('[Terminal] Create session error:', err);
+ logger.error('Create session error:', err);
// Remove the empty tab on error
const { removeTerminalTab } = useAppStore.getState();
removeTerminalTab(tabId);
@@ -943,16 +946,13 @@ export function TerminalView() {
if (!response.ok && response.status !== 404) {
// Log non-404 errors but still proceed with UI cleanup
const data = await response.json().catch(() => ({}));
- console.error(
- '[Terminal] Server failed to kill session:',
- data.error || response.statusText
- );
+ logger.error('Server failed to kill session:', data.error || response.statusText);
}
// Refresh session count
fetchServerSettings();
} catch (err) {
- console.error('[Terminal] Kill session error:', err);
+ logger.error('Kill session error:', err);
// Still remove from UI on network error - better UX than leaving broken terminal
removeTerminalFromLayout(sessionId);
}
@@ -983,7 +983,7 @@ export function TerminalView() {
try {
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
- console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
+ logger.error(`Failed to kill session ${sessionId}:`, err);
}
})
);
@@ -1210,9 +1210,7 @@ export function TerminalView() {
onSessionInvalid={() => {
// Auto-remove stale session when server says it doesn't exist
// This handles cases like server restart where sessions are lost
- console.log(
- `[Terminal] Session ${content.sessionId} is invalid, removing from layout`
- );
+ logger.info(`Session ${content.sessionId} is invalid, removing from layout`);
killTerminal(content.sessionId);
}}
isDragging={activeDragId === content.sessionId}
@@ -1587,9 +1585,7 @@ export function TerminalView() {
onNewTab={createTerminalInNewTab}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
- console.log(
- `[Terminal] Maximized session ${sessionId} is invalid, removing from layout`
- );
+ logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
killTerminal(sessionId);
}}
isDragging={false}
diff --git a/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx
index e928036e..32f82e34 100644
--- a/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx
+++ b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx
@@ -1,8 +1,11 @@
import React, { Component, ErrorInfo } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+const logger = createLogger('TerminalErrorBoundary');
+
interface Props {
children: React.ReactNode;
sessionId: string;
@@ -30,7 +33,7 @@ export class TerminalErrorBoundary extends Component {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- console.error('[TerminalErrorBoundary] Terminal crashed:', {
+ logger.error('Terminal crashed:', {
sessionId: this.props.sessionId,
error: error.message,
stack: error.stack,
diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
index 674c87de..8e0f6b96 100644
--- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
+++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import {
X,
SplitSquareHorizontal,
@@ -42,6 +43,8 @@ import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
+const logger = createLogger('Terminal');
+
// Font size constraints
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
@@ -296,7 +299,7 @@ export function TerminalPanel({
toast.success('Copied to clipboard');
return true;
} catch (err) {
- console.error('[Terminal] Copy failed:', err);
+ logger.error('Copy failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error('Copy failed', {
description: errorMessage.includes('permission')
@@ -361,7 +364,7 @@ export function TerminalPanel({
await sendTextInChunks(text);
} catch (err) {
- console.error('[Terminal] Paste failed:', err);
+ logger.error('Paste failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error('Paste failed', {
description: errorMessage.includes('permission')
@@ -504,7 +507,7 @@ export function TerminalPanel({
});
if (!response.ok) {
- console.warn('[Terminal] Failed to fetch wsToken:', response.status);
+ logger.warn('Failed to fetch wsToken:', response.status);
return null;
}
@@ -515,7 +518,7 @@ export function TerminalPanel({
return null;
} catch (error) {
- console.error('[Terminal] Error fetching wsToken:', error);
+ logger.error('Error fetching wsToken:', error);
return null;
}
}, [serverUrl]);
@@ -595,7 +598,7 @@ export function TerminalPanel({
const api = getElectronAPI();
if (api?.openExternalLink) {
api.openExternalLink(uri).catch((error) => {
- console.error('[Terminal] Failed to open URL:', error);
+ logger.error('Failed to open URL:', error);
// Fallback to window.open if Electron API fails
window.open(uri, '_blank', 'noopener,noreferrer');
});
@@ -697,7 +700,7 @@ export function TerminalPanel({
}
} catch {
// If we can't get home path, just use the path as-is
- console.warn('[Terminal] Could not resolve home directory path');
+ logger.warn('Could not resolve home directory path');
}
} else if (!clickedPath.startsWith('/') && !clickedPath.match(/^[a-zA-Z]:\\/)) {
// Relative path - resolve against project path
@@ -721,7 +724,7 @@ export function TerminalPanel({
toast.error('Failed to open in editor', { description: result.error });
}
} catch (error) {
- console.error('[Terminal] Failed to open file:', error);
+ logger.error('Failed to open file:', error);
toast.error('Failed to open file', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -744,7 +747,7 @@ export function TerminalPanel({
});
terminal.loadAddon(webglAddon);
} catch {
- console.warn('[Terminal] WebGL addon not available, falling back to canvas');
+ logger.warn('WebGL addon not available, falling back to canvas');
}
// Fit terminal to container - wait for stable dimensions
@@ -770,7 +773,7 @@ export function TerminalPanel({
try {
fitAddon.fit();
} catch (err) {
- console.error('[Terminal] Initial fit error:', err);
+ logger.error('Initial fit error:', err);
}
return;
}
@@ -1000,7 +1003,7 @@ export function TerminalPanel({
wsRef.current = ws;
ws.onopen = () => {
- console.log(`[Terminal] WebSocket connected for session ${sessionId}`);
+ logger.info(`WebSocket connected for session ${sessionId}`);
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
@@ -1037,7 +1040,7 @@ export function TerminalPanel({
}
break;
case 'connected': {
- console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
+ logger.info(`Session connected: ${msg.shell} in ${msg.cwd}`);
// Detect shell type from path
const shellPath = (msg.shell || '').toLowerCase();
// Windows shells use backslash paths and include powershell/pwsh/cmd
@@ -1088,16 +1091,12 @@ export function TerminalPanel({
break;
}
} catch (err) {
- console.error('[Terminal] Message parse error:', err);
+ logger.error('Message parse error:', err);
}
};
ws.onclose = (event) => {
- console.log(
- `[Terminal] WebSocket closed for session ${sessionId}:`,
- event.code,
- event.reason
- );
+ logger.info(`WebSocket closed for session ${sessionId}: ${event.code} ${event.reason}`);
wsRef.current = null;
// Clear heartbeat interval
@@ -1167,8 +1166,8 @@ export function TerminalPanel({
// Attempt reconnect after exponential delay
reconnectTimeoutRef.current = setTimeout(() => {
if (xtermRef.current) {
- console.log(
- `[Terminal] Attempting reconnect for session ${sessionId} (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
+ logger.info(
+ `Attempting reconnect for session ${sessionId} (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`
);
connect();
}
@@ -1176,7 +1175,7 @@ export function TerminalPanel({
};
ws.onerror = (error) => {
- console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error);
+ logger.error(`WebSocket error for session ${sessionId}:`, error);
};
};
@@ -1234,7 +1233,7 @@ export function TerminalPanel({
wsRef.current.send(JSON.stringify({ type: 'resize', cols, rows }));
}
} catch (err) {
- console.error('[Terminal] Resize error:', err);
+ logger.error('Resize error:', err);
}
}, RESIZE_DEBOUNCE_MS);
}, []);
@@ -1551,7 +1550,7 @@ export function TerminalPanel({
const api = getElectronAPI();
if (!api.saveImageToTemp) {
// Fallback path when Electron API is not available (browser mode)
- console.warn('[Terminal] saveImageToTemp not available, returning fallback path');
+ logger.warn('saveImageToTemp not available, returning fallback path');
return `.automaker/images/${Date.now()}_${filename}`;
}
@@ -1560,10 +1559,10 @@ export function TerminalPanel({
if (result.success && result.path) {
return result.path;
}
- console.error('[Terminal] Failed to save image:', result.error);
+ logger.error('Failed to save image:', result.error);
return null;
} catch (error) {
- console.error('[Terminal] Error saving image:', error);
+ logger.error('Error saving image:', error);
return null;
}
},
@@ -1662,7 +1661,7 @@ export function TerminalPanel({
toast.error(`Failed to save: ${file.name}`);
}
} catch (error) {
- console.error('[Terminal] Error processing image:', error);
+ logger.error('Error processing image:', error);
toast.error(`Error processing: ${file.name}`);
}
}
diff --git a/apps/ui/src/components/views/welcome-view.tsx b/apps/ui/src/components/views/welcome-view.tsx
index cc9afd60..33eb895c 100644
--- a/apps/ui/src/components/views/welcome-view.tsx
+++ b/apps/ui/src/components/views/welcome-view.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -34,6 +35,8 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import type { StarterTemplate } from '@/lib/templates';
import { useNavigate } from '@tanstack/react-router';
+const logger = createLogger('WelcomeView');
+
export function WelcomeView() {
const {
projects,
@@ -65,13 +68,13 @@ export function WelcomeView() {
const api = getElectronAPI();
if (!api.autoMode?.analyzeProject) {
- console.log('[Welcome] Auto mode API not available, skipping analysis');
+ logger.info('[Welcome] Auto mode API not available, skipping analysis');
return;
}
setIsAnalyzing(true);
try {
- console.log('[Welcome] Starting project analysis for:', projectPath);
+ logger.info('[Welcome] Starting project analysis for:', projectPath);
const result = await api.autoMode.analyzeProject(projectPath);
if (result.success) {
@@ -79,10 +82,10 @@ export function WelcomeView() {
description: 'AI agent has analyzed your project structure',
});
} else {
- console.error('[Welcome] Project analysis failed:', result.error);
+ logger.error('[Welcome] Project analysis failed:', result.error);
}
} catch (error) {
- console.error('[Welcome] Failed to analyze project:', error);
+ logger.error('[Welcome] Failed to analyze project:', error);
} finally {
setIsAnalyzing(false);
}
@@ -125,8 +128,8 @@ export function WelcomeView() {
setShowInitDialog(true);
// Kick off agent to analyze the project and update app_spec.txt
- console.log('[Welcome] Project initialized, created files:', initResult.createdFiles);
- console.log('[Welcome] Kicking off project analysis agent...');
+ logger.info('[Welcome] Project initialized, created files:', initResult.createdFiles);
+ logger.info('[Welcome] Kicking off project analysis agent...');
// Start analysis in background (don't await, let it run async)
analyzeProject(path);
@@ -139,7 +142,7 @@ export function WelcomeView() {
// Navigate to the board view
navigate({ to: '/board' });
} catch (error) {
- console.error('[Welcome] Failed to open project:', error);
+ logger.error('[Welcome] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -179,7 +182,7 @@ export function WelcomeView() {
}
}
} catch (error) {
- console.error('[Welcome] Failed to check workspace config:', error);
+ logger.error('[Welcome] Failed to check workspace config:', error);
// Fall back to current behavior on error
const api = getElectronAPI();
const result = await api.openDirectory();
@@ -317,7 +320,7 @@ export function WelcomeView() {
});
setShowInitDialog(true);
} catch (error) {
- console.error('Failed to create project:', error);
+ logger.error('Failed to create project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -418,7 +421,7 @@ export function WelcomeView() {
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
- console.error('Failed to create project from template:', error);
+ logger.error('Failed to create project from template:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
@@ -515,7 +518,7 @@ export function WelcomeView() {
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
- console.error('Failed to create project from custom URL:', error);
+ logger.error('Failed to create project from custom URL:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts
index 8f2264d6..8a354b3d 100644
--- a/apps/ui/src/hooks/index.ts
+++ b/apps/ui/src/hooks/index.ts
@@ -1,6 +1,7 @@
export { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings';
export { useElectronAgent } from './use-electron-agent';
+export { useGuidedPrompts } from './use-guided-prompts';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
export { useMessageQueue } from './use-message-queue';
export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection';
diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts
index a8a5808a..d1ff588c 100644
--- a/apps/ui/src/hooks/use-auto-mode.ts
+++ b/apps/ui/src/hooks/use-auto-mode.ts
@@ -1,9 +1,12 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
+const logger = createLogger('AutoMode');
+
// Type guard for plan_approval_required event
function isPlanApprovalEvent(
event: AutoModeEvent
@@ -67,7 +70,7 @@ export function useAutoMode() {
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
- console.log('[AutoMode Event]', event);
+ logger.info('Event:', event);
// Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event
@@ -84,7 +87,7 @@ export function useAutoMode() {
// Skip event if we couldn't determine the project
if (!eventProjectId) {
- console.warn('[AutoMode] Could not determine project for event:', event);
+ logger.warn('Could not determine project for event:', event);
return;
}
@@ -103,7 +106,7 @@ export function useAutoMode() {
case 'auto_mode_feature_complete':
// Feature completed - remove from running tasks and UI will reload features on its own
if (event.featureId) {
- console.log('[AutoMode] Feature completed:', event.featureId, 'passes:', event.passes);
+ logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
removeRunningTask(eventProjectId, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
@@ -121,7 +124,7 @@ export function useAutoMode() {
// Check if this is a user-initiated cancellation or abort (not a real error)
if (event.errorType === 'cancellation' || event.errorType === 'abort') {
// User cancelled/aborted the feature - just log as info, not an error
- console.log('[AutoMode] Feature cancelled/aborted:', event.error);
+ logger.info('Feature cancelled/aborted:', event.error);
// Remove from running tasks
if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId);
@@ -130,7 +133,7 @@ export function useAutoMode() {
}
// Real error - log and show to user
- console.error('[AutoMode Error]', event.error);
+ logger.error('Error:', event.error);
// Check for authentication errors and provide a more helpful message
const isAuthError =
@@ -182,7 +185,7 @@ export function useAutoMode() {
case 'auto_mode_phase':
// Log phase transitions (Planning, Action, Verification)
if (event.featureId && event.phase && event.message) {
- console.log(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
+ logger.debug(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: event.phase,
@@ -195,7 +198,7 @@ export function useAutoMode() {
case 'plan_approval_required':
// Plan requires user approval before proceeding
if (isPlanApprovalEvent(event)) {
- console.log(`[AutoMode] Plan approval required for ${event.featureId}`);
+ logger.debug(`[AutoMode] Plan approval required for ${event.featureId}`);
setPendingPlanApproval({
featureId: event.featureId,
projectPath: event.projectPath || currentProject?.path || '',
@@ -208,7 +211,7 @@ export function useAutoMode() {
case 'planning_started':
// Log when planning phase begins
if (event.featureId && event.mode && event.message) {
- console.log(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
+ logger.debug(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: 'planning',
@@ -221,7 +224,7 @@ export function useAutoMode() {
case 'plan_approved':
// Log when plan is approved by user
if (event.featureId) {
- console.log(`[AutoMode] Plan approved for ${event.featureId}`);
+ logger.debug(`[AutoMode] Plan approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: 'action',
@@ -236,7 +239,7 @@ export function useAutoMode() {
case 'plan_auto_approved':
// Log when plan is auto-approved (requirePlanApproval=false)
if (event.featureId) {
- console.log(`[AutoMode] Plan auto-approved for ${event.featureId}`);
+ logger.debug(`[AutoMode] Plan auto-approved for ${event.featureId}`);
addAutoModeActivity({
featureId: event.featureId,
type: 'action',
@@ -253,7 +256,7 @@ export function useAutoMode() {
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
- console.log(
+ logger.debug(
`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`
);
addAutoModeActivity({
@@ -269,7 +272,7 @@ export function useAutoMode() {
// Task started - show which task is being worked on
if (event.featureId && 'taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract;
- console.log(
+ logger.debug(
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
);
addAutoModeActivity({
@@ -284,7 +287,7 @@ export function useAutoMode() {
// Task completed - show progress
if (event.featureId && 'taskId' in event) {
const taskEvent = event as Extract;
- console.log(
+ logger.debug(
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
);
addAutoModeActivity({
@@ -302,7 +305,7 @@ export function useAutoMode() {
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
- console.log(
+ logger.debug(
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
);
addAutoModeActivity({
@@ -330,18 +333,18 @@ export function useAutoMode() {
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
const start = useCallback(() => {
if (!currentProject) {
- console.error('No project selected');
+ logger.error('No project selected');
return;
}
setAutoModeRunning(currentProject.id, true);
- console.log(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
+ logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
}, [currentProject, setAutoModeRunning, maxConcurrency]);
// Stop auto mode - UI only, running tasks continue until natural completion
const stop = useCallback(() => {
if (!currentProject) {
- console.error('No project selected');
+ logger.error('No project selected');
return;
}
@@ -350,14 +353,14 @@ export function useAutoMode() {
// Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event.
- console.log('[AutoMode] Stopped - running tasks will continue');
+ logger.info('Stopped - running tasks will continue');
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature
const stopFeature = useCallback(
async (featureId: string) => {
if (!currentProject) {
- console.error('No project selected');
+ logger.error('No project selected');
return;
}
@@ -371,7 +374,7 @@ export function useAutoMode() {
if (result.success) {
removeRunningTask(currentProject.id, featureId);
- console.log('[AutoMode] Feature stopped successfully:', featureId);
+ logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,
type: 'complete',
@@ -379,11 +382,11 @@ export function useAutoMode() {
passes: false,
});
} else {
- console.error('[AutoMode] Failed to stop feature:', result.error);
+ logger.error('Failed to stop feature:', result.error);
throw new Error(result.error || 'Failed to stop feature');
}
} catch (error) {
- console.error('[AutoMode] Error stopping feature:', error);
+ logger.error('Error stopping feature:', error);
throw error;
}
},
diff --git a/apps/ui/src/hooks/use-board-background-settings.ts b/apps/ui/src/hooks/use-board-background-settings.ts
index 2e00be96..fdb09b36 100644
--- a/apps/ui/src/hooks/use-board-background-settings.ts
+++ b/apps/ui/src/hooks/use-board-background-settings.ts
@@ -1,8 +1,11 @@
import { useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
+const logger = createLogger('BoardBackground');
+
/**
* Hook for managing board background settings with automatic persistence to server
*/
@@ -19,11 +22,11 @@ export function useBoardBackgroundSettings() {
});
if (!result.success) {
- console.error('Failed to persist settings:', result.error);
+ logger.error('Failed to persist settings:', result.error);
toast.error('Failed to save settings');
}
} catch (error) {
- console.error('Failed to persist settings:', error);
+ logger.error('Failed to persist settings:', error);
toast.error('Failed to save settings');
}
},
diff --git a/apps/ui/src/hooks/use-cursor-status-init.ts b/apps/ui/src/hooks/use-cursor-status-init.ts
new file mode 100644
index 00000000..79e277c7
--- /dev/null
+++ b/apps/ui/src/hooks/use-cursor-status-init.ts
@@ -0,0 +1,46 @@
+import { useEffect, useRef } from 'react';
+import { useSetupStore } from '@/store/setup-store';
+import { getHttpApiClient } from '@/lib/http-api-client';
+
+/**
+ * Hook to initialize Cursor CLI status on app startup.
+ * This ensures the cursorCliStatus is available in the setup store
+ * before the user opens feature dialogs.
+ */
+export function useCursorStatusInit() {
+ const { setCursorCliStatus, cursorCliStatus } = useSetupStore();
+ const initialized = useRef(false);
+
+ useEffect(() => {
+ // Only initialize once per session
+ if (initialized.current || cursorCliStatus !== null) {
+ return;
+ }
+ initialized.current = true;
+
+ const initCursorStatus = async () => {
+ try {
+ const api = getHttpApiClient();
+ const statusResult = await api.setup.getCursorStatus();
+
+ if (statusResult.success) {
+ setCursorCliStatus({
+ installed: statusResult.installed ?? false,
+ version: statusResult.version ?? undefined,
+ auth: statusResult.auth?.authenticated
+ ? {
+ authenticated: true,
+ method: statusResult.auth.method || 'unknown',
+ }
+ : undefined,
+ });
+ }
+ } catch (error) {
+ // Silently fail - cursor is optional
+ console.debug('[CursorStatusInit] Failed to check cursor status:', error);
+ }
+ };
+
+ initCursorStatus();
+ }, [setCursorCliStatus, cursorCliStatus]);
+}
diff --git a/apps/ui/src/hooks/use-electron-agent.ts b/apps/ui/src/hooks/use-electron-agent.ts
index 603bcc8e..a1037fe8 100644
--- a/apps/ui/src/hooks/use-electron-agent.ts
+++ b/apps/ui/src/hooks/use-electron-agent.ts
@@ -4,11 +4,15 @@ import { useMessageQueue } from './use-message-queue';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { sanitizeFilename } from '@/lib/image-utils';
+import { createLogger } from '@automaker/utils/logger';
+
+const logger = createLogger('ElectronAgent');
interface UseElectronAgentOptions {
sessionId: string;
workingDirectory?: string;
model?: string;
+ thinkingLevel?: string;
onToolUse?: (toolName: string, toolInput: unknown) => void;
}
@@ -18,6 +22,7 @@ interface QueuedPrompt {
message: string;
imagePaths?: string[];
model?: string;
+ thinkingLevel?: string;
addedAt: string;
}
@@ -64,6 +69,7 @@ export function useElectronAgent({
sessionId,
workingDirectory,
model,
+ thinkingLevel,
onToolUse,
}: UseElectronAgentOptions): UseElectronAgentResult {
const [messages, setMessages] = useState([]);
@@ -91,7 +97,7 @@ export function useElectronAgent({
setError(null);
try {
- console.log('[useElectronAgent] Sending message directly', {
+ logger.info('Sending message directly', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
@@ -121,9 +127,9 @@ export function useElectronAgent({
);
if (result.success && result.path) {
imagePaths.push(result.path);
- console.log('[useElectronAgent] Saved image to .automaker/images:', result.path);
+ logger.info('Saved image to .automaker/images:', result.path);
} else {
- console.error('[useElectronAgent] Failed to save image:', result.error);
+ logger.error('Failed to save image:', result.error);
}
}
}
@@ -133,7 +139,8 @@ export function useElectronAgent({
messageContent,
workingDirectory,
imagePaths,
- model
+ model,
+ thinkingLevel
);
if (!result.success) {
@@ -143,13 +150,13 @@ export function useElectronAgent({
// Note: We don't set isProcessing to false here because
// it will be set by the "complete" or "error" stream event
} catch (err) {
- console.error('[useElectronAgent] Failed to send message:', err);
+ logger.error('Failed to send message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
setIsProcessing(false);
throw err;
}
},
- [sessionId, workingDirectory, model, isProcessing]
+ [sessionId, workingDirectory, model, thinkingLevel, isProcessing]
);
// Message queue for queuing messages when agent is busy
@@ -188,13 +195,13 @@ export function useElectronAgent({
setError(null);
try {
- console.log('[useElectronAgent] Starting session:', sessionId);
+ logger.info('Starting session:', sessionId);
const result = await api.agent!.start(sessionId, workingDirectory);
if (!mounted) return;
if (result.success && result.messages) {
- console.log('[useElectronAgent] Loaded', result.messages.length, 'messages');
+ logger.info('Loaded', result.messages.length, 'messages');
setMessages(result.messages);
setIsConnected(true);
@@ -202,7 +209,7 @@ export function useElectronAgent({
const historyResult = await api.agent!.getHistory(sessionId);
if (mounted && historyResult.success) {
const isRunning = historyResult.isRunning || false;
- console.log('[useElectronAgent] Session running state:', isRunning);
+ logger.info('Session running state:', isRunning);
setIsProcessing(isRunning);
}
} else {
@@ -211,7 +218,7 @@ export function useElectronAgent({
}
} catch (err) {
if (!mounted) return;
- console.error('[useElectronAgent] Failed to initialize:', err);
+ logger.error('Failed to initialize:', err);
setError(err instanceof Error ? err.message : 'Failed to initialize');
setIsProcessing(false);
}
@@ -227,7 +234,7 @@ export function useElectronAgent({
// Auto-process queue when agent finishes processing
useEffect(() => {
if (!isProcessing && !isProcessingQueue && queuedMessages.length > 0) {
- console.log('[useElectronAgent] Auto-processing next queued message');
+ logger.info('Auto-processing next queued message');
processNext();
}
}, [isProcessing, isProcessingQueue, queuedMessages.length, processNext]);
@@ -238,21 +245,21 @@ export function useElectronAgent({
if (!api?.agent) return;
if (!sessionId) return; // Don't subscribe if no session
- console.log('[useElectronAgent] Subscribing to stream events for session:', sessionId);
+ logger.info('Subscribing to stream events for session:', sessionId);
const handleStream = (event: StreamEvent) => {
// CRITICAL: Only process events for our specific session
if (event.sessionId !== sessionId) {
- console.log('[useElectronAgent] Ignoring event for different session:', event.sessionId);
+ logger.info('Ignoring event for different session:', event.sessionId);
return;
}
- console.log('[useElectronAgent] Stream event for', sessionId, ':', event.type);
+ logger.info('Stream event for', sessionId, ':', event.type);
switch (event.type) {
case 'started':
// Agent started processing (including from queue)
- console.log('[useElectronAgent] Agent started processing for session:', sessionId);
+ logger.info('Agent started processing for session:', sessionId);
setIsProcessing(true);
break;
@@ -297,13 +304,13 @@ export function useElectronAgent({
case 'tool_use':
// Tool being used
- console.log('[useElectronAgent] Tool use:', event.tool.name);
+ logger.info('Tool use:', event.tool.name);
onToolUse?.(event.tool.name, event.tool.input);
break;
case 'complete':
// Agent finished processing for THIS session
- console.log('[useElectronAgent] Processing complete for session:', sessionId);
+ logger.info('Processing complete for session:', sessionId);
setIsProcessing(false);
if (event.messageId) {
setMessages((prev) =>
@@ -316,7 +323,7 @@ export function useElectronAgent({
case 'error':
// Error occurred for THIS session
- console.error('[useElectronAgent] Agent error for session:', sessionId, event.error);
+ logger.error('Agent error for session:', sessionId, event.error);
setIsProcessing(false);
setError(event.error);
if (event.message) {
@@ -327,13 +334,13 @@ export function useElectronAgent({
case 'queue_updated':
// Server queue was updated
- console.log('[useElectronAgent] Queue updated:', event.queue);
+ logger.info('Queue updated:', event.queue);
setServerQueue(event.queue || []);
break;
case 'queue_error':
// Error processing a queued prompt
- console.error('[useElectronAgent] Queue error:', event.error);
+ logger.error('Queue error:', event.error);
setError(event.error);
break;
}
@@ -343,7 +350,7 @@ export function useElectronAgent({
return () => {
if (unsubscribeRef.current) {
- console.log('[useElectronAgent] Unsubscribing from stream events for session:', sessionId);
+ logger.info('Unsubscribing from stream events for session:', sessionId);
unsubscribeRef.current();
unsubscribeRef.current = null;
}
@@ -360,7 +367,7 @@ export function useElectronAgent({
}
if (isProcessing) {
- console.warn('[useElectronAgent] Already processing a message');
+ logger.warn('Already processing a message');
return;
}
@@ -368,7 +375,7 @@ export function useElectronAgent({
setError(null);
try {
- console.log('[useElectronAgent] Sending message', {
+ logger.info('Sending message', {
hasImages: images && images.length > 0,
imageCount: images?.length || 0,
hasTextFiles: textFiles && textFiles.length > 0,
@@ -398,9 +405,9 @@ export function useElectronAgent({
);
if (result.success && result.path) {
imagePaths.push(result.path);
- console.log('[useElectronAgent] Saved image to .automaker/images:', result.path);
+ logger.info('Saved image to .automaker/images:', result.path);
} else {
- console.error('[useElectronAgent] Failed to save image:', result.error);
+ logger.error('Failed to save image:', result.error);
}
}
}
@@ -410,7 +417,8 @@ export function useElectronAgent({
messageContent,
workingDirectory,
imagePaths,
- model
+ model,
+ thinkingLevel
);
if (!result.success) {
@@ -420,12 +428,12 @@ export function useElectronAgent({
// Note: We don't set isProcessing to false here because
// it will be set by the "complete" or "error" stream event
} catch (err) {
- console.error('[useElectronAgent] Failed to send message:', err);
+ logger.error('Failed to send message:', err);
setError(err instanceof Error ? err.message : 'Failed to send message');
setIsProcessing(false);
}
},
- [sessionId, workingDirectory, model, isProcessing]
+ [sessionId, workingDirectory, model, thinkingLevel, isProcessing]
);
// Stop current execution
@@ -437,7 +445,7 @@ export function useElectronAgent({
}
try {
- console.log('[useElectronAgent] Stopping execution');
+ logger.info('Stopping execution');
const result = await api.agent!.stop(sessionId);
if (!result.success) {
@@ -446,7 +454,7 @@ export function useElectronAgent({
setIsProcessing(false);
}
} catch (err) {
- console.error('[useElectronAgent] Failed to stop:', err);
+ logger.error('Failed to stop:', err);
setError(err instanceof Error ? err.message : 'Failed to stop execution');
}
}, [sessionId]);
@@ -460,7 +468,7 @@ export function useElectronAgent({
}
try {
- console.log('[useElectronAgent] Clearing history');
+ logger.info('Clearing history');
const result = await api.agent!.clear(sessionId);
if (result.success) {
@@ -470,7 +478,7 @@ export function useElectronAgent({
setError(result.error || 'Failed to clear history');
}
} catch (err) {
- console.error('[useElectronAgent] Failed to clear:', err);
+ logger.error('Failed to clear:', err);
setError(err instanceof Error ? err.message : 'Failed to clear history');
}
}, [sessionId]);
@@ -512,18 +520,24 @@ export function useElectronAgent({
}
}
- console.log('[useElectronAgent] Adding to server queue');
- const result = await api.agent.queueAdd(sessionId, messageContent, imagePaths, model);
+ logger.info('Adding to server queue');
+ const result = await api.agent.queueAdd(
+ sessionId,
+ messageContent,
+ imagePaths,
+ model,
+ thinkingLevel
+ );
if (!result.success) {
setError(result.error || 'Failed to add to queue');
}
} catch (err) {
- console.error('[useElectronAgent] Failed to add to queue:', err);
+ logger.error('Failed to add to queue:', err);
setError(err instanceof Error ? err.message : 'Failed to add to queue');
}
},
- [sessionId, workingDirectory, model]
+ [sessionId, workingDirectory, model, thinkingLevel]
);
// Remove a prompt from the server queue
@@ -536,14 +550,14 @@ export function useElectronAgent({
}
try {
- console.log('[useElectronAgent] Removing from server queue:', promptId);
+ logger.info('Removing from server queue:', promptId);
const result = await api.agent.queueRemove(sessionId, promptId);
if (!result.success) {
setError(result.error || 'Failed to remove from queue');
}
} catch (err) {
- console.error('[useElectronAgent] Failed to remove from queue:', err);
+ logger.error('Failed to remove from queue:', err);
setError(err instanceof Error ? err.message : 'Failed to remove from queue');
}
},
@@ -559,14 +573,14 @@ export function useElectronAgent({
}
try {
- console.log('[useElectronAgent] Clearing server queue');
+ logger.info('Clearing server queue');
const result = await api.agent.queueClear(sessionId);
if (!result.success) {
setError(result.error || 'Failed to clear queue');
}
} catch (err) {
- console.error('[useElectronAgent] Failed to clear queue:', err);
+ logger.error('Failed to clear queue:', err);
setError(err instanceof Error ? err.message : 'Failed to clear queue');
}
}, [sessionId]);
diff --git a/apps/ui/src/hooks/use-guided-prompts.ts b/apps/ui/src/hooks/use-guided-prompts.ts
new file mode 100644
index 00000000..e192d6b3
--- /dev/null
+++ b/apps/ui/src/hooks/use-guided-prompts.ts
@@ -0,0 +1,86 @@
+/**
+ * Hook for fetching guided prompts from the backend API
+ *
+ * This hook provides the single source of truth for guided prompts,
+ * fetched from the backend /api/ideation/prompts endpoint.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
+import { getElectronAPI } from '@/lib/electron';
+
+interface UseGuidedPromptsReturn {
+ prompts: IdeationPrompt[];
+ categories: PromptCategory[];
+ isLoading: boolean;
+ error: string | null;
+ refetch: () => Promise;
+ getPromptsByCategory: (category: IdeaCategory) => IdeationPrompt[];
+ getPromptById: (id: string) => IdeationPrompt | undefined;
+ getCategoryById: (id: IdeaCategory) => PromptCategory | undefined;
+}
+
+export function useGuidedPrompts(): UseGuidedPromptsReturn {
+ const [prompts, setPrompts] = useState([]);
+ const [categories, setCategories] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchPrompts = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const api = getElectronAPI();
+ const result = await api.ideation?.getPrompts();
+
+ if (result?.success) {
+ setPrompts(result.prompts || []);
+ setCategories(result.categories || []);
+ } else {
+ setError(result?.error || 'Failed to fetch prompts');
+ }
+ } catch (err) {
+ console.error('Failed to fetch guided prompts:', err);
+ setError(err instanceof Error ? err.message : 'Failed to fetch prompts');
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchPrompts();
+ }, [fetchPrompts]);
+
+ const getPromptsByCategory = useCallback(
+ (category: IdeaCategory): IdeationPrompt[] => {
+ return prompts.filter((p) => p.category === category);
+ },
+ [prompts]
+ );
+
+ const getPromptById = useCallback(
+ (id: string): IdeationPrompt | undefined => {
+ return prompts.find((p) => p.id === id);
+ },
+ [prompts]
+ );
+
+ const getCategoryById = useCallback(
+ (id: IdeaCategory): PromptCategory | undefined => {
+ return categories.find((c) => c.id === id);
+ },
+ [categories]
+ );
+
+ return {
+ prompts,
+ categories,
+ isLoading,
+ error,
+ refetch: fetchPrompts,
+ getPromptsByCategory,
+ getPromptById,
+ getCategoryById,
+ };
+}
diff --git a/apps/ui/src/hooks/use-keyboard-shortcuts.ts b/apps/ui/src/hooks/use-keyboard-shortcuts.ts
index 4f5a0234..ae3c130a 100644
--- a/apps/ui/src/hooks/use-keyboard-shortcuts.ts
+++ b/apps/ui/src/hooks/use-keyboard-shortcuts.ts
@@ -1,5 +1,5 @@
-import { useEffect, useCallback } from 'react';
-import { useAppStore, parseShortcut } from '@/store/app-store';
+import { useEffect, useCallback, useMemo } from 'react';
+import { useAppStore, parseShortcut, DEFAULT_KEYBOARD_SHORTCUTS } from '@/store/app-store';
export interface KeyboardShortcut {
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
@@ -237,8 +237,18 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
/**
* Hook to get current keyboard shortcuts from store
* This replaces the static constants and allows customization
+ * Merges with defaults to ensure new shortcuts are always available
*/
export function useKeyboardShortcutsConfig() {
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
- return keyboardShortcuts;
+
+ // Merge with defaults to ensure new shortcuts are available
+ // even if user's persisted state predates them
+ return useMemo(
+ () => ({
+ ...DEFAULT_KEYBOARD_SHORTCUTS,
+ ...keyboardShortcuts,
+ }),
+ [keyboardShortcuts]
+ );
}
diff --git a/apps/ui/src/hooks/use-message-queue.ts b/apps/ui/src/hooks/use-message-queue.ts
index d84858a7..a99c1fd5 100644
--- a/apps/ui/src/hooks/use-message-queue.ts
+++ b/apps/ui/src/hooks/use-message-queue.ts
@@ -1,6 +1,9 @@
import { useState, useCallback } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
+const logger = createLogger('MessageQueue');
+
export interface QueuedMessage {
id: string;
content: string;
@@ -72,7 +75,7 @@ export function useMessageQueue({ onProcessNext }: UseMessageQueueOptions): UseM
// Remove the processed message from queue
setQueuedMessages((prev) => prev.slice(1));
} catch (error) {
- console.error('Error processing queued message:', error);
+ logger.error('Error processing queued message:', error);
// Keep the message in queue for retry or manual removal
} finally {
setIsProcessingQueue(false);
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts
index 7abc86c2..3674036b 100644
--- a/apps/ui/src/hooks/use-settings-migration.ts
+++ b/apps/ui/src/hooks/use-settings-migration.ts
@@ -18,11 +18,14 @@
*/
import { useEffect, useState, useRef } from 'react';
+import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { isElectron } from '@/lib/electron';
import { getItem, removeItem } from '@/lib/storage';
import { useAppStore } from '@/store/app-store';
+const logger = createLogger('SettingsMigration');
+
/**
* State returned by useSettingsMigration hook
*/
@@ -109,7 +112,7 @@ export function useSettingsMigration(): MigrationState {
const status = await api.settings.getStatus();
if (!status.success) {
- console.error('[Settings Migration] Failed to get status:', status);
+ logger.error('Failed to get status:', status);
setState({
checked: true,
migrated: false,
@@ -120,7 +123,7 @@ export function useSettingsMigration(): MigrationState {
// If settings files already exist, no migration needed
if (!status.needsMigration) {
- console.log('[Settings Migration] Settings files exist, no migration needed');
+ logger.info('Settings files exist, no migration needed');
setState({ checked: true, migrated: false, error: null });
return;
}
@@ -128,12 +131,12 @@ export function useSettingsMigration(): MigrationState {
// Check if we have localStorage data to migrate
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
- console.log('[Settings Migration] No localStorage data to migrate');
+ logger.info('No localStorage data to migrate');
setState({ checked: true, migrated: false, error: null });
return;
}
- console.log('[Settings Migration] Starting migration...');
+ logger.info('Starting migration...');
// Collect all localStorage data
const localStorageData: Record = {};
@@ -148,7 +151,7 @@ export function useSettingsMigration(): MigrationState {
const result = await api.settings.migrate(localStorageData);
if (result.success) {
- console.log('[Settings Migration] Migration successful:', {
+ logger.info('Migration successful:', {
globalSettings: result.migratedGlobalSettings,
credentials: result.migratedCredentials,
projects: result.migratedProjectCount,
@@ -161,7 +164,7 @@ export function useSettingsMigration(): MigrationState {
setState({ checked: true, migrated: true, error: null });
} else {
- console.warn('[Settings Migration] Migration had errors:', result.errors);
+ logger.warn('Migration had errors:', result.errors);
setState({
checked: true,
migrated: false,
@@ -169,7 +172,7 @@ export function useSettingsMigration(): MigrationState {
});
}
} catch (error) {
- console.error('[Settings Migration] Migration failed:', error);
+ logger.error('Migration failed:', error);
setState({
checked: true,
migrated: false,
@@ -224,14 +227,13 @@ export async function syncSettingsToServer(): Promise {
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
+ phaseModels: state.phaseModels,
autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers,
- mcpAutoApproveTools: state.mcpAutoApproveTools,
- mcpUnrestrictedTools: state.mcpUnrestrictedTools,
promptCustomization: state.promptCustomization,
projects: state.projects,
trashedProjects: state.trashedProjects,
@@ -243,7 +245,7 @@ export async function syncSettingsToServer(): Promise {
const result = await api.settings.updateGlobal(updates);
return result.success;
} catch (error) {
- console.error('[Settings Sync] Failed to sync settings:', error);
+ logger.error('Failed to sync settings:', error);
return false;
}
}
@@ -270,7 +272,7 @@ export async function syncCredentialsToServer(apiKeys: {
const result = await api.settings.updateCredentials({ apiKeys });
return result.success;
} catch (error) {
- console.error('[Settings Sync] Failed to sync credentials:', error);
+ logger.error('Failed to sync credentials:', error);
return false;
}
}
@@ -311,7 +313,7 @@ export async function syncProjectSettingsToServer(
const result = await api.settings.updateProject(projectPath, updates);
return result.success;
} catch (error) {
- console.error('[Settings Sync] Failed to sync project settings:', error);
+ logger.error('Failed to sync project settings:', error);
return false;
}
}
@@ -331,22 +333,20 @@ export async function loadMCPServersFromServer(): Promise {
const result = await api.settings.getGlobal();
if (!result.success || !result.settings) {
- console.error('[Settings Load] Failed to load settings:', result.error);
+ logger.error('Failed to load settings:', result.error);
return false;
}
const mcpServers = result.settings.mcpServers || [];
- const mcpAutoApproveTools = result.settings.mcpAutoApproveTools ?? true;
- const mcpUnrestrictedTools = result.settings.mcpUnrestrictedTools ?? true;
// Clear existing and add all from server
// We need to update the store directly since we can't use hooks here
- useAppStore.setState({ mcpServers, mcpAutoApproveTools, mcpUnrestrictedTools });
+ useAppStore.setState({ mcpServers });
- console.log(`[Settings Load] Loaded ${mcpServers.length} MCP servers from server`);
+ logger.info(`Loaded ${mcpServers.length} MCP servers from server`);
return true;
} catch (error) {
- console.error('[Settings Load] Failed to load MCP servers:', error);
+ logger.error('Failed to load MCP servers:', error);
return false;
}
}
diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts
index be82aa94..40244b18 100644
--- a/apps/ui/src/lib/agent-context-parser.ts
+++ b/apps/ui/src/lib/agent-context-parser.ts
@@ -39,55 +39,118 @@ export function formatModelName(model: string): string {
return model.split('-').slice(1, 3).join(' ');
}
+/**
+ * Helper to extract a balanced JSON object from a string starting at a given position
+ */
+function extractJsonObject(str: string, startIdx: number): string | null {
+ if (str[startIdx] !== '{') return null;
+
+ let depth = 0;
+ let inString = false;
+ let escapeNext = false;
+
+ for (let i = startIdx; i < str.length; i++) {
+ const char = str[i];
+
+ if (escapeNext) {
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === '\\' && inString) {
+ escapeNext = true;
+ continue;
+ }
+
+ if (char === '"' && !escapeNext) {
+ inString = !inString;
+ continue;
+ }
+
+ if (inString) continue;
+
+ if (char === '{') depth++;
+ else if (char === '}') {
+ depth--;
+ if (depth === 0) {
+ return str.slice(startIdx, i + 1);
+ }
+ }
+ }
+
+ return null;
+}
+
/**
* Extracts todos from the context content
* Looks for TodoWrite tool calls in the format:
- * TodoWrite: [{"content": "...", "status": "..."}]
+ * 🔧 Tool: TodoWrite
+ * Input: {"todos": [{"content": "...", "status": "..."}]}
*/
function extractTodos(content: string): AgentTaskInfo['todos'] {
const todos: AgentTaskInfo['todos'] = [];
- // Look for TodoWrite tool inputs
- const todoMatches = content.matchAll(
- /TodoWrite.*?(?:"todos"\s*:\s*)?(\[[\s\S]*?\](?=\s*(?:\}|$|🔧|📋|⚡|✅|❌)))/g
- );
+ // Find all occurrences of TodoWrite tool calls
+ const todoWriteMarker = '🔧 Tool: TodoWrite';
+ let searchStart = 0;
- for (const match of todoMatches) {
- try {
- // Try to find JSON array in the match
- const jsonStr = match[1] || match[0];
- const arrayMatch = jsonStr.match(/\[[\s\S]*?\]/);
- if (arrayMatch) {
- const parsed = JSON.parse(arrayMatch[0]);
- if (Array.isArray(parsed)) {
- for (const item of parsed) {
+ while (true) {
+ const markerIdx = content.indexOf(todoWriteMarker, searchStart);
+ if (markerIdx === -1) break;
+
+ // Look for "Input:" after the marker
+ const inputIdx = content.indexOf('Input:', markerIdx);
+ if (inputIdx === -1 || inputIdx > markerIdx + 100) {
+ searchStart = markerIdx + 1;
+ continue;
+ }
+
+ // Find the start of the JSON object
+ const jsonStart = content.indexOf('{', inputIdx);
+ if (jsonStart === -1) {
+ searchStart = markerIdx + 1;
+ continue;
+ }
+
+ // Extract the complete JSON object
+ const jsonStr = extractJsonObject(content, jsonStart);
+ if (jsonStr) {
+ try {
+ const parsed = JSON.parse(jsonStr) as {
+ todos?: Array<{ content: string; status: string }>;
+ };
+ if (parsed.todos && Array.isArray(parsed.todos)) {
+ // Clear previous todos - we want the latest state
+ todos.length = 0;
+ for (const item of parsed.todos) {
if (item.content && item.status) {
- // Check if this todo already exists (avoid duplicates)
- if (!todos.some((t) => t.content === item.content)) {
- todos.push({
- content: item.content,
- status: item.status,
- });
- }
+ todos.push({
+ content: item.content,
+ status: item.status as 'pending' | 'in_progress' | 'completed',
+ });
}
}
}
+ } catch {
+ // Ignore parse errors
}
- } catch {
- // Ignore parse errors
}
+
+ searchStart = markerIdx + 1;
}
- // Also try to extract from markdown task lists
- const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
- for (const match of markdownTodos) {
- const isCompleted = match[1].toLowerCase() === 'x';
- const content = match[2].trim();
- if (!todos.some((t) => t.content === content)) {
- todos.push({
- content,
- status: isCompleted ? 'completed' : 'pending',
- });
+ // Also try to extract from markdown task lists as fallback
+ if (todos.length === 0) {
+ const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
+ for (const match of markdownTodos) {
+ const isCompleted = match[1].toLowerCase() === 'x';
+ const todoContent = match[2].trim();
+ if (!todos.some((t) => t.content === todoContent)) {
+ todos.push({
+ content: todoContent,
+ status: isCompleted ? 'completed' : 'pending',
+ });
+ }
}
}
@@ -130,38 +193,60 @@ function getCurrentPhase(content: string): 'planning' | 'action' | 'verification
return 'planning';
}
+/**
+ * Cleans up fragmented streaming text by removing spurious newlines
+ * This handles cases where streaming providers send partial text chunks
+ * that got separated by newlines during accumulation
+ */
+function cleanFragmentedText(content: string): string {
+ // Remove newlines that break up words (newline between letters)
+ // e.g., "sum\n\nmary" -> "summary"
+ let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
+
+ // Also clean up fragmented XML-like tags
+ // e.g., "" -> ""
+ cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
+ cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '$1$2>');
+
+ return cleaned;
+}
+
/**
* Extracts a summary from completed feature context
* Looks for content between and tags
*/
function extractSummary(content: string): string | undefined {
+ // First, clean up any fragmented text from streaming
+ const cleanedContent = cleanFragmentedText(content);
+
// Look for tags - capture everything between opening and closing tags
- const summaryTagMatch = content.match(/([\s\S]*?)<\/summary>/i);
+ const summaryTagMatch = cleanedContent.match(/([\s\S]*?)<\/summary>/i);
if (summaryTagMatch) {
- return summaryTagMatch[1].trim();
+ // Clean up the extracted summary content as well
+ return cleanFragmentedText(summaryTagMatch[1]).trim();
}
// Fallback: Look for summary sections - capture everything including subsections (###)
// Stop at same-level ## sections (but not ###), or tool markers, or end
- const summaryMatch = content.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
+ const summaryMatch = cleanedContent.match(/## Summary[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i);
if (summaryMatch) {
- return summaryMatch[1].trim();
+ return cleanFragmentedText(summaryMatch[1]).trim();
}
// Look for completion markers and extract surrounding text
- const completionMatch = content.match(
+ const completionMatch = cleanedContent.match(
/✓ (?:Feature|Verification|Task) (?:successfully|completed|verified)[^\n]*(?:\n[^\n]{1,200})?/i
);
if (completionMatch) {
- return completionMatch[0].trim();
+ return cleanFragmentedText(completionMatch[0]).trim();
}
// Look for "What was done" type sections
- const whatWasDoneMatch = content.match(
+ const whatWasDoneMatch = cleanedContent.match(
/(?:What was done|Changes made|Implemented)[^\n]*\n([\s\S]*?)(?=\n## [^#]|\n🔧|$)/i
);
if (whatWasDoneMatch) {
- return whatWasDoneMatch[1].trim();
+ return cleanFragmentedText(whatWasDoneMatch[1]).trim();
}
return undefined;
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index 58125806..d81b46b6 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -13,6 +13,18 @@ import type {
AgentModel,
GitHubComment,
IssueCommentsResult,
+ Idea,
+ IdeaCategory,
+ IdeationSession,
+ IdeationMessage,
+ IdeationPrompt,
+ PromptCategory,
+ ProjectAnalysisResult,
+ AnalysisSuggestion,
+ StartSessionOptions,
+ CreateIdeaInput,
+ UpdateIdeaInput,
+ ConvertToFeatureOptions,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -30,6 +42,104 @@ export type {
IssueCommentsResult,
};
+// Re-export ideation types
+export type {
+ Idea,
+ IdeaCategory,
+ IdeationSession,
+ IdeationMessage,
+ IdeationPrompt,
+ PromptCategory,
+ ProjectAnalysisResult,
+ AnalysisSuggestion,
+ StartSessionOptions,
+ CreateIdeaInput,
+ UpdateIdeaInput,
+ ConvertToFeatureOptions,
+};
+
+// Ideation API interface
+export interface IdeationAPI {
+ // Session management
+ startSession: (
+ projectPath: string,
+ options?: StartSessionOptions
+ ) => Promise<{ success: boolean; session?: IdeationSession; error?: string }>;
+ getSession: (
+ projectPath: string,
+ sessionId: string
+ ) => Promise<{
+ success: boolean;
+ session?: IdeationSession;
+ messages?: IdeationMessage[];
+ error?: string;
+ }>;
+ sendMessage: (
+ sessionId: string,
+ message: string,
+ options?: { imagePaths?: string[]; model?: string }
+ ) => Promise<{ success: boolean; error?: string }>;
+ stopSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
+
+ // Ideas CRUD
+ listIdeas: (projectPath: string) => Promise<{ success: boolean; ideas?: Idea[]; error?: string }>;
+ createIdea: (
+ projectPath: string,
+ idea: CreateIdeaInput
+ ) => Promise<{ success: boolean; idea?: Idea; error?: string }>;
+ getIdea: (
+ projectPath: string,
+ ideaId: string
+ ) => Promise<{ success: boolean; idea?: Idea; error?: string }>;
+ updateIdea: (
+ projectPath: string,
+ ideaId: string,
+ updates: UpdateIdeaInput
+ ) => Promise<{ success: boolean; idea?: Idea; error?: string }>;
+ deleteIdea: (
+ projectPath: string,
+ ideaId: string
+ ) => Promise<{ success: boolean; error?: string }>;
+
+ // Project analysis
+ analyzeProject: (
+ projectPath: string
+ ) => Promise<{ success: boolean; analysis?: ProjectAnalysisResult; error?: string }>;
+
+ // Generate suggestions from a prompt
+ generateSuggestions: (
+ projectPath: string,
+ promptId: string,
+ category: IdeaCategory,
+ count?: number
+ ) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>;
+
+ // Convert to feature
+ convertToFeature: (
+ projectPath: string,
+ ideaId: string,
+ options?: ConvertToFeatureOptions
+ ) => Promise<{ success: boolean; feature?: any; featureId?: string; error?: string }>;
+
+ // Add suggestion directly to board as feature
+ addSuggestionToBoard: (
+ projectPath: string,
+ suggestion: AnalysisSuggestion
+ ) => Promise<{ success: boolean; featureId?: string; error?: string }>;
+
+ // Get guided prompts (single source of truth from backend)
+ getPrompts: () => Promise<{
+ success: boolean;
+ prompts?: IdeationPrompt[];
+ categories?: PromptCategory[];
+ error?: string;
+ }>;
+
+ // Event subscriptions
+ onStream: (callback: (event: any) => void) => () => void;
+ onAnalysisEvent: (callback: (event: any) => void) => () => void;
+}
+
export interface FileEntry {
name: string;
isDirectory: boolean;
@@ -647,6 +757,7 @@ export interface ElectronAPI {
error?: string;
}>;
};
+ ideation?: IdeationAPI;
}
// Note: Window interface is declared in @/types/electron.d.ts
diff --git a/apps/ui/src/lib/file-picker.ts b/apps/ui/src/lib/file-picker.ts
index c2b60090..e7c4631b 100644
--- a/apps/ui/src/lib/file-picker.ts
+++ b/apps/ui/src/lib/file-picker.ts
@@ -10,6 +10,10 @@
* user confirmation or server-side path resolution.
*/
+import { createLogger } from '@automaker/utils/logger';
+
+const logger = createLogger('FilePicker');
+
/**
* Directory picker result with structure information for server-side resolution
*/
@@ -65,18 +69,18 @@ export async function openDirectoryPicker(): Promise 0) {
const absolutePath = filePath.substring(0, lastSeparator);
- console.log('[FilePicker] Found absolute path:', absolutePath);
+ logger.info('Found absolute path:', absolutePath);
// Return as directory name for now - server can validate it directly
directoryName = absolutePath;
}
@@ -106,11 +110,11 @@ export async function openDirectoryPicker(): Promise 0) {
directoryName = pathParts[0]; // Top-level directory name
- console.log('[FilePicker] Extracted directory name:', directoryName);
+ logger.info('Extracted directory name:', directoryName);
}
}
@@ -127,7 +131,7 @@ export async function openDirectoryPicker(): Promise {
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
- console.log('[FilePicker] Dialog canceled (no files after focus and no change event)');
+ logger.info('Dialog canceled (no files after focus and no change event)');
safeResolve(null);
}
}, 2000); // Increased timeout for Windows - give it time
@@ -155,19 +159,19 @@ export async function openDirectoryPicker(): Promise => {
if (electron?.getServerUrl) {
try {
cachedServerUrl = await electron.getServerUrl();
- console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
+ logger.info('Server URL from Electron:', cachedServerUrl);
} catch (error) {
- console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
+ logger.warn('Failed to get server URL from Electron:', error);
}
}
};
@@ -145,16 +155,16 @@ export const initApiKey = async (): Promise => {
try {
cachedApiKey = await window.electronAPI.getApiKey();
if (cachedApiKey) {
- console.log('[HTTP Client] Using API key from Electron');
+ logger.info('Using API key from Electron');
return;
}
} catch (error) {
- console.warn('[HTTP Client] Failed to get API key from Electron:', error);
+ logger.warn('Failed to get API key from Electron:', error);
}
}
// In web mode, authentication is handled via HTTP-only cookies
- console.log('[HTTP Client] Web mode - using cookie-based authentication');
+ logger.info('Web mode - using cookie-based authentication');
} finally {
// Mark as initialized after completion, regardless of success or failure
apiKeyInitialized = true;
@@ -182,7 +192,7 @@ export const checkAuthStatus = async (): Promise<{
required: data.required ?? true,
};
} catch (error) {
- console.error('[HTTP Client] Failed to check auth status:', error);
+ logger.error('Failed to check auth status:', error);
return { authenticated: false, required: true };
}
};
@@ -207,23 +217,23 @@ export const login = async (
// Store the session token if login succeeded
if (data.success && data.token) {
setSessionToken(data.token);
- console.log('[HTTP Client] Session token stored after login');
+ logger.info('Session token stored after login');
// Verify the session is actually working by making a request to an authenticated endpoint
const verified = await verifySession();
if (!verified) {
- console.error('[HTTP Client] Login appeared successful but session verification failed');
+ logger.error('Login appeared successful but session verification failed');
return {
success: false,
error: 'Session verification failed. Please try again.',
};
}
- console.log('[HTTP Client] Login verified successfully');
+ logger.info('Login verified successfully');
}
return data;
} catch (error) {
- console.error('[HTTP Client] Login failed:', error);
+ logger.error('Login failed:', error);
return { success: false, error: 'Network error' };
}
};
@@ -243,20 +253,20 @@ export const fetchSessionToken = async (): Promise => {
});
if (!response.ok) {
- console.log('[HTTP Client] Failed to check auth status');
+ logger.info('Failed to check auth status');
return false;
}
const data = await response.json();
if (data.success && data.authenticated) {
- console.log('[HTTP Client] Session cookie is valid');
+ logger.info('Session cookie is valid');
return true;
}
- console.log('[HTTP Client] Session cookie is not authenticated');
+ logger.info('Session cookie is not authenticated');
return false;
} catch (error) {
- console.error('[HTTP Client] Failed to check session:', error);
+ logger.error('Failed to check session:', error);
return false;
}
};
@@ -273,11 +283,11 @@ export const logout = async (): Promise<{ success: boolean }> => {
// Clear the cached session token
clearSessionToken();
- console.log('[HTTP Client] Session token cleared on logout');
+ logger.info('Session token cleared on logout');
return await response.json();
} catch (error) {
- console.error('[HTTP Client] Logout failed:', error);
+ logger.error('Logout failed:', error);
return { success: false };
}
};
@@ -310,7 +320,7 @@ export const verifySession = async (): Promise => {
// Check for authentication errors
if (response.status === 401 || response.status === 403) {
- console.warn('[HTTP Client] Session verification failed - session expired or invalid');
+ logger.warn('Session verification failed - session expired or invalid');
// Clear the session since it's no longer valid
clearSessionToken();
// Try to clear the cookie via logout (fire and forget)
@@ -324,14 +334,14 @@ export const verifySession = async (): Promise => {
}
if (!response.ok) {
- console.warn('[HTTP Client] Session verification failed with status:', response.status);
+ logger.warn('Session verification failed with status:', response.status);
return false;
}
- console.log('[HTTP Client] Session verified successfully');
+ logger.info('Session verified successfully');
return true;
} catch (error) {
- console.error('[HTTP Client] Session verification error:', error);
+ logger.error('Session verification error:', error);
return false;
}
};
@@ -350,14 +360,14 @@ export const checkSandboxEnvironment = async (): Promise<{
});
if (!response.ok) {
- console.warn('[HTTP Client] Failed to check sandbox environment');
+ logger.warn('Failed to check sandbox environment');
return { isContainerized: false, error: 'Failed to check environment' };
}
const data = await response.json();
return { isContainerized: data.isContainerized ?? false };
} catch (error) {
- console.error('[HTTP Client] Sandbox environment check failed:', error);
+ logger.error('Sandbox environment check failed:', error);
return { isContainerized: false, error: 'Network error' };
}
};
@@ -368,7 +378,9 @@ type EventType =
| 'suggestions:event'
| 'spec-regeneration:event'
| 'issue-validation:event'
- | 'backlog-plan:event';
+ | 'backlog-plan:event'
+ | 'ideation:stream'
+ | 'ideation:analysis';
type EventCallback = (payload: unknown) => void;
@@ -399,7 +411,7 @@ export class HttpApiClient implements ElectronAPI {
this.connectWebSocket();
})
.catch((error) => {
- console.error('[HttpApiClient] API key initialization failed:', error);
+ logger.error('API key initialization failed:', error);
// Still attempt WebSocket connection - it may work with cookie auth
this.connectWebSocket();
});
@@ -428,7 +440,7 @@ export class HttpApiClient implements ElectronAPI {
});
if (!response.ok) {
- console.warn('[HttpApiClient] Failed to fetch wsToken:', response.status);
+ logger.warn('Failed to fetch wsToken:', response.status);
return null;
}
@@ -439,7 +451,7 @@ export class HttpApiClient implements ElectronAPI {
return null;
} catch (error) {
- console.error('[HttpApiClient] Error fetching wsToken:', error);
+ logger.error('Error fetching wsToken:', error);
return null;
}
}
@@ -456,9 +468,7 @@ export class HttpApiClient implements ElectronAPI {
if (isElectronMode()) {
const apiKey = getApiKey();
if (!apiKey) {
- console.warn(
- '[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect'
- );
+ logger.warn('Electron mode: API key not ready, delaying WebSocket connect');
this.isConnecting = false;
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
@@ -482,12 +492,12 @@ export class HttpApiClient implements ElectronAPI {
this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`);
} else {
// Fallback: try connecting without token (will fail if not authenticated)
- console.warn('[HttpApiClient] No wsToken available, attempting connection anyway');
+ logger.warn('No wsToken available, attempting connection anyway');
this.establishWebSocket(wsUrl);
}
})
.catch((error) => {
- console.error('[HttpApiClient] Failed to prepare WebSocket connection:', error);
+ logger.error('Failed to prepare WebSocket connection:', error);
this.isConnecting = false;
});
}
@@ -500,7 +510,7 @@ export class HttpApiClient implements ElectronAPI {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
- console.log('[HttpApiClient] WebSocket connected');
+ logger.info('WebSocket connected');
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -511,8 +521,8 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
- console.log(
- '[HttpApiClient] WebSocket message:',
+ logger.info(
+ 'WebSocket message:',
data.type,
'hasPayload:',
!!data.payload,
@@ -521,16 +531,16 @@ export class HttpApiClient implements ElectronAPI {
);
const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) {
- console.log('[HttpApiClient] Dispatching to', callbacks.size, 'callbacks');
+ logger.info('Dispatching to', callbacks.size, 'callbacks');
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
- console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
+ logger.error('Failed to parse WebSocket message:', error);
}
};
this.ws.onclose = () => {
- console.log('[HttpApiClient] WebSocket disconnected');
+ logger.info('WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
@@ -543,11 +553,11 @@ export class HttpApiClient implements ElectronAPI {
};
this.ws.onerror = (error) => {
- console.error('[HttpApiClient] WebSocket error:', error);
+ logger.error('WebSocket error:', error);
this.isConnecting = false;
};
} catch (error) {
- console.error('[HttpApiClient] Failed to create WebSocket:', error);
+ logger.error('Failed to create WebSocket:', error);
this.isConnecting = false;
}
}
@@ -747,7 +757,7 @@ export class HttpApiClient implements ElectronAPI {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
- console.error('File browser not initialized');
+ logger.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -769,7 +779,7 @@ export class HttpApiClient implements ElectronAPI {
return { canceled: false, filePaths: [result.path] };
}
- console.error('Invalid directory:', result.error || 'Path not allowed');
+ logger.error('Invalid directory:', result.error || 'Path not allowed');
return { canceled: true, filePaths: [] };
}
@@ -777,7 +787,7 @@ export class HttpApiClient implements ElectronAPI {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
- console.error('File browser not initialized');
+ logger.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -796,7 +806,7 @@ export class HttpApiClient implements ElectronAPI {
return { canceled: false, filePaths: [path] };
}
- console.error('File not found');
+ logger.error('File not found');
return { canceled: true, filePaths: [] };
}
@@ -989,12 +999,13 @@ export class HttpApiClient implements ElectronAPI {
}> => this.get('/api/setup/platform'),
verifyClaudeAuth: (
- authMethod?: 'cli' | 'api_key'
+ authMethod?: 'cli' | 'api_key',
+ apiKey?: string
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
- }> => this.post('/api/setup/verify-claude-auth', { authMethod }),
+ }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),
getGhStatus: (): Promise<{
success: boolean;
@@ -1006,6 +1017,126 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.get('/api/setup/gh-status'),
+ // Cursor CLI methods
+ getCursorStatus: (): Promise<{
+ success: boolean;
+ installed?: boolean;
+ version?: string | null;
+ path?: string | null;
+ auth?: {
+ authenticated: boolean;
+ method: string;
+ };
+ installCommand?: string;
+ loginCommand?: string;
+ error?: string;
+ }> => this.get('/api/setup/cursor-status'),
+
+ getCursorConfig: (
+ projectPath: string
+ ): Promise<{
+ success: boolean;
+ config?: {
+ defaultModel?: string;
+ models?: string[];
+ mcpServers?: string[];
+ rules?: string[];
+ };
+ availableModels?: Array<{
+ id: string;
+ label: string;
+ description: string;
+ hasThinking: boolean;
+ tier: 'free' | 'pro';
+ }>;
+ error?: string;
+ }> => this.get(`/api/setup/cursor-config?projectPath=${encodeURIComponent(projectPath)}`),
+
+ setCursorDefaultModel: (
+ projectPath: string,
+ model: string
+ ): Promise<{
+ success: boolean;
+ model?: string;
+ error?: string;
+ }> => this.post('/api/setup/cursor-config/default-model', { projectPath, model }),
+
+ setCursorModels: (
+ projectPath: string,
+ models: string[]
+ ): Promise<{
+ success: boolean;
+ models?: string[];
+ error?: string;
+ }> => this.post('/api/setup/cursor-config/models', { projectPath, models }),
+
+ // Cursor CLI Permissions
+ getCursorPermissions: (
+ projectPath?: string
+ ): Promise<{
+ success: boolean;
+ globalPermissions?: { allow: string[]; deny: string[] } | null;
+ projectPermissions?: { allow: string[]; deny: string[] } | null;
+ effectivePermissions?: { allow: string[]; deny: string[] } | null;
+ activeProfile?: 'strict' | 'development' | 'custom' | null;
+ hasProjectConfig?: boolean;
+ availableProfiles?: Array<{
+ id: string;
+ name: string;
+ description: string;
+ permissions: { allow: string[]; deny: string[] };
+ }>;
+ error?: string;
+ }> =>
+ this.get(
+ `/api/setup/cursor-permissions${projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : ''}`
+ ),
+
+ applyCursorPermissionProfile: (
+ profileId: 'strict' | 'development',
+ scope: 'global' | 'project',
+ projectPath?: string
+ ): Promise<{
+ success: boolean;
+ message?: string;
+ scope?: string;
+ profileId?: string;
+ error?: string;
+ }> => this.post('/api/setup/cursor-permissions/profile', { profileId, scope, projectPath }),
+
+ setCursorCustomPermissions: (
+ projectPath: string,
+ permissions: { allow: string[]; deny: string[] }
+ ): Promise<{
+ success: boolean;
+ message?: string;
+ permissions?: { allow: string[]; deny: string[] };
+ error?: string;
+ }> => this.post('/api/setup/cursor-permissions/custom', { projectPath, permissions }),
+
+ deleteCursorProjectPermissions: (
+ projectPath: string
+ ): Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }> =>
+ this.httpDelete(
+ `/api/setup/cursor-permissions?projectPath=${encodeURIComponent(projectPath)}`
+ ),
+
+ getCursorExampleConfig: (
+ profileId?: 'strict' | 'development'
+ ): Promise<{
+ success: boolean;
+ profileId?: string;
+ config?: string;
+ error?: string;
+ }> =>
+ this.get(
+ `/api/setup/cursor-permissions/example${profileId ? `?profileId=${profileId}` : ''}`
+ ),
+
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},
@@ -1107,12 +1238,14 @@ export class HttpApiClient implements ElectronAPI {
enhance: (
originalText: string,
enhancementMode: string,
- model?: string
+ model?: string,
+ thinkingLevel?: string
): Promise =>
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
+ thinkingLevel,
}),
};
@@ -1181,8 +1314,13 @@ export class HttpApiClient implements ElectronAPI {
// Suggestions API
suggestions: SuggestionsAPI = {
- generate: (projectPath: string, suggestionType?: SuggestionType) =>
- this.post('/api/suggestions/generate', { projectPath, suggestionType }),
+ generate: (
+ projectPath: string,
+ suggestionType?: SuggestionType,
+ model?: string,
+ thinkingLevel?: string
+ ) =>
+ this.post('/api/suggestions/generate', { projectPath, suggestionType, model, thinkingLevel }),
stop: () => this.post('/api/suggestions/stop'),
status: () => this.get('/api/suggestions/status'),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
@@ -1252,8 +1390,12 @@ export class HttpApiClient implements ElectronAPI {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
- validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) =>
- this.post('/api/github/validate-issue', { projectPath, ...issue, model }),
+ validateIssue: (
+ projectPath: string,
+ issue: IssueValidationInput,
+ model?: string,
+ thinkingLevel?: string
+ ) => this.post('/api/github/validate-issue', { projectPath, ...issue, model, thinkingLevel }),
getValidationStatus: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validation-status', { projectPath, issueNumber }),
stopValidation: (projectPath: string, issueNumber: number) =>
@@ -1301,7 +1443,8 @@ export class HttpApiClient implements ElectronAPI {
message: string,
workingDirectory?: string,
imagePaths?: string[],
- model?: string
+ model?: string,
+ thinkingLevel?: string
): Promise<{ success: boolean; error?: string }> =>
this.post('/api/agent/send', {
sessionId,
@@ -1309,6 +1452,7 @@ export class HttpApiClient implements ElectronAPI {
workingDirectory,
imagePaths,
model,
+ thinkingLevel,
}),
getHistory: (
@@ -1335,7 +1479,8 @@ export class HttpApiClient implements ElectronAPI {
sessionId: string,
message: string,
imagePaths?: string[],
- model?: string
+ model?: string,
+ thinkingLevel?: string
): Promise<{
success: boolean;
queuedPrompt?: {
@@ -1343,10 +1488,12 @@ export class HttpApiClient implements ElectronAPI {
message: string;
imagePaths?: string[];
model?: string;
+ thinkingLevel?: string;
addedAt: string;
};
error?: string;
- }> => this.post('/api/agent/queue/add', { sessionId, message, imagePaths, model }),
+ }> =>
+ this.post('/api/agent/queue/add', { sessionId, message, imagePaths, model, thinkingLevel }),
queueList: (
sessionId: string
@@ -1357,6 +1504,7 @@ export class HttpApiClient implements ElectronAPI {
message: string;
imagePaths?: string[];
model?: string;
+ thinkingLevel?: string;
addedAt: string;
}>;
error?: string;
@@ -1438,8 +1586,6 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record;
enabled?: boolean;
}>;
- mcpAutoApproveTools?: boolean;
- mcpUnrestrictedTools?: boolean;
};
error?: string;
}> => this.get('/api/settings/global'),
@@ -1640,6 +1786,66 @@ export class HttpApiClient implements ElectronAPI {
},
};
+ // Ideation API - brainstorming and idea management
+ ideation: IdeationAPI = {
+ startSession: (projectPath: string, options?: StartSessionOptions) =>
+ this.post('/api/ideation/session/start', { projectPath, options }),
+
+ getSession: (projectPath: string, sessionId: string) =>
+ this.post('/api/ideation/session/get', { projectPath, sessionId }),
+
+ sendMessage: (
+ sessionId: string,
+ message: string,
+ options?: { imagePaths?: string[]; model?: string }
+ ) => this.post('/api/ideation/session/message', { sessionId, message, options }),
+
+ stopSession: (sessionId: string) => this.post('/api/ideation/session/stop', { sessionId }),
+
+ listIdeas: (projectPath: string) => this.post('/api/ideation/ideas/list', { projectPath }),
+
+ createIdea: (projectPath: string, idea: CreateIdeaInput) =>
+ this.post('/api/ideation/ideas/create', { projectPath, idea }),
+
+ getIdea: (projectPath: string, ideaId: string) =>
+ this.post('/api/ideation/ideas/get', { projectPath, ideaId }),
+
+ updateIdea: (projectPath: string, ideaId: string, updates: UpdateIdeaInput) =>
+ this.post('/api/ideation/ideas/update', { projectPath, ideaId, updates }),
+
+ deleteIdea: (projectPath: string, ideaId: string) =>
+ this.post('/api/ideation/ideas/delete', { projectPath, ideaId }),
+
+ analyzeProject: (projectPath: string) => this.post('/api/ideation/analyze', { projectPath }),
+
+ generateSuggestions: (
+ projectPath: string,
+ promptId: string,
+ category: IdeaCategory,
+ count?: number
+ ) =>
+ this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }),
+
+ convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) =>
+ this.post('/api/ideation/convert', { projectPath, ideaId, ...options }),
+
+ addSuggestionToBoard: (
+ projectPath: string,
+ suggestion: AnalysisSuggestion
+ ): Promise<{ success: boolean; featureId?: string; error?: string }> =>
+ this.post('/api/ideation/add-suggestion', { projectPath, suggestion }),
+
+ getPrompts: () => this.get('/api/ideation/prompts'),
+
+ onStream: (callback: (event: any) => void): (() => void) => {
+ return this.subscribeToEvent('ideation:stream', callback as EventCallback);
+ },
+
+ onAnalysisEvent: (callback: (event: any) => void): (() => void) => {
+ return this.subscribeToEvent('ideation:analysis', callback as EventCallback);
+ },
+ };
+
// MCP API - Test MCP server connections and list tools
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
// drive-by command execution attacks. Servers must be saved first.
@@ -1787,5 +1993,5 @@ export function getHttpApiClient(): HttpApiClient {
// This ensures the init promise is created early, even before React components mount
// The actual async work happens in the background and won't block module loading
initApiKey().catch((error) => {
- console.error('[HTTP Client] Failed to initialize API key:', error);
+ logger.error('Failed to initialize API key:', error);
});
diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts
index b7a86abe..a6fa3278 100644
--- a/apps/ui/src/lib/log-parser.ts
+++ b/apps/ui/src/lib/log-parser.ts
@@ -3,6 +3,32 @@
* Parses agent output into structured sections for display
*/
+import type {
+ CursorStreamEvent,
+ CursorSystemEvent,
+ CursorAssistantEvent,
+ CursorToolCallEvent,
+ CursorResultEvent,
+} from '@automaker/types';
+
+/**
+ * Cleans up fragmented streaming text by removing spurious newlines
+ * This handles cases where streaming providers send partial text chunks
+ * that got separated by newlines during accumulation
+ */
+function cleanFragmentedText(content: string): string {
+ // Remove newlines that break up words (newline between letters)
+ // e.g., "sum\n\nmary" -> "summary"
+ let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
+
+ // Also clean up fragmented XML-like tags
+ // e.g., "" -> ""
+ cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
+ cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '$1$2>');
+
+ return cleaned;
+}
+
export type LogEntryType =
| 'prompt'
| 'tool_call'
@@ -32,12 +58,16 @@ const TOOL_CATEGORIES: Record = {
Bash: 'bash',
Grep: 'search',
Glob: 'search',
+ Ls: 'read',
+ Delete: 'write',
WebSearch: 'search',
WebFetch: 'read',
TodoWrite: 'todo',
Task: 'task',
NotebookEdit: 'edit',
KillShell: 'bash',
+ SemanticSearch: 'search',
+ ReadLints: 'read',
};
/**
@@ -92,6 +122,8 @@ const generateDeterministicId = (content: string, lineIndex: number): string =>
*/
function detectEntryType(content: string): LogEntryType {
const trimmed = content.trim();
+ // Clean fragmented text for pattern matching
+ const cleaned = cleanFragmentedText(trimmed);
// Tool calls
if (trimmed.startsWith('🔧 Tool:') || trimmed.match(/^Tool:\s*/)) {
@@ -134,14 +166,17 @@ function detectEntryType(content: string): LogEntryType {
}
// Success messages and summary sections
+ // Check both raw and cleaned content for summary tags (handles fragmented streaming)
if (
trimmed.startsWith('✅') ||
trimmed.toLowerCase().includes('success') ||
trimmed.toLowerCase().includes('completed') ||
- // Summary tags (preferred format from agent)
+ // Summary tags (preferred format from agent) - check both raw and cleaned
trimmed.startsWith('') ||
+ cleaned.startsWith('') ||
// Markdown summary headers (fallback)
trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
+ cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i)
) {
return 'success';
@@ -292,6 +327,15 @@ export function generateToolSummary(toolName: string, content: string): string |
case 'KillShell': {
return 'Terminating shell session';
}
+ case 'SemanticSearch': {
+ const query = parsed.query as string | undefined;
+ return `Semantic search: "${query?.slice(0, 30) || ''}"`;
+ }
+ case 'ReadLints': {
+ const paths = parsed.paths as string[] | undefined;
+ const pathCount = paths?.length || 0;
+ return `Reading lints for ${pathCount} file(s)`;
+ }
default:
return undefined;
}
@@ -300,6 +344,455 @@ export function generateToolSummary(toolName: string, content: string): string |
}
}
+// ============================================================================
+// Cursor Event Parsing
+// ============================================================================
+
+/**
+ * Detect if a parsed JSON object is a Cursor stream event
+ */
+function isCursorEvent(obj: unknown): obj is CursorStreamEvent {
+ return (
+ obj !== null &&
+ typeof obj === 'object' &&
+ 'type' in obj &&
+ 'session_id' in obj &&
+ ['system', 'user', 'assistant', 'tool_call', 'result'].includes(
+ (obj as Record).type as string
+ )
+ );
+}
+
+/**
+ * Normalize Cursor tool call event to log entry
+ */
+function normalizeCursorToolCall(
+ event: CursorToolCallEvent,
+ baseEntry: { id: string; timestamp: string }
+): LogEntry | null {
+ const toolCall = event.tool_call;
+ const isStarted = event.subtype === 'started';
+ const isCompleted = event.subtype === 'completed';
+
+ // Read tool
+ if (toolCall.readToolCall) {
+ const path = toolCall.readToolCall.args?.path || 'unknown';
+ const result = toolCall.readToolCall.result?.success;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Reading ${path}` : `Read ${path}`,
+ content:
+ isCompleted && result
+ ? `${result.totalLines} lines, ${result.totalChars} chars`
+ : `Path: ${path}`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Read',
+ toolCategory: 'read' as ToolCategory,
+ filePath: path,
+ summary: isCompleted ? `Read ${result?.totalLines || 0} lines` : `Reading file...`,
+ },
+ };
+ }
+
+ // Write tool
+ if (toolCall.writeToolCall) {
+ const path =
+ toolCall.writeToolCall.args?.path ||
+ toolCall.writeToolCall.result?.success?.path ||
+ 'unknown';
+ const result = toolCall.writeToolCall.result?.success;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Writing ${path}` : `Wrote ${path}`,
+ content:
+ isCompleted && result
+ ? `${result.linesCreated} lines, ${result.fileSize} bytes`
+ : `Path: ${path}`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Write',
+ toolCategory: 'write' as ToolCategory,
+ filePath: path,
+ summary: isCompleted ? `Wrote ${result?.linesCreated || 0} lines` : `Writing file...`,
+ },
+ };
+ }
+
+ // Edit tool
+ if (toolCall.editToolCall) {
+ const path = toolCall.editToolCall.args?.path || 'unknown';
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Editing ${path}` : `Edited ${path}`,
+ content: `Path: ${path}`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Edit',
+ toolCategory: 'edit' as ToolCategory,
+ filePath: path,
+ summary: isCompleted ? `Edited file` : `Editing file...`,
+ },
+ };
+ }
+
+ // Shell/Bash tool
+ if (toolCall.shellToolCall) {
+ const command = toolCall.shellToolCall.args?.command || '';
+ const result = toolCall.shellToolCall.result;
+ const shortCmd = command.length > 50 ? command.slice(0, 50) + '...' : command;
+
+ let content = `Command: ${command}`;
+ if (isCompleted && result?.success) {
+ content += `\nExit code: ${result.success.exitCode}`;
+ } else if (isCompleted && result?.rejected) {
+ content += `\nRejected: ${result.rejected.reason}`;
+ }
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Running: ${shortCmd}` : `Ran: ${shortCmd}`,
+ content,
+ collapsed: true,
+ metadata: {
+ toolName: 'Bash',
+ toolCategory: 'bash' as ToolCategory,
+ summary: isCompleted
+ ? result?.success
+ ? `Exit ${result.success.exitCode}`
+ : result?.rejected
+ ? 'Rejected'
+ : 'Completed'
+ : `Running...`,
+ },
+ };
+ }
+
+ // Delete tool
+ if (toolCall.deleteToolCall) {
+ const path = toolCall.deleteToolCall.args?.path || 'unknown';
+ const result = toolCall.deleteToolCall.result;
+
+ let content = `Path: ${path}`;
+ if (isCompleted && result?.rejected) {
+ content += `\nRejected: ${result.rejected.reason}`;
+ }
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Deleting ${path}` : `Deleted ${path}`,
+ content,
+ collapsed: true,
+ metadata: {
+ toolName: 'Delete',
+ toolCategory: 'write' as ToolCategory,
+ filePath: path,
+ summary: isCompleted ? (result?.rejected ? 'Rejected' : 'Deleted') : `Deleting...`,
+ },
+ };
+ }
+
+ // Grep tool
+ if (toolCall.grepToolCall) {
+ const pattern = toolCall.grepToolCall.args?.pattern || '';
+ const searchPath = toolCall.grepToolCall.args?.path;
+ const result = toolCall.grepToolCall.result?.success;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Searching: "${pattern}"` : `Searched: "${pattern}"`,
+ content: `Pattern: ${pattern}${searchPath ? `\nPath: ${searchPath}` : ''}${
+ isCompleted && result ? `\nMatched ${result.matchedLines} lines` : ''
+ }`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Grep',
+ toolCategory: 'search' as ToolCategory,
+ summary: isCompleted ? `Found ${result?.matchedLines || 0} matches` : `Searching...`,
+ },
+ };
+ }
+
+ // Ls tool
+ if (toolCall.lsToolCall) {
+ const path = toolCall.lsToolCall.args?.path || '.';
+ const result = toolCall.lsToolCall.result?.success;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Listing ${path}` : `Listed ${path}`,
+ content: `Path: ${path}${
+ isCompleted && result
+ ? `\n${result.childrenFiles} files, ${result.childrenDirs} directories`
+ : ''
+ }`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Ls',
+ toolCategory: 'read' as ToolCategory,
+ filePath: path,
+ summary: isCompleted
+ ? `${result?.childrenFiles || 0} files, ${result?.childrenDirs || 0} dirs`
+ : `Listing...`,
+ },
+ };
+ }
+
+ // Glob tool
+ if (toolCall.globToolCall) {
+ const pattern = toolCall.globToolCall.args?.globPattern || '';
+ const targetDir = toolCall.globToolCall.args?.targetDirectory;
+ const result = toolCall.globToolCall.result?.success;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Finding: ${pattern}` : `Found: ${pattern}`,
+ content: `Pattern: ${pattern}${targetDir ? `\nDirectory: ${targetDir}` : ''}${
+ isCompleted && result ? `\nFound ${result.totalFiles} files` : ''
+ }`,
+ collapsed: true,
+ metadata: {
+ toolName: 'Glob',
+ toolCategory: 'search' as ToolCategory,
+ summary: isCompleted ? `Found ${result?.totalFiles || 0} files` : `Finding...`,
+ },
+ };
+ }
+
+ // Semantic Search tool
+ if (toolCall.semSearchToolCall) {
+ const query = toolCall.semSearchToolCall.args?.query || '';
+ const targetDirs = toolCall.semSearchToolCall.args?.targetDirectories;
+ const result = toolCall.semSearchToolCall.result?.success;
+ const shortQuery = query.length > 40 ? query.slice(0, 40) + '...' : query;
+ const resultCount = result?.codeResults?.length || 0;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Semantic search: "${shortQuery}"` : `Searched: "${shortQuery}"`,
+ content: `Query: ${query}${targetDirs?.length ? `\nDirectories: ${targetDirs.join(', ')}` : ''}${
+ isCompleted
+ ? `\n${resultCount > 0 ? `Found ${resultCount} result(s)` : result?.results || 'No results'}`
+ : ''
+ }`,
+ collapsed: true,
+ metadata: {
+ toolName: 'SemanticSearch',
+ toolCategory: 'search' as ToolCategory,
+ summary: isCompleted
+ ? resultCount > 0
+ ? `Found ${resultCount} result(s)`
+ : 'No results'
+ : `Searching...`,
+ },
+ };
+ }
+
+ // Read Lints tool
+ if (toolCall.readLintsToolCall) {
+ const paths = toolCall.readLintsToolCall.args?.paths || [];
+ const result = toolCall.readLintsToolCall.result?.success;
+ const pathCount = paths.length;
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: isStarted ? `Reading lints for ${pathCount} file(s)` : `Read lints`,
+ content: `Paths: ${paths.join(', ')}${
+ isCompleted && result
+ ? `\nFound ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`
+ : ''
+ }`,
+ collapsed: true,
+ metadata: {
+ toolName: 'ReadLints',
+ toolCategory: 'read' as ToolCategory,
+ summary: isCompleted
+ ? `${result?.totalDiagnostics || 0} diagnostic(s)`
+ : `Reading lints...`,
+ },
+ };
+ }
+
+ // Generic function tool (fallback)
+ if (toolCall.function) {
+ const name = toolCall.function.name;
+ const args = toolCall.function.arguments;
+
+ // Determine category based on tool name
+ const category = categorizeToolName(name);
+
+ return {
+ ...baseEntry,
+ id: `${baseEntry.id}-${event.call_id}`,
+ type: 'tool_call' as LogEntryType,
+ title: `${name} ${isStarted ? 'started' : 'completed'}`,
+ content: args || '',
+ collapsed: true,
+ metadata: {
+ toolName: name,
+ toolCategory: category,
+ summary: `${name} ${event.subtype}`,
+ },
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Normalize Cursor stream event to log entry
+ */
+export function normalizeCursorEvent(event: CursorStreamEvent): LogEntry | null {
+ const timestamp = new Date().toISOString();
+ const baseEntry = {
+ id: `cursor-${event.session_id}-${Date.now()}`,
+ timestamp,
+ };
+
+ switch (event.type) {
+ case 'system': {
+ const sysEvent = event as CursorSystemEvent;
+ return {
+ ...baseEntry,
+ type: 'info' as LogEntryType,
+ title: 'Session Started',
+ content: `Model: ${sysEvent.model}\nAuth: ${sysEvent.apiKeySource}\nCWD: ${sysEvent.cwd}`,
+ collapsed: true,
+ metadata: {
+ phase: 'init',
+ },
+ };
+ }
+
+ case 'assistant': {
+ const assistEvent = event as CursorAssistantEvent;
+ const text = assistEvent.message.content
+ .filter((c) => c.type === 'text')
+ .map((c) => c.text)
+ .join('');
+
+ if (!text.trim()) return null;
+
+ return {
+ ...baseEntry,
+ type: 'info' as LogEntryType,
+ title: 'Assistant',
+ content: text,
+ collapsed: false,
+ };
+ }
+
+ case 'tool_call': {
+ const toolEvent = event as CursorToolCallEvent;
+ return normalizeCursorToolCall(toolEvent, baseEntry);
+ }
+
+ case 'result': {
+ const resultEvent = event as CursorResultEvent;
+
+ if (resultEvent.is_error) {
+ return {
+ ...baseEntry,
+ type: 'error' as LogEntryType,
+ title: 'Error',
+ content: resultEvent.error || resultEvent.result || 'Unknown error',
+ collapsed: false,
+ };
+ }
+
+ return {
+ ...baseEntry,
+ type: 'success' as LogEntryType,
+ title: 'Completed',
+ content: `Duration: ${resultEvent.duration_ms}ms`,
+ collapsed: true,
+ };
+ }
+
+ default:
+ return null;
+ }
+}
+
+/**
+ * Parse a single log line into a structured entry
+ * Handles both Cursor JSON events and plain text
+ */
+export function parseLogLine(line: string): LogEntry | null {
+ if (!line.trim()) return null;
+
+ try {
+ const parsed = JSON.parse(line);
+
+ // Check if it's a Cursor stream event
+ if (isCursorEvent(parsed)) {
+ return normalizeCursorEvent(parsed);
+ }
+
+ // For other JSON, treat as debug info
+ return {
+ id: `json-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ type: 'debug',
+ title: 'Debug Info',
+ content: line,
+ timestamp: new Date().toISOString(),
+ collapsed: true,
+ };
+ } catch {
+ // Non-JSON line - treat as plain text
+ return {
+ id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`,
+ type: 'info',
+ title: 'Output',
+ content: line,
+ timestamp: new Date().toISOString(),
+ collapsed: false,
+ };
+ }
+}
+
+/**
+ * Get provider-specific styling for log entries
+ */
+export function getProviderStyle(entry: LogEntry): { badge?: string; icon?: string } {
+ // Check if entry has Cursor session ID pattern
+ if (entry.id.startsWith('cursor-')) {
+ return {
+ badge: 'Cursor',
+ icon: 'terminal',
+ };
+ }
+
+ // Default (Claude/AutoMaker)
+ return {
+ badge: 'Claude',
+ icon: 'bot',
+ };
+}
+
/**
* Determines if an entry should be collapsed by default
*/
@@ -339,6 +832,9 @@ export function shouldCollapseByDefault(entry: LogEntry): boolean {
* Generates a title for a log entry
*/
function generateTitle(type: LogEntryType, content: string): string {
+ // Clean content for pattern matching
+ const cleaned = cleanFragmentedText(content);
+
switch (type) {
case 'tool_call': {
const toolName = extractToolName(content);
@@ -361,11 +857,19 @@ function generateTitle(type: LogEntryType, content: string): string {
case 'error':
return 'Error';
case 'success': {
- // Check if it's a summary section
- if (content.startsWith('') || content.includes('')) {
+ // Check if it's a summary section (check both raw and cleaned)
+ if (
+ content.startsWith('') ||
+ content.includes('') ||
+ cleaned.startsWith('') ||
+ cleaned.includes('')
+ ) {
return 'Summary';
}
- if (content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)) {
+ if (
+ content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
+ cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i)
+ ) {
return 'Summary';
}
if (
@@ -489,6 +993,26 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
continue;
}
+ // Check for Cursor stream events (NDJSON lines)
+ // These are complete JSON objects on a single line
+ if (trimmedLine.startsWith('{') && trimmedLine.endsWith('}')) {
+ try {
+ const parsed = JSON.parse(trimmedLine);
+ if (isCursorEvent(parsed)) {
+ // Finalize any pending entry before adding Cursor event
+ finalizeEntry();
+ const cursorEntry = normalizeCursorEvent(parsed);
+ if (cursorEntry) {
+ entries.push(cursorEntry);
+ }
+ lineIndex++;
+ continue;
+ }
+ } catch {
+ // Not valid JSON, continue with normal parsing
+ }
+ }
+
// If we're in JSON accumulation mode, keep accumulating until depth returns to 0
if (inJsonAccumulation) {
currentContent.push(line);
@@ -537,10 +1061,12 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
trimmedLine.match(/\[Status\]/i) ||
trimmedLine.toLowerCase().includes('ultrathink preparation') ||
trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) ||
- // Summary tags (preferred format from agent)
+ // Summary tags (preferred format from agent) - check both raw and cleaned for fragmented streaming
trimmedLine.startsWith('') ||
+ cleanFragmentedText(trimmedLine).startsWith('') ||
// Agent summary sections (markdown headers - fallback)
trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
+ cleanFragmentedText(trimmedLine).match(/^##\s+(Summary|Feature|Changes|Implementation)/i) ||
// Summary introduction lines
trimmedLine.match(/^All tasks completed/i) ||
trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i);
@@ -568,7 +1094,13 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
currentContent.push(trimmedLine);
// If this is a tag, start summary accumulation mode
- if (trimmedLine.startsWith('') && !trimmedLine.includes('')) {
+ // Check both raw and cleaned for fragmented streaming
+ const cleanedTrimmed = cleanFragmentedText(trimmedLine);
+ if (
+ (trimmedLine.startsWith('') || cleanedTrimmed.startsWith('')) &&
+ !trimmedLine.includes('') &&
+ !cleanedTrimmed.includes('')
+ ) {
inSummaryAccumulation = true;
}
} else if (isInputLine && currentEntry) {
@@ -664,6 +1196,53 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
return merged;
}
+/**
+ * Extracts summary content from raw log output
+ * Returns the summary text if found, or null if no summary exists
+ */
+export function extractSummary(rawOutput: string): string | null {
+ if (!rawOutput || !rawOutput.trim()) {
+ return null;
+ }
+
+ // Try to find tags first (preferred format)
+ const summaryTagMatch = rawOutput.match(/([\s\S]*?)<\/summary>/);
+ if (summaryTagMatch) {
+ return summaryTagMatch[1].trim();
+ }
+
+ // Try to find markdown ## Summary section
+ const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
+ if (summaryHeaderMatch) {
+ return summaryHeaderMatch[1].trim();
+ }
+
+ // Try other summary formats (Feature, Changes, Implementation)
+ const otherHeaderMatch = rawOutput.match(
+ /^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
+ );
+ if (otherHeaderMatch) {
+ return `## ${otherHeaderMatch[1]}\n${otherHeaderMatch[2].trim()}`;
+ }
+
+ // Try to find summary introduction lines
+ const introMatch = rawOutput.match(
+ /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
+ );
+ if (introMatch) {
+ return introMatch[2].trim();
+ }
+
+ const completionMatch = rawOutput.match(
+ /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
+ );
+ if (completionMatch) {
+ return completionMatch[2].trim();
+ }
+
+ return null;
+}
+
/**
* Gets the color classes for a log entry type
*/
diff --git a/apps/ui/src/lib/project-init.ts b/apps/ui/src/lib/project-init.ts
index f8b39933..b4334ee9 100644
--- a/apps/ui/src/lib/project-init.ts
+++ b/apps/ui/src/lib/project-init.ts
@@ -5,8 +5,11 @@
* new or existing projects.
*/
+import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from './electron';
+const logger = createLogger('ProjectInit');
+
export interface ProjectInitResult {
success: boolean;
isNewProject: boolean;
@@ -72,22 +75,22 @@ export async function initializeProject(projectPath: string): Promise