diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 35ea08f91f..f5d7a0dc19 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -26,19 +26,75 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private codingAgent: any = null; private llmService: any = null; private configWrapper: AutoDevConfigWrapper | null = null; + private completionManager: any = null; private isExecuting = false; private messages: Array<{ role: string; content: string }> = []; + private editorChangeDisposable: vscode.Disposable | undefined; constructor( private readonly context: vscode.ExtensionContext, private readonly log: (message: string) => void - ) {} + ) { + // Initialize completion manager + try { + if (KotlinCC?.llm?.JsCompletionManager) { + this.completionManager = new KotlinCC.llm.JsCompletionManager(); + this.log('CompletionManager initialized'); + } + } catch (error) { + this.log(`Failed to initialize CompletionManager: ${error}`); + } + + // Listen to active editor changes + this.editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && this.webviewView) { + this.sendActiveFileUpdate(editor.document); + } + }); + } + + /** + * Send active file update to webview + */ + private sendActiveFileUpdate(document: vscode.TextDocument): void { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) return; + + const relativePath = vscode.workspace.asRelativePath(document.uri, false); + const fileName = document.fileName.split('/').pop() || document.fileName.split('\\').pop() || ''; + const isDirectory = false; - resolveWebviewView( + // Skip binary files and non-file schemes + if (document.uri.scheme !== 'file') return; + const binaryExtensions = ['jar', 'class', 'exe', 'dll', 'so', 'dylib', 'png', 'jpg', 'jpeg', 'gif', 'ico', 'pdf', 'zip', 'tar', 'gz', 'rar', '7z']; + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + if (binaryExtensions.includes(ext)) return; + + this.postMessage({ + type: 'activeFileChanged', + data: { + path: relativePath, + name: fileName, + isDirectory + } + }); + } + + /** + * Send current active file to webview + */ + private sendCurrentActiveFile(): void { + const editor = vscode.window.activeTextEditor; + if (editor) { + this.sendActiveFileUpdate(editor.document); + } + } + + async resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken - ): void { + ): Promise { this.webviewView = webviewView; webviewView.webview.options = { @@ -72,11 +128,45 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { case 'selectConfig': await this.selectConfig(message.data?.configName as string); break; + case 'searchFiles': + await this.handleSearchFiles(message.data?.query as string); + break; + case 'getRecentFiles': + await this.handleGetRecentFiles(); + break; + case 'readFileContent': + await this.handleReadFileContent(message.data?.path as string); + break; + case 'requestConfig': + // Webview is ready and requesting config + this.sendConfigUpdate(); + // Also send current active file + this.sendCurrentActiveFile(); + break; + case 'getActiveFile': + // Get current active file + this.sendCurrentActiveFile(); + break; + case 'getCompletions': + // Get completion suggestions from mpp-core + await this.handleGetCompletions( + message.data?.text as string, + message.data?.cursorPosition as number + ); + break; + case 'applyCompletion': + // Apply a completion item + await this.handleApplyCompletion( + message.data?.text as string, + message.data?.cursorPosition as number, + message.data?.completionIndex as number + ); + break; } }); // Initialize agent from config file - this.initializeFromConfig(); + await this.initializeFromConfig(); } /** @@ -468,6 +558,313 @@ configs: case 'rerun-tool': // Rerun a tool break; + + case 'optimizePrompt': + // Optimize prompt using LLM + await this.handlePromptOptimize(data?.prompt as string); + break; + + case 'openMcpConfig': + // Open MCP configuration + await this.openMcpConfig(); + break; + } + } + + /** + * Open MCP configuration file + */ + private async openMcpConfig(): Promise { + try { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + const mcpConfigPath = `${homeDir}/.autodev/mcp.json`; + + // Check if file exists, create if not + const fs = await import('fs').then(m => m.promises); + try { + await fs.access(mcpConfigPath); + } catch { + // Create default MCP config + const defaultConfig = { + mcpServers: {} + }; + await fs.mkdir(`${homeDir}/.autodev`, { recursive: true }); + await fs.writeFile(mcpConfigPath, JSON.stringify(defaultConfig, null, 2)); + } + + // Open the file in VSCode + const uri = vscode.Uri.file(mcpConfigPath); + await vscode.window.showTextDocument(uri); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to open MCP config: ${message}`); + vscode.window.showErrorMessage(`Failed to open MCP config: ${message}`); + } + } + + /** + * Handle prompt optimization request + * Uses LLM to enhance the user's prompt + */ + private async handlePromptOptimize(prompt: string): Promise { + if (!prompt || !this.llmService) { + this.postMessage({ type: 'promptOptimizeFailed', data: { error: 'No prompt or LLM service' } }); + return; + } + + try { + const systemPrompt = `You are a prompt optimization assistant. Your task is to enhance the user's prompt to be more clear, specific, and effective for an AI coding assistant. + +Rules: +1. Keep the original intent and meaning +2. Add clarity and specificity where needed +3. Structure the prompt for better understanding +4. Keep it concise - don't make it unnecessarily long +5. Return ONLY the optimized prompt, no explanations + +User's original prompt:`; + + const response = await this.llmService.chat([ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ]); + + if (response) { + this.postMessage({ type: 'promptOptimized', data: { optimizedPrompt: response.trim() } }); + } else { + this.postMessage({ type: 'promptOptimizeFailed', data: { error: 'Empty response' } }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Prompt optimization failed: ${message}`); + this.postMessage({ type: 'promptOptimizeFailed', data: { error: message } }); + } + } + + /** + * Handle file search request from webview + * Searches for files matching the query in the workspace + */ + private async handleSearchFiles(query: string): Promise { + if (!query || query.length < 2) { + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + return; + } + + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + return; + } + + const basePath = workspaceFolders[0].uri.fsPath; + const lowerQuery = query.toLowerCase(); + + // Search for files matching the query + const files = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50 + ); + + const fileResults: Array<{ name: string; path: string; relativePath: string; isDirectory: boolean }> = []; + const folderPaths = new Set(); + + for (const file of files) { + const relativePath = vscode.workspace.asRelativePath(file, false); + const name = file.path.split('/').pop() || ''; + + // Skip binary files + const ext = name.split('.').pop()?.toLowerCase() || ''; + const binaryExts = ['jar', 'class', 'exe', 'dll', 'so', 'dylib', 'png', 'jpg', 'jpeg', 'gif', 'ico', 'pdf', 'zip', 'tar', 'gz']; + if (binaryExts.includes(ext)) continue; + + fileResults.push({ + name, + path: relativePath, // Use relative path for consistency with activeFileChanged + relativePath, + isDirectory: false + }); + + // Collect parent folders that match the query + const parts = relativePath.split('/'); + let currentPath = ''; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + if (parts[i].toLowerCase().includes(lowerQuery)) { + folderPaths.add(currentPath); + } + } + } + + const folderResults = Array.from(folderPaths).slice(0, 10).map(p => ({ + name: p.split('/').pop() || p, + path: p, // Use relative path for consistency + relativePath: p, + isDirectory: true + })); + + this.postMessage({ + type: 'searchFilesResult', + data: { + files: fileResults.slice(0, 30), + folders: folderResults + } + }); + } catch (error) { + this.log(`Error searching files: ${error}`); + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + } + } + + /** + * Handle get recent files request from webview + * Returns recently opened files in the workspace + */ + private async handleGetRecentFiles(): Promise { + try { + // Get recently opened text documents + const recentFiles: Array<{ name: string; path: string; relativePath: string; isDirectory: boolean }> = []; + + // Get visible text editors first (most recently used) + for (const editor of vscode.window.visibleTextEditors) { + const doc = editor.document; + if (doc.uri.scheme === 'file') { + const relativePath = vscode.workspace.asRelativePath(doc.uri, false); + recentFiles.push({ + name: doc.fileName.split('/').pop() || '', + path: relativePath, // Use relative path for consistency + relativePath, + isDirectory: false + }); + } + } + + // Add other open documents + for (const doc of vscode.workspace.textDocuments) { + const relativePath = vscode.workspace.asRelativePath(doc.uri, false); + if (doc.uri.scheme === 'file' && !recentFiles.some(f => f.path === relativePath)) { + recentFiles.push({ + name: doc.fileName.split('/').pop() || '', + path: relativePath, // Use relative path for consistency + relativePath, + isDirectory: false + }); + } + } + + this.postMessage({ + type: 'recentFilesResult', + data: { files: recentFiles.slice(0, 20) } + }); + } catch (error) { + this.log(`Error getting recent files: ${error}`); + this.postMessage({ type: 'recentFilesResult', data: { files: [] } }); + } + } + + /** + * Handle read file content request from webview + * filePath can be either relative or absolute path + */ + private async handleReadFileContent(filePath: string): Promise { + if (!filePath) { + this.postMessage({ type: 'fileContentResult', data: { content: null, error: 'No path provided' } }); + return; + } + + try { + // Convert relative path to absolute if needed + let uri: vscode.Uri; + if (filePath.startsWith('/') || filePath.match(/^[a-zA-Z]:\\/)) { + // Already absolute path + uri = vscode.Uri.file(filePath); + } else { + // Relative path - resolve against workspace + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + this.postMessage({ type: 'fileContentResult', data: { content: null, error: 'No workspace folder' } }); + return; + } + uri = vscode.Uri.joinPath(workspaceFolder.uri, filePath); + } + + const content = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder().decode(content); + + this.postMessage({ + type: 'fileContentResult', + data: { path: filePath, content: text } + }); + } catch (error) { + this.log(`Error reading file: ${error}`); + this.postMessage({ + type: 'fileContentResult', + data: { path: filePath, content: null, error: String(error) } + }); + } + } + + /** + * Handle get completions request from webview + * Uses mpp-core's CompletionManager + */ + private async handleGetCompletions(text: string, cursorPosition: number): Promise { + if (!this.completionManager) { + this.postMessage({ type: 'completionsResult', data: { items: [] } }); + return; + } + + try { + const items = this.completionManager.getCompletions(text, cursorPosition); + const itemsArray = Array.from(items || []).map((item: any, index: number) => ({ + text: item.text, + displayText: item.displayText, + description: item.description, + icon: item.icon, + triggerType: item.triggerType, + index + })); + + this.postMessage({ type: 'completionsResult', data: { items: itemsArray } }); + } catch (error) { + this.log(`Error getting completions: ${error}`); + this.postMessage({ type: 'completionsResult', data: { items: [] } }); + } + } + + /** + * Handle apply completion request from webview + * Uses mpp-core's CompletionManager insert handler + */ + private async handleApplyCompletion( + text: string, + cursorPosition: number, + completionIndex: number + ): Promise { + if (!this.completionManager) { + this.postMessage({ type: 'completionApplied', data: null }); + return; + } + + try { + const result = this.completionManager.applyCompletion(text, cursorPosition, completionIndex); + if (result) { + this.postMessage({ + type: 'completionApplied', + data: { + newText: result.newText, + newCursorPosition: result.newCursorPosition, + shouldTriggerNextCompletion: result.shouldTriggerNextCompletion + } + }); + } else { + this.postMessage({ type: 'completionApplied', data: null }); + } + } catch (error) { + this.log(`Error applying completion: ${error}`); + this.postMessage({ type: 'completionApplied', data: null }); } } diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx index 14f05b2e55..be8cf68a3d 100644 --- a/mpp-vscode/webview/src/App.tsx +++ b/mpp-vscode/webview/src/App.tsx @@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Timeline } from './components/Timeline'; import { ChatInput } from './components/ChatInput'; import { ModelConfig } from './components/ModelSelector'; +import { SelectedFile } from './components/FileChip'; +import { CompletionItem } from './components/CompletionPopup'; import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; import type { AgentState, ToolCallInfo, TerminalOutput, ToolCallTimelineItem } from './types/timeline'; import './App.css'; @@ -18,6 +20,12 @@ interface ConfigState { currentConfigName: string | null; } +interface CompletionResult { + newText: string; + newCursorPosition: number; + shouldTriggerNextCompletion: boolean; +} + const App: React.FC = () => { // Agent state - mirrors ComposeRenderer's state const [agentState, setAgentState] = useState({ @@ -35,6 +43,16 @@ const App: React.FC = () => { currentConfigName: null }); + // Token usage state + const [totalTokens, setTotalTokens] = useState(null); + + // Active file state (for auto-add current file feature) + const [activeFile, setActiveFile] = useState(null); + + // Completion state - from mpp-core + const [completionItems, setCompletionItems] = useState([]); + const [completionResult, setCompletionResult] = useState(null); + const { postMessage, onMessage, isVSCode } = useVSCode(); // Handle messages from extension @@ -198,6 +216,43 @@ const App: React.FC = () => { }); } break; + + // Token usage update + case 'tokenUpdate': + if (msg.data?.totalTokens != null) { + setTotalTokens(msg.data.totalTokens as number); + } + break; + + // Active file changed (for auto-add current file) + case 'activeFileChanged': + if (msg.data) { + setActiveFile({ + path: msg.data.path as string, + name: msg.data.name as string, + relativePath: msg.data.path as string, + isDirectory: msg.data.isDirectory as boolean || false + }); + } + break; + + // Completion results from mpp-core + case 'completionsResult': + if (msg.data?.items) { + setCompletionItems(msg.data.items as CompletionItem[]); + } + break; + + // Completion applied result + case 'completionApplied': + if (msg.data) { + setCompletionResult({ + newText: msg.data.newText as string, + newCursorPosition: msg.data.newCursorPosition as number, + shouldTriggerNextCompletion: msg.data.shouldTriggerNextCompletion as boolean + }); + } + break; } }, []); @@ -206,8 +261,22 @@ const App: React.FC = () => { return onMessage(handleExtensionMessage); }, [onMessage, handleExtensionMessage]); + // Request config on mount + useEffect(() => { + postMessage({ type: 'requestConfig' }); + }, [postMessage]); + // Send message to extension - const handleSend = useCallback((content: string) => { + const handleSend = useCallback((content: string, files?: SelectedFile[]) => { + // Build message with file context (DevIns format) + let fullContent = content; + if (files && files.length > 0) { + const fileCommands = files.map(f => + f.isDirectory ? `/dir:${f.relativePath}` : `/file:${f.relativePath}` + ).join('\n'); + fullContent = `${fileCommands}\n\n${content}`; + } + // Immediately show user message in timeline for feedback setAgentState(prev => ({ ...prev, @@ -215,12 +284,12 @@ const App: React.FC = () => { timeline: [...prev.timeline, { type: 'message', timestamp: Date.now(), - message: { role: 'user', content } + message: { role: 'user', content: fullContent } }] })); // Send to extension - postMessage({ type: 'sendMessage', content }); + postMessage({ type: 'sendMessage', content: fullContent }); }, [postMessage]); // Clear history @@ -248,6 +317,48 @@ const App: React.FC = () => { postMessage({ type: 'selectConfig', data: { configName: config.name } }); }, [postMessage]); + // Handle prompt optimization + const handlePromptOptimize = useCallback(async (prompt: string): Promise => { + return new Promise((resolve) => { + // Send optimization request to extension + postMessage({ type: 'action', action: 'optimizePrompt', data: { prompt } }); + + // Listen for response + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'promptOptimized' && msg.data?.optimizedPrompt) { + window.removeEventListener('message', handler); + resolve(msg.data.optimizedPrompt as string); + } else if (msg.type === 'promptOptimizeFailed') { + window.removeEventListener('message', handler); + resolve(prompt); // Return original on failure + } + }; + window.addEventListener('message', handler); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + resolve(prompt); + }, 30000); + }); + }, [postMessage]); + + // Handle MCP config click + const handleMcpConfigClick = useCallback(() => { + postMessage({ type: 'action', action: 'openMcpConfig' }); + }, [postMessage]); + + // Handle get completions from mpp-core + const handleGetCompletions = useCallback((text: string, cursorPosition: number) => { + postMessage({ type: 'getCompletions', data: { text, cursorPosition } }); + }, [postMessage]); + + // Handle apply completion from mpp-core + const handleApplyCompletion = useCallback((text: string, cursorPosition: number, completionIndex: number) => { + postMessage({ type: 'applyCompletion', data: { text, cursorPosition, completionIndex } }); + }, [postMessage]); + // Check if we need to show config prompt const needsConfig = agentState.timeline.length === 0 && agentState.currentStreamingContent.includes('No configuration found') || @@ -310,11 +421,19 @@ const App: React.FC = () => { onStop={handleStop} onConfigSelect={handleConfigSelect} onConfigureClick={handleOpenConfig} + onMcpConfigClick={handleMcpConfigClick} + onPromptOptimize={handlePromptOptimize} + onGetCompletions={handleGetCompletions} + onApplyCompletion={handleApplyCompletion} + completionItems={completionItems} + completionResult={completionResult} disabled={agentState.isProcessing} isExecuting={agentState.isProcessing} placeholder="Ask AutoDev anything... (use / for commands, @ for agents)" availableConfigs={configState.availableConfigs} currentConfigName={configState.currentConfigName} + totalTokens={totalTokens} + activeFile={activeFile} /> ); diff --git a/mpp-vscode/webview/src/components/ChatInput.css b/mpp-vscode/webview/src/components/ChatInput.css index c9a76ef9d8..50a4ff0dcf 100644 --- a/mpp-vscode/webview/src/components/ChatInput.css +++ b/mpp-vscode/webview/src/components/ChatInput.css @@ -1,16 +1,19 @@ .chat-input-container { - padding: 8px 12px 12px; - border-top: 1px solid var(--panel-border); + position: relative; + border: 1px solid var(--panel-border); + border-radius: 8px; background: var(--background); + margin: 8px; } -/* Toolbar */ +/* Bottom Toolbar */ .input-toolbar { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 8px; - padding: 0 4px; + padding: 6px 8px; + border-top: 1px solid var(--panel-border); + background: var(--selection-background); } .toolbar-left { @@ -56,6 +59,13 @@ display: flex; gap: 8px; align-items: flex-end; + padding: 8px; +} + +.input-with-completion { + position: relative; + flex: 1; + min-width: 0; } .chat-textarea { @@ -149,17 +159,47 @@ } .input-hint { - margin-top: 6px; - font-size: 11px; + font-size: 10px; color: var(--foreground); - opacity: 0.5; + opacity: 0.4; + white-space: nowrap; } .input-hint kbd { - background: var(--selection-background); - padding: 2px 5px; - border-radius: 3px; + background: var(--background); + padding: 1px 4px; + border-radius: 2px; font-family: inherit; + font-size: 9px; +} + +/* Token indicator */ +.token-indicator { + font-size: 11px; + color: var(--foreground); + opacity: 0.6; + padding: 2px 6px; + background: var(--background); + border-radius: 4px; +} + +/* Enhance button */ +.enhance-button { + display: flex; + align-items: center; + gap: 4px; +} + +.enhance-button.enhancing { + color: var(--vscode-textLink-foreground, #3794ff); + animation: pulse 1.5s ease-in-out infinite; +} + +.enhance-button .enhancing-text { font-size: 10px; } +@keyframes pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} diff --git a/mpp-vscode/webview/src/components/ChatInput.tsx b/mpp-vscode/webview/src/components/ChatInput.tsx index 0a70c6907e..7646ee3d44 100644 --- a/mpp-vscode/webview/src/components/ChatInput.tsx +++ b/mpp-vscode/webview/src/components/ChatInput.tsx @@ -1,18 +1,30 @@ -import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import React, { useState, useRef, useEffect, KeyboardEvent, useCallback } from 'react'; import { ModelSelector, ModelConfig } from './ModelSelector'; +import { TopToolbar } from './TopToolbar'; +import { SelectedFile } from './FileChip'; +import { DevInInput } from './DevInInput'; +import { CompletionPopup, CompletionItem } from './CompletionPopup'; import './ChatInput.css'; interface ChatInputProps { - onSend: (message: string) => void; + onSend: (message: string, files?: SelectedFile[]) => void; onClear?: () => void; onStop?: () => void; onConfigSelect?: (config: ModelConfig) => void; onConfigureClick?: () => void; + onMcpConfigClick?: () => void; + onPromptOptimize?: (prompt: string) => Promise; + onGetCompletions?: (text: string, cursorPosition: number) => void; + onApplyCompletion?: (text: string, cursorPosition: number, completionIndex: number) => void; + completionItems?: CompletionItem[]; + completionResult?: { newText: string; newCursorPosition: number; shouldTriggerNextCompletion: boolean } | null; disabled?: boolean; isExecuting?: boolean; placeholder?: string; availableConfigs?: ModelConfig[]; currentConfigName?: string | null; + totalTokens?: number | null; + activeFile?: SelectedFile | null; } export const ChatInput: React.FC = ({ @@ -21,38 +33,131 @@ export const ChatInput: React.FC = ({ onStop, onConfigSelect, onConfigureClick, + onMcpConfigClick, + onPromptOptimize, + onGetCompletions, + onApplyCompletion, + completionItems: externalCompletionItems, + completionResult, disabled = false, isExecuting = false, placeholder = 'Ask AutoDev...', availableConfigs = [], - currentConfigName = null + currentConfigName = null, + totalTokens = null, + activeFile = null }) => { const [input, setInput] = useState(''); - const textareaRef = useRef(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [isEnhancing, setIsEnhancing] = useState(false); + const [completionOpen, setCompletionOpen] = useState(false); + const [selectedCompletionIndex, setSelectedCompletionIndex] = useState(0); + const [autoAddCurrentFile, setAutoAddCurrentFile] = useState(true); + const inputRef = useRef(null); + const cursorPositionRef = useRef(0); - // Auto-resize textarea + // Use external completion items if provided + const completionItems = externalCompletionItems || []; + + // Auto-add active file when it changes useEffect(() => { - const textarea = textareaRef.current; - if (textarea) { - textarea.style.height = 'auto'; - textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`; + if (autoAddCurrentFile && activeFile) { + setSelectedFiles(prev => { + if (prev.some(f => f.path === activeFile.path)) return prev; + return [...prev, activeFile]; + }); } - }, [input]); + }, [activeFile, autoAddCurrentFile]); - // Focus on mount + // Handle completion result from mpp-core useEffect(() => { - textareaRef.current?.focus(); + if (completionResult) { + setInput(completionResult.newText); + cursorPositionRef.current = completionResult.newCursorPosition; + if (completionResult.shouldTriggerNextCompletion && onGetCompletions) { + // Trigger next completion + onGetCompletions(completionResult.newText, completionResult.newCursorPosition); + } else { + setCompletionOpen(false); + } + } + }, [completionResult, onGetCompletions]); + + // Update completion items when external items change + useEffect(() => { + if (externalCompletionItems && externalCompletionItems.length > 0) { + setCompletionOpen(true); + setSelectedCompletionIndex(0); + } else if (externalCompletionItems && externalCompletionItems.length === 0) { + setCompletionOpen(false); + } + }, [externalCompletionItems]); + + const handleAddFile = useCallback((file: SelectedFile) => { + setSelectedFiles(prev => { + if (prev.some(f => f.path === file.path)) return prev; + return [...prev, file]; + }); + }, []); + + const handleRemoveFile = useCallback((file: SelectedFile) => { + setSelectedFiles(prev => prev.filter(f => f.path !== file.path)); + }, []); + + const handleClearFiles = useCallback(() => { + setSelectedFiles([]); }, []); + // Handle completion trigger - request completions from mpp-core + const handleTriggerCompletion = useCallback((trigger: '/' | '@' | '$', position: number) => { + cursorPositionRef.current = position; + if (onGetCompletions) { + // Use mpp-core for completions + onGetCompletions(input.substring(0, position) + trigger, position + 1); + } + }, [input, onGetCompletions]); + + // Handle completion selection + const handleSelectCompletion = useCallback((item: CompletionItem, _index: number) => { + if (onApplyCompletion && item.index !== undefined) { + // Use mpp-core to apply completion + onApplyCompletion(input, cursorPositionRef.current, item.index); + } else { + // Fallback: simple text replacement + setInput(prev => prev + (item.insertText || item.text)); + } + setCompletionOpen(false); + }, [input, onApplyCompletion]); + const handleSubmit = () => { const trimmed = input.trim(); if (trimmed && !disabled) { - onSend(trimmed); + onSend(trimmed, selectedFiles.length > 0 ? selectedFiles : undefined); setInput(''); + // Keep files in context for follow-up questions } }; + const handlePromptOptimize = useCallback(async () => { + if (!onPromptOptimize || !input.trim() || isEnhancing || isExecuting) return; + + setIsEnhancing(true); + try { + const enhanced = await onPromptOptimize(input); + if (enhanced) { + setInput(enhanced); + } + } catch (error) { + console.error('Failed to optimize prompt:', error); + } finally { + setIsEnhancing(false); + } + }, [input, onPromptOptimize, isEnhancing, isExecuting]); + const handleKeyDown = (e: KeyboardEvent) => { + // Don't submit if completion popup is open + if (completionOpen) return; + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -60,46 +165,39 @@ export const ChatInput: React.FC = ({ }; return ( -
- {/* Toolbar - Model Selector and Config */} -
-
- {})} - onConfigureClick={onConfigureClick || (() => {})} - /> -
-
- {onClear && ( - - )} -
-
+
+ {/* File Context Toolbar */} + setAutoAddCurrentFile(prev => !prev)} + /> - {/* Input Area */} + {/* Input Area with DevIn highlighting */}
-