import { useState, useRef, useEffect, useLayoutEffect, useMemo, memo, useCallback } from 'react'; import { useStore } from '../../store'; import { useShallow } from 'zustand/react/shallow'; import { CommandAutocomplete, getFilteredCommandCount, getCommandAtIndex } from './CommandAutocomplete'; import { FileAutocomplete, buildFileSearchIndex, getFileAutocompleteContext, insertFileTag, searchFileSuggestions, } from './FileAutocomplete'; import { getModelsForHarness, MODEL_DISPLAY_NAMES, type ModelOption } from '../../config/models'; import { ContextIndicator } from './ContextIndicator'; import { WorktreeSelector } from './WorktreeSelector'; import type { Command } from '../../commands'; import type { ApprovalDecision, DiffData, ImageChunk, ModelConfig, PermissionMode, WorktreeType, AcpToolKind } from '@/lib/agents/types'; const PERMISSION_OPTIONS: Array<{ value: PermissionMode; label: string }> = [ { value: 'default', label: 'Default permissions' }, { value: 'full', label: 'Full access' }, ]; const PROMPT_TEXTAREA_MAX_HEIGHT = 200; interface PromptApprovalRequest { requestId: string; title: string; toolKind: AcpToolKind; input: Record; diffData?: DiffData; } interface ApprovalOption { label: string; decision: ApprovalDecision; } interface ApprovalPromptContent { question: string; preview: string; options: ApprovalOption[]; } function truncateText(text: string, max: number): string { if (text.length <= max) return text; return `${text.slice(0, max - 1)}…`; } function getFilePathFromApproval(request: PromptApprovalRequest): string | null { if (request.diffData?.filePath) return request.diffData.filePath; if (typeof request.input.filePath === 'string') return request.input.filePath; if (typeof request.input.path === 'string') return request.input.path; return null; } function buildApprovalPromptContent(request: PromptApprovalRequest): ApprovalPromptContent { const { toolKind, title, input, diffData } = request; if (toolKind === 'execute') { const command = typeof input.command === 'string' ? input.command.trim() : ''; const commandPreview = command || '(empty command)'; return { question: 'Do you want to allow running this command?', preview: commandPreview, options: [ { label: 'Yes', decision: 'approve' }, { label: `Yes, and don't ask again for commands that start with ${truncateText(commandPreview, 64)}`, decision: 'auto-approve', }, { label: 'No, and tell Open SWE what to do differently', decision: 'reject' }, ], }; } const targetPath = getFilePathFromApproval(request); const isFileUpdate = toolKind === 'edit' || toolKind === 'delete' || toolKind === 'move' || diffData != null; let question = 'Do you want to allow this action?'; if (isFileUpdate) { if (toolKind === 'delete') { question = targetPath ? `Do you want to allow deleting ${targetPath}?` : 'Do you want to allow deleting this file?'; } else if (toolKind === 'move') { question = targetPath ? `Do you want to allow moving ${targetPath}?` : 'Do you want to allow moving this file?'; } else { question = targetPath ? `Do you want to allow updating ${targetPath}?` : 'Do you want to allow updating this file?'; } } const preview = targetPath ? `${title} ${targetPath}` : `${title} ${truncateText(JSON.stringify(input), 180)}`; return { question, preview, options: [ { label: 'Yes', decision: 'approve' }, { label: isFileUpdate ? 'Yes, and allow all edits for this session' : 'Yes, and allow similar actions for this session', decision: 'auto-approve', }, { label: 'No, and tell Open SWE what to do differently', decision: 'reject' }, ], }; } interface PromptBarProps { onSubmit: (query: string) => void; busy: boolean; projectPath?: string; /** The main repo root path (not the worktree path). Used for git operations. */ mainProjectPath?: string; gitBranch?: string; githubPR?: { number: number; url: string } | null; sessionId: string; tabId: string; isFocused: boolean; isDragOver?: boolean; pendingImages?: ImageChunk[]; onRemoveImage?: (index: number) => void; dropUp?: boolean; worktreeType?: WorktreeType; worktreePath?: string; connectedTop?: boolean; pendingApproval?: PromptApprovalRequest | null; onApproveApproval?: (approvalRequestId: string) => void; onRejectApproval?: (approvalRequestId: string) => void; onAutoApproveApproval?: (approvalRequestId: string) => void; } export const PromptBar = memo(function PromptBar({ onSubmit, busy, projectPath, mainProjectPath, gitBranch, githubPR, sessionId, tabId, isFocused, isDragOver = false, pendingImages, onRemoveImage, dropUp = true, worktreeType, worktreePath, connectedTop = false, pendingApproval = null, onApproveApproval, onRejectApproval, onAutoApproveApproval, }: PromptBarProps) { const { query, permissionMode, setPermissionMode, defaultModelConfig, sessionModelConfig, setSessionModelConfig, setSessionPromptDraft, harness, } = useStore(useShallow(state => ({ query: state.sessions[sessionId]?.promptDraft ?? '', permissionMode: state.permissionMode, setPermissionMode: state.setPermissionMode, defaultModelConfig: state.modelConfig, sessionModelConfig: state.sessions[sessionId]?.modelConfig, setSessionModelConfig: state.setSessionModelConfig, setSessionPromptDraft: state.setSessionPromptDraft, harness: state.harness, }))); const inputRef = useRef(null); const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); const [fileSelectedIndex, setFileSelectedIndex] = useState(0); const [permissionDropdownOpen, setPermissionDropdownOpen] = useState(false); const permissionDropdownRef = useRef(null); const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const modelDropdownRef = useRef(null); const [modelDropdownIndex, setModelDropdownIndex] = useState(0); const [projectFiles, setProjectFiles] = useState([]); const [filesLoading, setFilesLoading] = useState(false); const [cursorPosition, setCursorPosition] = useState(0); const [approvalSelectedIndex, setApprovalSelectedIndex] = useState(0); const modelConfig = sessionModelConfig ?? defaultModelConfig; const setModelConfig = useCallback((config: Partial) => { setSessionModelConfig(sessionId, config); }, [sessionId, setSessionModelConfig]); const setQuery = useCallback((nextQuery: string) => { setSessionPromptDraft(sessionId, nextQuery); }, [sessionId, setSessionPromptDraft]); const hasPendingApproval = Boolean(pendingApproval?.requestId); const approvalContent = useMemo( () => (pendingApproval ? buildApprovalPromptContent(pendingApproval) : null), [pendingApproval], ); const isTypingCommand = query.startsWith('/'); const commandQuery = isTypingCommand ? query.slice(1).split(/\s/)[0] : ''; const showCommandAutocomplete = !hasPendingApproval && isTypingCommand && !query.includes(' '); const fileIndex = useMemo(() => buildFileSearchIndex(projectFiles), [projectFiles]); const fileContext = useMemo( () => getFileAutocompleteContext(query, cursorPosition), [query, cursorPosition], ); const fileSuggestions = useMemo( () => (fileContext ? searchFileSuggestions(fileIndex, fileContext.fileQuery) : []), [fileContext, fileIndex], ); const showFileAutocomplete = !hasPendingApproval && fileContext !== null && !showCommandAutocomplete; const submitApprovalDecision = useCallback((decision: ApprovalDecision) => { if (!pendingApproval?.requestId) return; if (decision === 'approve') { onApproveApproval?.(pendingApproval.requestId); return; } if (decision === 'auto-approve') { onAutoApproveApproval?.(pendingApproval.requestId); return; } onRejectApproval?.(pendingApproval.requestId); }, [pendingApproval?.requestId, onApproveApproval, onAutoApproveApproval, onRejectApproval]); useEffect(() => { if (hasPendingApproval) { setPermissionDropdownOpen(false); setModelDropdownOpen(false); } }, [hasPendingApproval]); useEffect(() => { setApprovalSelectedIndex(0); }, [pendingApproval?.requestId]); useEffect(() => { if (!hasPendingApproval || !approvalContent || !isFocused) return; const options = approvalContent.options; function handleApprovalKeyDown(e: KeyboardEvent) { e.stopPropagation(); if (e.key === 'ArrowDown') { e.preventDefault(); setApprovalSelectedIndex((prev) => (prev + 1) % options.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setApprovalSelectedIndex((prev) => (prev - 1 + options.length) % options.length); return; } if (e.key === 'Enter') { e.preventDefault(); const selectedOption = options[approvalSelectedIndex]; if (!selectedOption) return; submitApprovalDecision(selectedOption.decision); return; } if (e.key === 'Escape') { e.preventDefault(); submitApprovalDecision('reject'); } } document.addEventListener('keydown', handleApprovalKeyDown); return () => document.removeEventListener('keydown', handleApprovalKeyDown); }, [approvalContent, approvalSelectedIndex, hasPendingApproval, isFocused, submitApprovalDecision]); useLayoutEffect(() => { if (isFocused && !hasPendingApproval) { inputRef.current?.focus(); } }, [busy, isFocused, hasPendingApproval]); useLayoutEffect(() => { const el = inputRef.current; if (!el) return; el.style.height = 'auto'; const clampedHeight = Math.min(el.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT); el.style.height = `${clampedHeight}px`; el.style.overflowY = el.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; }, [query]); useEffect(() => { setCommandSelectedIndex(0); }, [commandQuery]); useEffect(() => { setFileSelectedIndex(0); }, [fileContext?.fileQuery]); useEffect(() => { let cancelled = false; if (!projectPath) { setProjectFiles([]); setFilesLoading(false); return () => { cancelled = true; }; } setFilesLoading(true); window.fs.listFiles(projectPath) .then((files) => { if (cancelled) return; setProjectFiles(files); }) .catch((err: unknown) => { console.warn('[prompt-bar] Failed to list files:', err); if (cancelled) return; setProjectFiles([]); }) .finally(() => { if (!cancelled) { setFilesLoading(false); } }); return () => { cancelled = true; }; }, [projectPath]); useEffect(() => { function handleClickOutside(e: MouseEvent) { if (permissionDropdownRef.current && !permissionDropdownRef.current.contains(e.target as Node)) { setPermissionDropdownOpen(false); } if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) { setModelDropdownOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (!isFocused) return; if (hasPendingApproval) return; if (e.shiftKey && e.key === 'Tab') { e.preventDefault(); void setPermissionMode(permissionMode === 'default' ? 'full' : 'default'); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isFocused, hasPendingApproval, permissionMode, setPermissionMode]); const handleCommandSelect = (command: Command) => { setQuery(`/${command.name} `); inputRef.current?.focus(); }; const handleFileSelect = (filePath: string) => { if (!fileContext) return; const { nextQuery, nextCursor } = insertFileTag(query, filePath, fileContext); setQuery(nextQuery); setCursorPosition(nextCursor); setTimeout(() => { if (inputRef.current) { inputRef.current.selectionStart = nextCursor; inputRef.current.selectionEnd = nextCursor; inputRef.current.focus(); } }, 0); }; const handleModelSelect = (model: ModelOption) => { setModelConfig({ name: model.id, effort: model.effort || 'default' }); inputRef.current?.focus(); }; const handleInputChange = (e: React.ChangeEvent) => { setQuery(e.target.value); setCursorPosition(e.target.selectionStart || 0); }; const handleInputSelect = (e: React.SyntheticEvent) => { setCursorPosition((e.target as HTMLTextAreaElement).selectionStart || 0); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (hasPendingApproval) { e.preventDefault(); return; } if (showCommandAutocomplete) { const count = getFilteredCommandCount(commandQuery); if (e.key === 'ArrowDown') { e.preventDefault(); setCommandSelectedIndex((prev) => (prev + 1) % count); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setCommandSelectedIndex((prev) => (prev - 1 + count) % count); return; } if (e.key === 'Tab') { e.preventDefault(); const command = getCommandAtIndex(commandQuery, commandSelectedIndex); if (command) { handleCommandSelect(command); } return; } if (e.key === 'Escape') { e.preventDefault(); setQuery(''); return; } if (e.key === 'Enter') { e.preventDefault(); const command = getCommandAtIndex(commandQuery, commandSelectedIndex); if (command) { onSubmit(`/${command.name}`); setQuery(''); } return; } } if (showFileAutocomplete && fileContext) { const count = fileSuggestions.length; if (count > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setFileSelectedIndex((prev) => (prev + 1) % count); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setFileSelectedIndex((prev) => (prev - 1 + count) % count); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); const filePath = fileSuggestions[fileSelectedIndex] ?? null; if (filePath) { handleFileSelect(filePath); } return; } } if (filesLoading && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); return; } if (e.key === 'Escape') { e.preventDefault(); if (fileContext.atIndex >= 0) { const prefix = query.slice(0, fileContext.atIndex); const suffix = query.slice(fileContext.tokenEnd); setQuery(prefix + suffix); setCursorPosition(prefix.length); } return; } } if (e.key === 'Enter' && !e.shiftKey && query.trim()) { e.preventDefault(); onSubmit(query.trim()); setQuery(''); } }; return (
{showCommandAutocomplete && ( )} {showFileAutocomplete && fileContext && ( )} {pendingImages && pendingImages.length > 0 && (
{pendingImages.map((img, i) => (
{img.fileName
))}
)}
{isDragOver && (
Drop images here
)} {hasPendingApproval && approvalContent ? (
{approvalContent.question}
{approvalContent.preview}
{approvalContent.options.map((option, index) => { const selected = index === approvalSelectedIndex; return ( ); })}
{modelDropdownOpen && (
{getModelsForHarness(harness).map((model, idx) => { const isCurrent = model.id === modelConfig.name && (model.effort || 'default') === (modelConfig.effort || 'default'); return ( ); })}
)}
·
{permissionDropdownOpen && (
{PERMISSION_OPTIONS.map((option) => { const selected = option.value === permissionMode; return ( ); })}
)}
{gitBranch && projectPath && ( <> {githubPR && ( <> · { e.preventDefault(); window.open(githubPR.url, '_blank'); }} className="text-[color:var(--ui-text-muted)] hover:text-[#87CEEB] transition-colors shrink-0" > #{githubPR.number} )} )} ·
) : ( <>