terminal name generator

This commit is contained in:
AndyMik90
2025-12-15 14:06:52 +01:00
parent 92998dedd6
commit cfeb15726e
13 changed files with 909 additions and 99 deletions
@@ -260,7 +260,7 @@ export function registerTaskHandlers(
async (
_,
taskId: string,
updates: { title?: string; description?: string }
updates: { title?: string; description?: string; metadata?: Partial<TaskMetadata> }
): Promise<IPCResult<Task>> => {
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()
};
@@ -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<IPCResult<string>> => {
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,
@@ -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<string, string> {
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<string, string> = {};
// 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<string | null> {
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();
@@ -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<IPCResult<string>>;
// Terminal Session Management
getTerminalSessions: (projectPath: string) => Promise<IPCResult<import('../../shared/types').TerminalSession[]>>;
@@ -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<IPCResult<string>> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GENERATE_NAME, command, cwd),
// Terminal Session Management
getTerminalSessions: (projectPath: string): Promise<IPCResult<import('../../shared/types').TerminalSession[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath),
@@ -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<string | null>(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<TaskCategory | ''>(task.metadata?.category || '');
const [priority, setPriority] = useState<TaskPriority | ''>(task.metadata?.priority || '');
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
// Image attachments
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
// Review setting
const [requireReviewBeforeCoding, setRequireReviewBeforeCoding] = useState(
task.metadata?.requireReviewBeforeCoding ?? false
);
// Ref for the textarea to handle paste events
const descriptionRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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<typeof task.metadata> = {};
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
<DialogHeader>
<DialogTitle className="text-foreground">Edit Task</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -135,13 +290,18 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
Description <span className="text-destructive">*</span>
</Label>
<Textarea
ref={descriptionRef}
id="edit-description"
placeholder="Describe the feature, bug fix, or improvement..."
placeholder="Describe the feature, bug fix, or improvement. Be as specific as possible about requirements, constraints, and expected behavior."
value={description}
onChange={(e) => setDescription(e.target.value)}
onPaste={handlePaste}
rows={5}
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images.
</p>
</div>
{/* Title (Optional - Auto-generated if empty) */}
@@ -161,71 +321,202 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
</p>
</div>
{/* Read-only Images Section (only shown if task has images) */}
{attachedImages.length > 0 && (
<>
<button
type="button"
onClick={() => setShowImages(!showImages)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isSaving}
>
<span className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
Attached Images
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{attachedImages.length}
</span>
</span>
{showImages ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{showImages && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
<p className="text-xs text-muted-foreground">
These images were attached when the task was created. They cannot be modified.
</p>
<div className="grid grid-cols-2 gap-3">
{attachedImages.map((image) => (
<div
key={image.id}
className="relative group rounded-lg overflow-hidden border border-border bg-background"
>
{image.thumbnail ? (
<img
src={`data:${image.mimeType};base64,${image.thumbnail}`}
alt={image.filename}
className="w-full h-24 object-cover"
/>
) : (
<div className="w-full h-24 flex items-center justify-center bg-muted">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-2 py-1">
<p className="text-xs text-white truncate" title={image.filename}>
{image.filename}
</p>
</div>
</div>
))}
</div>
</div>
)}
</>
{/* Paste Success Indicator */}
{pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
<ImageIcon className="h-4 w-4" />
Image added successfully!
</div>
)}
{/* Advanced Options Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isSaving}
>
<span>Classification (optional)</span>
{showAdvanced ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
<div className="grid grid-cols-2 gap-4">
{/* Category */}
<div className="space-y-2">
<Label htmlFor="edit-category" className="text-xs font-medium text-muted-foreground">
Category
</Label>
<Select
value={category}
onValueChange={(value) => setCategory(value as TaskCategory)}
disabled={isSaving}
>
<SelectTrigger id="edit-category" className="h-9">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_CATEGORY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor="edit-priority" className="text-xs font-medium text-muted-foreground">
Priority
</Label>
<Select
value={priority}
onValueChange={(value) => setPriority(value as TaskPriority)}
disabled={isSaving}
>
<SelectTrigger id="edit-priority" className="h-9">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="edit-complexity" className="text-xs font-medium text-muted-foreground">
Complexity
</Label>
<Select
value={complexity}
onValueChange={(value) => setComplexity(value as TaskComplexity)}
disabled={isSaving}
>
<SelectTrigger id="edit-complexity" className="h-9">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_COMPLEXITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Impact */}
<div className="space-y-2">
<Label htmlFor="edit-impact" className="text-xs font-medium text-muted-foreground">
Impact
</Label>
<Select
value={impact}
onValueChange={(value) => setImpact(value as TaskImpact)}
disabled={isSaving}
>
<SelectTrigger id="edit-impact" className="h-9">
<SelectValue placeholder="Select impact" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_IMPACT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
These labels help organize and prioritize tasks. They&apos;re optional but useful for filtering.
</p>
</div>
)}
{/* Images Toggle */}
<button
type="button"
onClick={() => setShowImages(!showImages)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isSaving}
>
<span className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
Reference Images (optional)
{images.length > 0 && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{images.length}
</span>
)}
</span>
{showImages ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Image Upload Section */}
{showImages && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
<p className="text-xs text-muted-foreground">
Attach screenshots, mockups, or diagrams to provide visual context for the AI.
</p>
<ImageUpload
images={images}
onImagesChange={setImages}
disabled={isSaving}
/>
</div>
)}
{/* Review Requirement Toggle */}
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
<Checkbox
id="edit-require-review"
checked={requireReviewBeforeCoding}
onCheckedChange={(checked) => setRequireReviewBeforeCoding(checked === true)}
disabled={isSaving}
className="mt-0.5"
/>
<div className="flex-1 space-y-1">
<Label
htmlFor="edit-require-review"
className="text-sm font-medium text-foreground cursor-pointer"
>
Require human review before coding
</Label>
<p className="text-xs text-muted-foreground">
When enabled, you&apos;ll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="rounded-lg bg-[var(--error-light)] border border-[var(--error)]/30 p-3 text-sm text-[var(--error)]">
{error}
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
<X className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
@@ -8,6 +8,7 @@ import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, L
import { Button } from './ui/button';
import { cn } from '../lib/utils';
import { useTerminalStore, type TerminalStatus } from '../stores/terminal-store';
import { useSettingsStore } from '../stores/settings-store';
import type { Task, ExecutionPhase } from '../../shared/types';
import {
Select,
@@ -67,6 +68,9 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
const isCreatedRef = useRef(false);
const isMountedRef = useRef(true);
const titleInputRef = useRef<HTMLInputElement>(null);
const commandBufferRef = useRef<string>('');
const lastCommandRef = useRef<string>('');
const autoNameTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Title editing state
const [isEditingTitle, setIsEditingTitle] = useState(false);
@@ -77,6 +81,7 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
const setClaudeMode = useTerminalStore((state) => state.setClaudeMode);
const updateTerminal = useTerminalStore((state) => state.updateTerminal);
const setAssociatedTask = useTerminalStore((state) => state.setAssociatedTask);
const autoNameTerminals = useSettingsStore((state) => state.settings.autoNameTerminals);
// Filter tasks to only show backlog (Planning) status tasks for dropdown
const backlogTasks = tasks.filter((t) => t.status === 'backlog');
@@ -92,6 +97,30 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
data: { type: 'terminal', terminalId: id }
});
// Auto-naming function - generates terminal name based on commands
const triggerAutoNaming = useCallback(async () => {
// Only auto-name if setting is enabled, not in Claude mode, and we have a command
if (!autoNameTerminals || terminal?.isClaudeMode || !lastCommandRef.current.trim()) {
return;
}
const command = lastCommandRef.current.trim();
// Skip very short or common commands
if (command.length < 2 || ['ls', 'cd', 'll', 'pwd', 'exit', 'clear'].includes(command)) {
return;
}
try {
const result = await window.electronAPI.generateTerminalName(command, terminal?.cwd || cwd);
if (result.success && result.data) {
updateTerminal(id, { title: result.data });
}
} catch (error) {
// Silently fail - auto-naming is not critical
console.debug('[Terminal] Auto-naming failed:', error);
}
}, [autoNameTerminals, terminal?.isClaudeMode, terminal?.cwd, cwd, id, updateTerminal]);
// Initialize xterm.js UI (separate from PTY creation)
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return;
@@ -167,6 +196,32 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
// Handle terminal input - send to main process
xterm.onData((data) => {
window.electronAPI.sendTerminalInput(id, data);
// Track commands for auto-naming
if (data === '\r' || data === '\n') {
// Enter pressed - save the command and schedule auto-naming
lastCommandRef.current = commandBufferRef.current;
commandBufferRef.current = '';
// Clear any pending auto-naming
if (autoNameTimeoutRef.current) {
clearTimeout(autoNameTimeoutRef.current);
}
// Trigger auto-naming after a delay (wait for command to execute and produce output)
autoNameTimeoutRef.current = setTimeout(() => {
triggerAutoNaming();
}, 1500); // 1.5 second delay
} else if (data === '\x7f' || data === '\b') {
// Backspace - remove last char from buffer
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
} else if (data === '\x03') {
// Ctrl+C - clear buffer
commandBufferRef.current = '';
} else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
// Printable character - add to buffer
commandBufferRef.current += data;
}
});
// Handle resize
@@ -346,6 +401,12 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
return () => {
isMountedRef.current = false;
// Clear auto-naming timeout
if (autoNameTimeoutRef.current) {
clearTimeout(autoNameTimeoutRef.current);
autoNameTimeoutRef.current = null;
}
// Delay cleanup to skip StrictMode's immediate remount
setTimeout(() => {
if (!isMountedRef.current) {
@@ -80,13 +80,28 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
fetchSessionDates();
}, [projectPath]);
// Get addRestoredTerminal from store
const addRestoredTerminal = useTerminalStore((state) => state.addRestoredTerminal);
// Handle restoring sessions from a specific date
const handleRestoreFromDate = useCallback(async (date: string) => {
if (!projectPath || isRestoring) return;
setIsRestoring(true);
try {
// First close all existing terminals
// First get the session data for this date (we need it after restore)
const sessionsResult = await window.electronAPI.getTerminalSessionsForDate(date, projectPath);
const sessionsToRestore = sessionsResult.success ? sessionsResult.data || [] : [];
console.log(`[TerminalGrid] Found ${sessionsToRestore.length} sessions to restore from ${date}`);
if (sessionsToRestore.length === 0) {
console.log('[TerminalGrid] No sessions found for this date');
setIsRestoring(false);
return;
}
// Close all existing terminals
for (const terminal of terminals) {
await window.electronAPI.destroyTerminal(terminal.id);
removeTerminal(terminal.id);
@@ -95,7 +110,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
// Small delay to ensure cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Restore sessions from the selected date
// Restore sessions from the selected date (creates PTYs in main process)
const result = await window.electronAPI.restoreTerminalSessionsFromDate(
date,
projectPath,
@@ -104,9 +119,20 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
);
if (result.success && result.data) {
console.log(`Restored ${result.data.restored} sessions from ${date}`);
// The terminal-store's restoreTerminalSessions will be called by App.tsx
// Or we can manually add the restored terminals
console.log(`[TerminalGrid] Main process restored ${result.data.restored} sessions from ${date}`);
// Add each successfully restored session to the renderer's terminal store
for (const sessionResult of result.data.sessions) {
if (sessionResult.success) {
// Find the full session data
const fullSession = sessionsToRestore.find(s => s.id === sessionResult.id);
if (fullSession) {
console.log(`[TerminalGrid] Adding restored terminal to store: ${fullSession.id}`);
addRestoredTerminal(fullSession);
}
}
}
// Refresh session dates to update counts
const datesResult = await window.electronAPI.getTerminalSessionDates(projectPath);
if (datesResult.success && datesResult.data) {
@@ -118,7 +144,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps)
} finally {
setIsRestoring(false);
}
}, [projectPath, terminals, removeTerminal, isRestoring]);
}, [projectPath, terminals, removeTerminal, addRestoredTerminal, isRestoring]);
// Setup drag sensors
const sensors = useSensors(
@@ -1,6 +1,7 @@
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Switch } from '../ui/switch';
import { SettingsSection } from './SettingsSection';
import { AVAILABLE_MODELS } from '../../../shared/constants';
import type { AppSettings } from '../../../shared/types';
@@ -56,6 +57,23 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between max-w-md">
<div className="space-y-1">
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
AI Terminal Naming
</Label>
<p className="text-sm text-muted-foreground">
Automatically name terminals based on commands (uses Haiku)
</p>
</div>
<Switch
id="autoNameTerminals"
checked={settings.autoNameTerminals}
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
/>
</div>
</div>
</div>
</SettingsSection>
);
@@ -49,21 +49,23 @@ export function ProjectSelector({
return (
<>
<div className="space-y-2">
<div className="space-y-2 w-full overflow-hidden">
<Select
value={selectedProjectId || ''}
onValueChange={handleValueChange}
open={open}
onOpenChange={setOpen}
>
<SelectTrigger className="w-full [&_span]:truncate">
<SelectTrigger className="w-full max-w-full overflow-hidden">
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
<SelectValue placeholder="Select a project..." className="truncate min-w-0 flex-1" />
<span className="truncate min-w-0 flex-1 text-left">
{selectedProject?.name || 'Select a project...'}
</span>
</div>
</SelectTrigger>
<SelectContent
className="min-w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
<SelectContent
className="w-[var(--radix-select-trigger-width)] max-w-[280px]"
position="popper"
sideOffset={4}
>
@@ -73,18 +75,18 @@ export function ProjectSelector({
</div>
) : (
projects.map((project) => (
<SelectItem
key={project.id}
<SelectItem
key={project.id}
value={project.id}
className="pr-8"
className="pr-2"
>
<div className="flex items-center justify-between w-full min-w-0">
<span className="truncate flex-1" title={`${project.name} - ${project.path}`}>
<div className="flex items-center gap-2 w-full min-w-0 max-w-full">
<span className="truncate flex-1 min-w-0" title={`${project.name} - ${project.path}`}>
{project.name}
</span>
<button
type="button"
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-destructive/10 transition-colors"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-destructive/10 transition-colors"
onPointerDown={(e) => {
e.stopPropagation();
}}
@@ -93,7 +95,7 @@ export function ProjectSelector({
}}
onClick={(e) => handleRemoveProject(project.id, e)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</div>
</SelectItem>
@@ -111,8 +113,8 @@ export function ProjectSelector({
{/* Project path - shown when project is selected */}
{selectedProject && (
<p
className="text-xs text-muted-foreground px-1 break-words leading-relaxed"
<p
className="text-xs text-muted-foreground px-1 truncate"
title={selectedProject.path}
>
{selectedProject.path}
@@ -272,11 +272,11 @@ export async function persistTaskStatus(
}
/**
* Update task title/description and persist to file
* Update task title/description/metadata and persist to file
*/
export async function persistUpdateTask(
taskId: string,
updates: { title?: string; description?: string }
updates: { title?: string; description?: string; metadata?: Partial<TaskMetadata> }
): Promise<boolean> {
const store = useTaskStore.getState();
@@ -289,6 +289,7 @@ export async function persistUpdateTask(
store.updateTask(taskId, {
title: result.data.title,
description: result.data.description,
metadata: result.data.metadata,
updatedAt: new Date()
});
return true;
@@ -37,6 +37,9 @@
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
/* Semantic colors */
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
@@ -115,6 +118,9 @@
--sidebar: #FFFFFF;
--sidebar-foreground: #0B0B0F;
--popover: #FFFFFF;
--popover-foreground: #0B0B0F;
/* Semantic colors */
--success: #4EBE96;
--success-foreground: #FFFFFF;
@@ -173,6 +179,9 @@
--sidebar: #0E0E12;
--sidebar-foreground: #E6E6E6;
--popover: #1A1A1F;
--popover-foreground: #E6E6E6;
/* Semantic colors - muted for dark mode */
--success: #4EBE96;
--success-foreground: #0B0B0F;
+2
View File
@@ -89,6 +89,7 @@ export const DEFAULT_APP_SETTINGS = {
pythonPath: undefined as string | undefined,
autoBuildPath: undefined as string | undefined,
autoUpdateAutoBuild: true,
autoNameTerminals: true,
notifications: {
onTaskComplete: true,
onTaskFailed: true,
@@ -171,6 +172,7 @@ export const IPC_CHANNELS = {
TERMINAL_INPUT: 'terminal:input',
TERMINAL_RESIZE: 'terminal:resize',
TERMINAL_INVOKE_CLAUDE: 'terminal:invokeClaude',
TERMINAL_GENERATE_NAME: 'terminal:generateName',
// Terminal session management
TERMINAL_GET_SESSIONS: 'terminal:getSessions',
@@ -11,6 +11,7 @@ export interface AppSettings {
pythonPath?: string;
autoBuildPath?: string;
autoUpdateAutoBuild: boolean;
autoNameTerminals: boolean;
notifications: NotificationSettings;
// Global API keys (used as defaults for all projects)
globalClaudeOAuthToken?: string;