diff --git a/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts index 8d134078..df4e18e4 100644 --- a/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts @@ -260,7 +260,7 @@ export function registerTaskHandlers( async ( _, taskId: string, - updates: { title?: string; description?: string } + updates: { title?: string; description?: string; metadata?: Partial } ): Promise> => { try { // Find task and project @@ -363,11 +363,80 @@ export function registerTaskHandlers( } } + // Update metadata if provided + let updatedMetadata = task.metadata; + if (updates.metadata) { + updatedMetadata = { ...task.metadata, ...updates.metadata }; + + // Process and save attached images if provided + if (updates.metadata.attachedImages && updates.metadata.attachedImages.length > 0) { + const attachmentsDir = path.join(specDir, 'attachments'); + mkdirSync(attachmentsDir, { recursive: true }); + + const savedImages: typeof updates.metadata.attachedImages = []; + + for (const image of updates.metadata.attachedImages) { + // If image has data (new image), save it + if (image.data) { + try { + const buffer = Buffer.from(image.data, 'base64'); + const imagePath = path.join(attachmentsDir, image.filename); + writeFileSync(imagePath, buffer); + + savedImages.push({ + id: image.id, + filename: image.filename, + mimeType: image.mimeType, + size: image.size, + path: `attachments/${image.filename}` + }); + } catch (err) { + console.error(`Failed to save image ${image.filename}:`, err); + } + } else if (image.path) { + // Existing image, keep it + savedImages.push(image); + } + } + + updatedMetadata.attachedImages = savedImages; + } + + // Update task_metadata.json + const metadataPath = path.join(specDir, 'task_metadata.json'); + try { + writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2)); + } catch (err) { + console.error('Failed to update task_metadata.json:', err); + } + + // Update requirements.json if it exists + const requirementsPath = path.join(specDir, 'requirements.json'); + if (existsSync(requirementsPath)) { + try { + const requirementsContent = readFileSync(requirementsPath, 'utf-8'); + const requirements = JSON.parse(requirementsContent); + + if (updates.description !== undefined) { + requirements.task_description = updates.description; + } + if (updates.metadata.category) { + requirements.workflow_type = updates.metadata.category; + } + + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); + } catch (err) { + console.error('Failed to update requirements.json:', err); + } + } + } + // Build the updated task object const updatedTask: Task = { ...task, title: finalTitle ?? task.title, description: updates.description ?? task.description, + metadata: updatedMetadata, updatedAt: new Date() }; diff --git a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts index 5b00ae7d..ed9350d1 100644 --- a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts @@ -5,6 +5,7 @@ import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSett import { getClaudeProfileManager } from '../claude-profile-manager'; import { TerminalManager } from '../terminal-manager'; import { projectStore } from '../project-store'; +import { terminalNameGenerator } from '../terminal-name-generator'; /** @@ -53,6 +54,25 @@ export function registerTerminalHandlers( } ); + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GENERATE_NAME, + async (_, command: string, cwd?: string): Promise> => { + try { + const name = await terminalNameGenerator.generateName(command, cwd); + if (name) { + return { success: true, data: name }; + } else { + return { success: false, error: 'Failed to generate terminal name' }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to generate terminal name' + }; + } + } + ); + // Claude profile management (multi-account support) ipcMain.handle( IPC_CHANNELS.CLAUDE_PROFILES_GET, diff --git a/auto-claude-ui/src/main/terminal-name-generator.ts b/auto-claude-ui/src/main/terminal-name-generator.ts new file mode 100644 index 00000000..a9eb7999 --- /dev/null +++ b/auto-claude-ui/src/main/terminal-name-generator.ts @@ -0,0 +1,306 @@ +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { spawn } from 'child_process'; +import { app } from 'electron'; +import { EventEmitter } from 'events'; +import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; + +/** + * Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set + */ +const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1'; + +function debug(...args: unknown[]): void { + if (DEBUG) { + console.log('[TerminalNameGenerator]', ...args); + } +} + +/** + * Service for generating terminal names from commands using Claude AI + */ +export class TerminalNameGenerator extends EventEmitter { + private pythonPath: string = 'python3'; + private autoBuildSourcePath: string = ''; + + constructor() { + super(); + debug('TerminalNameGenerator initialized'); + } + + /** + * Configure paths for Python and auto-claude source + */ + configure(pythonPath?: string, autoBuildSourcePath?: string): void { + if (pythonPath) { + this.pythonPath = pythonPath; + } + if (autoBuildSourcePath) { + this.autoBuildSourcePath = autoBuildSourcePath; + } + } + + /** + * Get the auto-claude source path (detects automatically if not configured) + */ + private getAutoBuildSourcePath(): string | null { + if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { + return this.autoBuildSourcePath; + } + + const possiblePaths = [ + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + path.resolve(app.getAppPath(), '..', 'auto-claude'), + path.resolve(process.cwd(), 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; + } + + /** + * Load environment variables from auto-claude .env file + */ + private loadAutoBuildEnv(): Record { + const autoBuildSource = this.getAutoBuildSourcePath(); + if (!autoBuildSource) return {}; + + const envPath = path.join(autoBuildSource, '.env'); + if (!existsSync(envPath)) return {}; + + try { + const envContent = readFileSync(envPath, 'utf-8'); + const envVars: Record = {}; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + envVars[key] = value; + } + } + + return envVars; + } catch { + return {}; + } + } + + /** + * Generate a terminal name from a command using Claude AI + * @param command - The command or recent output to generate a name from + * @param cwd - Current working directory for context + * @returns Promise resolving to the generated name (2-3 words) or null on failure + */ + async generateName(command: string, cwd?: string): Promise { + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + debug('Auto-claude source path not found'); + return null; + } + + const prompt = this.createNamePrompt(command, cwd); + const script = this.createGenerationScript(prompt); + + debug('Generating terminal name for command:', command.substring(0, 100) + '...'); + + const autoBuildEnv = this.loadAutoBuildEnv(); + debug('Environment loaded', { + hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN + }); + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const profileEnv = getProfileEnv(); + + return new Promise((resolve) => { + const childProcess = spawn(this.pythonPath, ['-c', script], { + cwd: autoBuildSource, + env: { + ...process.env, + ...autoBuildEnv, + ...profileEnv, // Include active Claude profile config + PYTHONUNBUFFERED: '1' + } + }); + + let output = ''; + let errorOutput = ''; + const timeout = setTimeout(() => { + debug('Terminal name generation timed out after 30s'); + childProcess.kill(); + resolve(null); + }, 30000); // 30 second timeout + + childProcess.stdout?.on('data', (data: Buffer) => { + output += data.toString(); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + errorOutput += data.toString(); + }); + + childProcess.on('exit', (code: number | null) => { + clearTimeout(timeout); + + if (code === 0 && output.trim()) { + const name = this.cleanName(output.trim()); + debug('Generated terminal name:', name); + resolve(name); + } else { + // Check for rate limit + const combinedOutput = `${output}\n${errorOutput}`; + const rateLimitDetection = detectRateLimit(combinedOutput); + if (rateLimitDetection.isRateLimited) { + debug('Rate limit detected:', { + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + const rateLimitInfo = createSDKRateLimitInfo('terminal-name-generator', rateLimitDetection); + this.emit('sdk-rate-limit', rateLimitInfo); + } + + if (!rateLimitDetection.isRateLimited) { + debug('Terminal name generation failed', { + code, + errorOutput: errorOutput.substring(0, 500) + }); + } + resolve(null); + } + }); + + childProcess.on('error', (err) => { + clearTimeout(timeout); + debug('Process error:', err.message); + resolve(null); + }); + }); + } + + /** + * Create the prompt for terminal name generation + */ + private createNamePrompt(command: string, cwd?: string): string { + let prompt = `Generate a very short, descriptive name (2-3 words MAX) for a terminal window based on what it's doing. The name should be concise and help identify the terminal at a glance. + +Command or activity: +${command}`; + + if (cwd) { + prompt += ` + +Working directory: +${cwd}`; + } + + prompt += ` + +Output ONLY the name (2-3 words), nothing else. Examples: "npm build", "git logs", "python tests", "claude dev"`; + + return prompt; + } + + /** + * Create the Python script to generate terminal name using Claude Agent SDK + */ + private createGenerationScript(prompt: string): string { + // Escape the prompt for Python string - use JSON.stringify for safe escaping + const escapedPrompt = JSON.stringify(prompt); + + return ` +import asyncio +import sys + +async def generate_name(): + try: + from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + + prompt = ${escapedPrompt} + + # Create a minimal client for simple text generation (no tools needed) + client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model="claude-haiku-4-5", + system_prompt="You generate very short, concise terminal names (2-3 words MAX). Output ONLY the name, nothing else. No quotes, no explanation, no preamble. Keep it as short as possible while being descriptive.", + max_turns=1, + ) + ) + + async with client: + # Send the query + await client.query(prompt) + + # Collect response text from AssistantMessage + response_text = "" + async for msg in client.receive_response(): + msg_type = type(msg).__name__ + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + if block_type == "TextBlock" and hasattr(block, "text"): + response_text += block.text + + if response_text: + # Clean up the result + name = response_text.strip() + # Remove any quotes + name = name.strip('"').strip("'") + # Take first line only + name = name.split('\\n')[0].strip() + if name: + print(name) + sys.exit(0) + + # If we get here, no valid response + sys.exit(1) + + except ImportError as e: + print(f"Import error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +asyncio.run(generate_name()) +`; + } + + /** + * Clean up the generated name + */ + private cleanName(name: string): string { + // Remove quotes if present + let cleaned = name.replace(/^["']|["']$/g, ''); + + // Remove any "Terminal:" or similar prefixes + cleaned = cleaned.replace(/^(terminal|name)[:\s]*/i, ''); + + // Truncate if too long (max 30 chars for terminal names) + if (cleaned.length > 30) { + cleaned = cleaned.substring(0, 27) + '...'; + } + + return cleaned.trim(); + } +} + +// Export singleton instance +export const terminalNameGenerator = new TerminalNameGenerator(); diff --git a/auto-claude-ui/src/preload/api/terminal-api.ts b/auto-claude-ui/src/preload/api/terminal-api.ts index c735c2a4..5564e19e 100644 --- a/auto-claude-ui/src/preload/api/terminal-api.ts +++ b/auto-claude-ui/src/preload/api/terminal-api.ts @@ -15,6 +15,7 @@ export interface TerminalAPI { sendTerminalInput: (id: string, data: string) => void; resizeTerminal: (id: string, cols: number, rows: number) => void; invokeClaudeInTerminal: (id: string, cwd?: string) => void; + generateTerminalName: (command: string, cwd?: string) => Promise>; // Terminal Session Management getTerminalSessions: (projectPath: string) => Promise>; @@ -81,6 +82,9 @@ export const createTerminalAPI = (): TerminalAPI => ({ invokeClaudeInTerminal: (id: string, cwd?: string): void => ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd), + generateTerminalName: (command: string, cwd?: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GENERATE_NAME, command, cwd), + // Terminal Session Management getTerminalSessions: (projectPath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath), diff --git a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx index a7175780..857ffbb7 100644 --- a/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx +++ b/auto-claude-ui/src/renderer/components/TaskEditDialog.tsx @@ -1,13 +1,16 @@ /** - * TaskEditDialog - Dialog for editing task title and description + * TaskEditDialog - Dialog for editing task details * - * Allows users to modify the title and description of an existing task. + * Allows users to modify all task properties including title, description, + * classification fields, images, and review settings. * Follows the same dialog pattern as TaskCreationWizard for consistency. * * Features: * - Pre-populates form with current task values - * - Form validation (title and description required) - * - Shows attached images in read-only mode (if task has images) + * - Form validation (description required) + * - Editable classification fields (category, priority, complexity, impact) + * - Editable image attachments (add/remove images) + * - Editable review settings (requireReviewBeforeCoding) * - Saves changes via persistUpdateTask (updates store + spec files) * - Prevents save when no changes have been made * @@ -21,8 +24,8 @@ * /> * ``` */ -import { useState, useEffect } from 'react'; -import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp } from 'lucide-react'; +import { useState, useEffect, useCallback, useRef, type ClipboardEvent } from 'react'; +import { Loader2, Image as ImageIcon, ChevronDown, ChevronUp, X } from 'lucide-react'; import { Dialog, DialogContent, @@ -35,9 +38,33 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { Textarea } from './ui/textarea'; import { Label } from './ui/label'; +import { Checkbox } from './ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from './ui/select'; +import { + ImageUpload, + generateImageId, + blobToBase64, + createThumbnail, + isValidImageMimeType, + resolveFilename +} from './ImageUpload'; import { persistUpdateTask } from '../stores/task-store'; import { cn } from '../lib/utils'; -import type { Task, ImageAttachment } from '../../shared/types'; +import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types'; +import { + TASK_CATEGORY_LABELS, + TASK_PRIORITY_LABELS, + TASK_COMPLEXITY_LABELS, + TASK_IMPACT_LABELS, + MAX_IMAGES_PER_TASK, + ALLOWED_IMAGE_TYPES_DISPLAY +} from '../../shared/constants'; /** * Props for the TaskEditDialog component @@ -58,21 +85,129 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi const [description, setDescription] = useState(task.description); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); const [showImages, setShowImages] = useState(false); + const [pasteSuccess, setPasteSuccess] = useState(false); - // Get attached images from task metadata - const attachedImages: ImageAttachment[] = task.metadata?.attachedImages || []; + // Classification fields + const [category, setCategory] = useState(task.metadata?.category || ''); + const [priority, setPriority] = useState(task.metadata?.priority || ''); + const [complexity, setComplexity] = useState(task.metadata?.complexity || ''); + const [impact, setImpact] = useState(task.metadata?.impact || ''); + + // Image attachments + const [images, setImages] = useState(task.metadata?.attachedImages || []); + + // Review setting + const [requireReviewBeforeCoding, setRequireReviewBeforeCoding] = useState( + task.metadata?.requireReviewBeforeCoding ?? false + ); + + // Ref for the textarea to handle paste events + const descriptionRef = useRef(null); // Reset form when task changes or dialog opens useEffect(() => { if (open) { setTitle(task.title); setDescription(task.description); + setCategory(task.metadata?.category || ''); + setPriority(task.metadata?.priority || ''); + setComplexity(task.metadata?.complexity || ''); + setImpact(task.metadata?.impact || ''); + setImages(task.metadata?.attachedImages || []); + setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false); setError(null); + + // Auto-expand sections if they have content + if (task.metadata?.category || task.metadata?.priority || task.metadata?.complexity || task.metadata?.impact) { + setShowAdvanced(true); + } // Auto-expand images section if task has images - setShowImages(attachedImages.length > 0); + setShowImages((task.metadata?.attachedImages || []).length > 0); + setPasteSuccess(false); } - }, [open, task.title, task.description, attachedImages.length]); + }, [open, task]); + + /** + * Handle paste event for screenshot support + */ + const handlePaste = useCallback(async (e: ClipboardEvent) => { + const clipboardItems = e.clipboardData?.items; + if (!clipboardItems) return; + + // Find image items in clipboard + const imageItems: DataTransferItem[] = []; + for (let i = 0; i < clipboardItems.length; i++) { + const item = clipboardItems[i]; + if (item.type.startsWith('image/')) { + imageItems.push(item); + } + } + + // If no images, allow normal paste behavior + if (imageItems.length === 0) return; + + // Prevent default paste when we have images + e.preventDefault(); + + // Check if we can add more images + const remainingSlots = MAX_IMAGES_PER_TASK - images.length; + if (remainingSlots <= 0) { + setError(`Maximum of ${MAX_IMAGES_PER_TASK} images allowed`); + return; + } + + setError(null); + + // Process image items + const newImages: ImageAttachment[] = []; + const existingFilenames = images.map(img => img.filename); + + for (const item of imageItems.slice(0, remainingSlots)) { + const file = item.getAsFile(); + if (!file) continue; + + // Validate image type + if (!isValidImageMimeType(file.type)) { + setError(`Invalid image type. Allowed: ${ALLOWED_IMAGE_TYPES_DISPLAY}`); + continue; + } + + try { + const dataUrl = await blobToBase64(file); + const thumbnail = await createThumbnail(dataUrl); + + // Generate filename for pasted images (screenshot-timestamp.ext) + const extension = file.type.split('/')[1] || 'png'; + const baseFilename = `screenshot-${Date.now()}.${extension}`; + const resolvedFilename = resolveFilename(baseFilename, [ + ...existingFilenames, + ...newImages.map(img => img.filename) + ]); + + newImages.push({ + id: generateImageId(), + filename: resolvedFilename, + mimeType: file.type, + size: file.size, + data: dataUrl.split(',')[1], // Store base64 without data URL prefix + thumbnail + }); + } catch { + setError('Failed to process pasted image'); + } + } + + if (newImages.length > 0) { + setImages(prev => [...prev, ...newImages]); + // Auto-expand images section + setShowImages(true); + // Show success feedback + setPasteSuccess(true); + setTimeout(() => setPasteSuccess(false), 2000); + } + }, [images]); const handleSave = async () => { // Validate input - only description is required @@ -84,7 +219,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi // Check if anything changed const trimmedTitle = title.trim(); const trimmedDescription = description.trim(); - if (trimmedTitle === task.title && trimmedDescription === task.description) { + const hasChanges = + trimmedTitle !== task.title || + trimmedDescription !== task.description || + category !== (task.metadata?.category || '') || + priority !== (task.metadata?.priority || '') || + complexity !== (task.metadata?.complexity || '') || + impact !== (task.metadata?.impact || '') || + requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) || + JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []); + + if (!hasChanges) { // No changes, just close onOpenChange(false); return; @@ -93,10 +238,20 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi setIsSaving(true); setError(null); + // Build metadata updates + const metadataUpdates: Partial = {}; + if (category) metadataUpdates.category = category; + if (priority) metadataUpdates.priority = priority; + if (complexity) metadataUpdates.complexity = complexity; + if (impact) metadataUpdates.impact = impact; + if (images.length > 0) metadataUpdates.attachedImages = images; + metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding; + // Title is optional - if empty, it will be auto-generated by the backend const success = await persistUpdateTask(task.id, { title: trimmedTitle, - description: trimmedDescription + description: trimmedDescription, + metadata: metadataUpdates }); if (success) { @@ -124,7 +279,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi Edit Task - Update the task title and description. Changes will be saved to the spec files. + Update task details including title, description, classification, images, and settings. Changes will be saved to the spec files. @@ -135,13 +290,18 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi Description *