diff --git a/src/main/cli.ts b/src/main/cli.ts index fa9ab7456d..de305eba92 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -464,7 +464,9 @@ async function sendPrompt(state: CLIState, content: string): Promise { print(`\n${c.blue}🤖 Agent:${c.reset}`) try { - const stopReason = await state.conductor.sendPrompt(state.currentSession.id, content) + // Convert string to MessageContent format + const messageContent = [{ type: 'text' as const, text: content }] + const stopReason = await state.conductor.sendPrompt(state.currentSession.id, messageContent) print(`\n\n${c.dim}[${stopReason}]${c.reset}\n`) } catch (err) { printError(`${err}`) diff --git a/src/main/conductor/Conductor.ts b/src/main/conductor/Conductor.ts index c8f18b4b56..ab740edcb1 100644 --- a/src/main/conductor/Conductor.ts +++ b/src/main/conductor/Conductor.ts @@ -24,6 +24,7 @@ import type { SessionData, ListSessionsOptions, } from '../../shared/types' +import type { MessageContent, MessageContentItem } from '../../shared/types/message' import { formatHistoryForReplay, hasReplayableHistory } from './historyReplay' export interface SessionUpdateCallback { @@ -290,15 +291,27 @@ export class Conductor { } /** - * Send a prompt to the agent + * Send a prompt to the agent (supports text and images) */ - async sendPrompt(sessionId: string, content: string): Promise { + async sendPrompt(sessionId: string, content: MessageContent): Promise { // Ensure agent is running (lazy start if needed) const sessionAgent = await this.ensureAgentForSession(sessionId) const { connection, agentSessionId } = sessionAgent - // Prepare the final prompt content - let finalContent = content + // Convert MessageContent to ACP SDK format + const convertToAcpFormat = (items: MessageContent): Array<{ type: string; text?: string; data?: string; mimeType?: string }> => { + return items.map((item: MessageContentItem) => { + if (item.type === 'text') { + return { type: 'text', text: item.text } + } else if (item.type === 'image') { + return { type: 'image', data: item.data, mimeType: item.mimeType } + } + return { type: 'text', text: '' } // fallback + }) + } + + // Build prompt content array + let promptContent = convertToAcpFormat(content) // If this is a resumed session, prepend conversation history to first prompt if (sessionAgent.needsHistoryReplay && this.sessionStore) { @@ -308,7 +321,8 @@ export class Conductor { const history = formatHistoryForReplay(data.updates) if (history) { console.log(`[Conductor] Prepending conversation history (${data.updates.length} updates)`) - finalContent = history + content + // Prepend history as text block before other content + promptContent = [{ type: 'text', text: history }, ...promptContent] } } } catch (error) { @@ -320,17 +334,24 @@ export class Conductor { } } + // Log prompt info + const textContent = content.find((c: MessageContentItem) => c.type === 'text') + const imageCount = content.filter((c: MessageContentItem) => c.type === 'image').length console.log(`[Conductor] Sending prompt to session ${agentSessionId}`) - console.log(`[Conductor] Content: ${finalContent.slice(0, 100)}${finalContent.length > 100 ? '...' : ''}`) + if (textContent && textContent.type === 'text') { + console.log(`[Conductor] Text: ${textContent.text.slice(0, 100)}${textContent.text.length > 100 ? '...' : ''}`) + } + if (imageCount > 0) { + console.log(`[Conductor] Images: ${imageCount}`) + } // Store user message before sending (so it appears in history) - // Note: We store the original content, not the history-prepended version if (this.sessionStore) { const userUpdate = { sessionId: agentSessionId, update: { sessionUpdate: 'user_message', - content: { type: 'text', text: content }, + content: content, // Store full MessageContent array }, } await this.sessionStore.appendUpdate(sessionId, userUpdate as any) @@ -343,7 +364,7 @@ export class Conductor { try { const result = await connection.prompt({ sessionId: agentSessionId, - prompt: [{ type: 'text', text: finalContent }], + prompt: promptContent as any, }) console.log(`[Conductor] Prompt completed with stopReason: ${result.stopReason}`) diff --git a/src/main/config/defaults.ts b/src/main/config/defaults.ts index 7750dd5d3d..feeb308109 100644 --- a/src/main/config/defaults.ts +++ b/src/main/config/defaults.ts @@ -9,7 +9,7 @@ export const DEFAULT_AGENTS: Record = { name: 'Claude Code', command: 'claude-code-acp', args: [], - enabled: true, + enabled: true // Note: Uses https://github.com/zed-industries/claude-code-acp // Requires ANTHROPIC_API_KEY environment variable }, @@ -18,24 +18,17 @@ export const DEFAULT_AGENTS: Record = { name: 'opencode', command: 'opencode', args: ['acp'], - enabled: true, + enabled: true }, codex: { id: 'codex', name: 'Codex CLI (ACP)', command: 'codex-acp', args: [], - enabled: true, + enabled: true // Note: Uses https://github.com/zed-industries/codex-acp // Official Codex CLI doesn't support ACP - }, - gemini: { - id: 'gemini', - name: 'Gemini CLI', - command: 'gemini', - args: ['acp'], - enabled: true, - }, + } } export const DEFAULT_CONFIG: AppConfig = { @@ -44,6 +37,6 @@ export const DEFAULT_CONFIG: AppConfig = { agents: DEFAULT_AGENTS, ui: { theme: 'system', - fontSize: 14, - }, + fontSize: 14 + } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c4cdcef3b5..5fd18886e4 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -9,6 +9,7 @@ import { checkAgents } from '../utils/agent-check' import type { Conductor } from '../conductor/Conductor' import type { ListSessionsOptions, MulticaSession } from '../../shared/types' import type { FileTreeNode, DetectedApp } from '../../shared/electron-api' +import type { MessageContent } from '../../shared/types/message' import * as fs from 'fs' import * as path from 'path' import { spawn } from 'child_process' @@ -48,7 +49,7 @@ function isValidPath(inputPath: string): boolean { export function registerIPCHandlers(conductor: Conductor): void { // --- Agent handlers (per-session) --- - ipcMain.handle(IPC_CHANNELS.AGENT_PROMPT, async (_event, sessionId: string, content: string) => { + ipcMain.handle(IPC_CHANNELS.AGENT_PROMPT, async (_event, sessionId: string, content: MessageContent) => { const stopReason = await conductor.sendPrompt(sessionId, content) return { stopReason } }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 50a1e30876..f4703665e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { IPC_CHANNELS } from '../shared/ipc-channels' import type { ElectronAPI, OpenWithOptions } from '../shared/electron-api' import type { ListSessionsOptions, MulticaSession } from '../shared/types' +import type { MessageContent } from '../shared/types/message' // Electron API exposed to renderer process const electronAPI: ElectronAPI = { @@ -9,7 +10,7 @@ const electronAPI: ElectronAPI = { getAgentStatus: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_STATUS), // Agent communication - sendPrompt: (sessionId: string, content: string) => + sendPrompt: (sessionId: string, content: MessageContent) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_PROMPT, sessionId, content), cancelRequest: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_CANCEL, sessionId), diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index 8d821c107c..6bea256e29 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -81,6 +81,12 @@ interface TextBlock { content: string } +interface ImageBlock { + type: 'image' + data: string + mimeType: string +} + interface ThoughtBlock { type: 'thought' content: string @@ -91,7 +97,7 @@ interface ToolCallBlock { toolCall: ToolCall } -type ContentBlock = TextBlock | ThoughtBlock | ToolCallBlock +type ContentBlock = TextBlock | ImageBlock | ThoughtBlock | ToolCallBlock interface Message { role: 'user' | 'assistant' @@ -148,13 +154,38 @@ function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] { case 'user_message' as string: // Flush any pending assistant message flushAssistantMessage() - // Add user message + // Add user message - supports multiple formats for backward compatibility { - const userUpdate = update as { content?: { type: string; text: string } } - if (userUpdate.content?.type === 'text') { + const userUpdate = update as { content?: unknown } + const userBlocks: ContentBlock[] = [] + const content = userUpdate.content + + if (Array.isArray(content)) { + // New format: MessageContent array (e.g., [{ type: 'text', text: '...' }, { type: 'image', ... }]) + for (const item of content) { + if (item && typeof item === 'object') { + if (item.type === 'text' && typeof item.text === 'string') { + userBlocks.push({ type: 'text', content: item.text }) + } else if (item.type === 'image' && typeof item.data === 'string') { + userBlocks.push({ type: 'image', data: item.data, mimeType: item.mimeType || 'image/png' }) + } + } + } + } else if (content && typeof content === 'object' && 'type' in content && 'text' in content) { + // Old format: single text content object { type: 'text', text: '...' } + const textContent = content as { type: string; text: unknown } + if (textContent.type === 'text' && typeof textContent.text === 'string') { + userBlocks.push({ type: 'text', content: textContent.text }) + } + } else if (typeof content === 'string') { + // Fallback: plain string content + userBlocks.push({ type: 'text', content: content }) + } + + if (userBlocks.length > 0) { messages.push({ role: 'user', - blocks: [{ type: 'text', content: userUpdate.content.text }], + blocks: userBlocks, }) } } @@ -269,14 +300,31 @@ interface MessageBubbleProps { function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === 'user' - // User message - bubble style + // User message - bubble style with support for images if (isUser) { - // Get the text content from blocks + const imageBlocks = message.blocks.filter((b): b is ImageBlock => b.type === 'image') const textBlock = message.blocks.find((b): b is TextBlock => b.type === 'text') + return (
-
- {textBlock?.content || ''} +
+ {/* Render images first */} + {imageBlocks.length > 0 && ( +
+ {imageBlocks.map((img, idx) => ( + {`Uploaded + ))} +
+ )} + {/* Render text content */} + {textBlock && ( +
{textBlock.content}
+ )}
) @@ -293,6 +341,15 @@ function MessageBubble({ message }: MessageBubbleProps) { return case 'text': return + case 'image': + return ( + {`Image + ) default: return null } diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx index 0183c79e99..1f07da2d12 100644 --- a/src/renderer/src/components/MessageInput.tsx +++ b/src/renderer/src/components/MessageInput.tsx @@ -1,13 +1,14 @@ /** - * Message input component + * Message input component with image upload support */ -import { useState, useRef, useEffect } from 'react' -import { ArrowUp, Square, Folder } from 'lucide-react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { ArrowUp, Square, Folder, Paperclip, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import type { MessageContent, ImageContentItem } from '../../../shared/types/message' interface MessageInputProps { - onSend: (content: string) => void + onSend: (content: MessageContent) => void onCancel: () => void isProcessing: boolean disabled: boolean @@ -16,6 +17,9 @@ interface MessageInputProps { onSelectFolder: () => Promise } +const MAX_IMAGE_SIZE = 10 * 1024 * 1024 // 10MB +const SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] + export function MessageInput({ onSend, onCancel, @@ -27,7 +31,9 @@ export function MessageInput({ }: MessageInputProps) { const [value, setValue] = useState('') const [isComposing, setIsComposing] = useState(false) + const [images, setImages] = useState([]) const textareaRef = useRef(null) + const fileInputRef = useRef(null) // Get folder name from path const folderName = workingDirectory @@ -48,13 +54,107 @@ export function MessageInput({ textareaRef.current?.focus() }, []) - const handleSubmit = () => { - const trimmed = value.trim() - if (!trimmed || disabled || isProcessing) return + // Convert file to base64 + const fileToBase64 = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + // Remove data URL prefix (e.g., "data:image/png;base64,") + const base64 = result.split(',')[1] + resolve(base64) + } + reader.onerror = reject + reader.readAsDataURL(file) + }) + }, []) - onSend(trimmed) + // Process and add image + const addImage = useCallback(async (file: File) => { + // Validate file type + if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) { + console.warn('Unsupported image type:', file.type) + return + } + + // Validate file size + if (file.size > MAX_IMAGE_SIZE) { + console.warn('Image too large:', file.size) + return + } + + try { + const base64 = await fileToBase64(file) + const imageItem: ImageContentItem = { + type: 'image', + data: base64, + mimeType: file.type, + } + setImages((prev) => [...prev, imageItem]) + } catch (err) { + console.error('Failed to process image:', err) + } + }, [fileToBase64]) + + // Handle paste event + const handlePaste = useCallback(async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) { + await addImage(file) + } + break + } + } + }, [addImage]) + + // Handle file selection + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + + for (const file of files) { + await addImage(file) + } + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, [addImage]) + + // Remove image + const removeImage = useCallback((index: number) => { + setImages((prev) => prev.filter((_, i) => i !== index)) + }, []) + + // Handle submit + const handleSubmit = useCallback(() => { + const trimmed = value.trim() + if ((!trimmed && images.length === 0) || disabled || isProcessing) return + + // Build message content array + const content: MessageContent = [] + + // Add images first + for (const img of images) { + content.push(img) + } + + // Add text if present + if (trimmed) { + content.push({ type: 'text', text: trimmed }) + } + + onSend(content) setValue('') - } + setImages([]) + }, [value, images, disabled, isProcessing, onSend]) const handleKeyDown = (e: React.KeyboardEvent) => { // Don't submit while IME is composing @@ -64,7 +164,7 @@ export function MessageInput({ } } - const canSubmit = !disabled && value.trim().length > 0 + const canSubmit = !disabled && (value.trim().length > 0 || images.length > 0) const hasFolder = !!workingDirectory // Render folder selection mode when no folder is selected @@ -104,12 +204,37 @@ export function MessageInput({
+ {/* Image previews */} + {images.length > 0 && ( +
+ {images.map((img, index) => ( +
+ {`Upload + +
+ ))} +
+ )} + {/* Text input */}