diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 335b7a49..63489175 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -24,6 +24,49 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; +const collectVisibleSessionIdsForBlockingRequests = ( + sessions: Array<{ id: string; parentID?: string }> | undefined, + currentSessionId: string | null +): string[] => { + if (!currentSessionId) return []; + if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId]; + + const current = sessions.find((session) => session.id === currentSessionId); + if (!current) return [currentSessionId]; + + // Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread. + if (current.parentID) { + return []; + } + + const childIds = sessions + .filter((session) => session.parentID === currentSessionId) + .map((session) => session.id); + + return [currentSessionId, ...childIds]; +}; + +const flattenBlockingRequests = ( + source: Map, + sessionIds: string[] +): T[] => { + if (sessionIds.length === 0) return []; + const seen = new Set(); + const result: T[] = []; + + for (const sessionId of sessionIds) { + const entries = source.get(sessionId); + if (!entries || entries.length === 0) continue; + for (const entry of entries) { + if (seen.has(entry.id)) continue; + seen.add(entry.id); + result.push(entry); + } + } + + return result; +}; + export const ChatContainer: React.FC = () => { const { currentSessionId, @@ -67,20 +110,32 @@ export const ChatContainer: React.FC = () => { ) ); - const sessionPermissions = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.permissions.get(currentSessionId) ?? EMPTY_PERMISSIONS : EMPTY_PERMISSIONS), - [currentSessionId] - ) + const blockingRequestState = useSessionStore( + useShallow((state) => ({ + sessions: state.sessions, + permissions: state.permissions, + questions: state.questions, + })) ); - const sessionQuestions = useSessionStore( - React.useCallback( - (state) => (currentSessionId ? state.questions.get(currentSessionId) ?? EMPTY_QUESTIONS : EMPTY_QUESTIONS), - [currentSessionId] - ) + const scopedSessionIds = React.useMemo( + () => collectVisibleSessionIdsForBlockingRequests( + blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })), + currentSessionId, + ), + [blockingRequestState.sessions, currentSessionId] ); + const sessionPermissions = React.useMemo(() => { + if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; + return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds); + }, [blockingRequestState.permissions, scopedSessionIds]); + + const sessionQuestions = React.useMemo(() => { + if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; + return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds); + }, [blockingRequestState.questions, scopedSessionIds]); + const memoryState = useSessionStore( React.useCallback( (state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null), diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8254d5f0..1b3af3fe 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -20,7 +20,6 @@ import { AttachedFilesList } from './FileAttachment'; import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; -import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; @@ -77,13 +76,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } return draft; }); + const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); const [isDragging, setIsDragging] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); const [commandQuery, setCommandQuery] = React.useState(''); - const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false); - const [agentQuery, setAgentQuery] = React.useState(''); const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands'); const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); @@ -98,7 +96,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const canAcceptDropRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); - const agentRef = React.useRef(null); const skillRef = React.useRef(null); const sendMessage = useSessionStore((state) => state.sendMessage); @@ -210,6 +207,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo React.useEffect(() => { if (prevSessionIdRef.current !== currentSessionId) { prevSessionIdRef.current = currentSessionId; + setInputMode('normal'); if (!persistChatDraft) { // Clear draft when switching sessions if persist is disabled @@ -542,9 +540,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo textareaRef.current?.blur(); } - // Handle slash commands locally before sending + // Handle local slash commands only in normal mode const normalizedCommand = primaryText.trimStart(); - if (normalizedCommand.startsWith('/')) { + if (inputMode === 'normal' && normalizedCommand.startsWith('/')) { const commandName = normalizedCommand .slice(1) .trim() @@ -571,28 +569,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setMessage(''); return; // Don't send to assistant } - // /compact - call SDK summarize endpoint - else if (commandName === 'compact' && currentSessionId) { - try { - const { opencodeClient } = await import('@/lib/opencode/client'); - const directory = opencodeClient.getDirectory(); - const response = await opencodeClient.getApiClient().session.summarize({ - sessionID: currentSessionId, - directory: directory || undefined, - providerID: currentProviderId, - modelID: currentModelId, - }); - if (response.error) { - throw new Error('Failed to compact session'); - } - scrollToBottom?.({ instant: true, force: true }); - } catch (error) { - console.error('Failed to compact session:', error); - toast.error('Failed to compact session'); - } - setMessage(''); - return; // Don't send to assistant - } } // Collect all attachments for error recovery @@ -609,7 +585,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo primaryAttachments, agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, - currentVariant + currentVariant, + inputMode ).catch((error: unknown) => { const rawMessage = error instanceof Error @@ -663,13 +640,13 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Primary action for send button - respects queue mode setting const handlePrimaryAction = React.useCallback(() => { - const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle'; + const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled && canQueue) { handleQueueMessage(); } else { void handleSubmitRef.current(); } - }, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); + }, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); // Auto-send queued messages when session becomes idle (but not after abort) React.useEffect(() => { @@ -706,6 +683,18 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. if (isIMECompositionEvent(e)) return; + if (inputMode === 'shell' && e.key === 'Escape') { + e.preventDefault(); + setInputMode('normal'); + return; + } + + if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) { + e.preventDefault(); + setInputMode('normal'); + return; + } + if (showCommandAutocomplete && commandRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); @@ -714,14 +703,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } - if (showAgentAutocomplete && agentRef.current) { - if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { - e.preventDefault(); - agentRef.current.handleKeyDown(e.key); - return; - } - } - if (showSkillAutocomplete && skillRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); @@ -738,7 +719,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } - if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) { + if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) { e.preventDefault(); handleCycleAgent(); return; @@ -747,7 +728,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Handle ArrowUp/ArrowDown for message history navigation // ArrowUp: only when cursor at start (position 0) or input is empty // ArrowDown: also works when cursor at end (to cycle forward through history) - const isAnyAutocompleteOpen = showCommandAutocomplete || showAgentAutocomplete || showSkillAutocomplete || showFileMention; + const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention; const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0; const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length; const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart); @@ -800,7 +781,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Normal mode: Enter sends, Ctrl+Enter queues // Note: Queueing only works when there's an existing session (currentSessionId) // For new sessions (draft), always send immediately - const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle'; + const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled) { if (isCtrlEnter || !canQueue) { @@ -896,6 +877,13 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [adjustTextareaHeight, message, isMobile]); const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { + if (inputMode === 'shell') { + setShowCommandAutocomplete(false); + setShowFileMention(false); + setShowSkillAutocomplete(false); + return; + } + if (value.startsWith('/')) { const firstSpace = value.indexOf(' '); const firstNewline = value.indexOf('\n'); @@ -910,7 +898,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setAutocompleteTab('commands'); setShowCommandAutocomplete(true); setShowFileMention(false); - setShowAgentAutocomplete(false); setShowSkillAutocomplete(false); return; } @@ -920,25 +907,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const textBeforeCursor = value.substring(0, cursorPosition); - const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); - if (lastHashSymbol !== -1) { - const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; - const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); - const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n'); - const isWordBoundary = !charBefore || /\s/.test(charBefore); - - if (isWordBoundary && !hasSeparator) { - setAgentQuery(textAfterHash); - setAutocompleteTab('agents'); - setShowAgentAutocomplete(true); - setShowFileMention(false); - return; - } - } - - setShowAgentAutocomplete(false); - setAgentQuery(''); - const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); if (lastSlashSymbol !== -1) { const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null; @@ -950,7 +918,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setSkillQuery(textAfterSlash); setShowSkillAutocomplete(true); setShowFileMention(false); - setShowAgentAutocomplete(false); return; } } @@ -960,10 +927,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { + const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); - if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + const isWordBoundary = !charBefore || /\s/.test(charBefore); + if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); - setAutocompleteTab('files'); + setAutocompleteTab('agents'); setShowFileMention(true); } else { setShowFileMention(false); @@ -971,12 +940,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } else { setShowFileMention(false); } - }, [setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); + }, [inputMode, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); - const applyAutocompletePrefix = React.useCallback((prefix: '/' | '#' | '@') => { + const applyAutocompletePrefix = React.useCallback((prefix: '/' | '@') => { const nextMessage = message.length === 0 ? prefix - : (message[0] === '/' || message[0] === '#' || message[0] === '@') + : (message[0] === '/' || message[0] === '@') ? `${prefix}${message.slice(1)}` : `${prefix}${message}`; setMessage(nextMessage); @@ -1008,22 +977,20 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } setAutocompleteTab(tab); setCommandQuery(''); - setAgentQuery(''); setMentionQuery(''); if (tab === 'commands') { applyAutocompletePrefix('/'); } if (tab === 'agents') { - applyAutocompletePrefix('#'); + applyAutocompletePrefix('@'); } if (tab === 'files') { applyAutocompletePrefix('@'); } setShowSkillAutocomplete(false); setShowCommandAutocomplete(tab === 'commands'); - setShowAgentAutocomplete(tab === 'agents'); - setShowFileMention(tab === 'files'); - }, [applyAutocompletePrefix, isMobile, setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); + setShowFileMention(tab === 'agents' || tab === 'files'); + }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); const handleOpenCommandMenu = React.useCallback(() => { if (!isMobile) { @@ -1047,10 +1014,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setCommandQuery(''); setAutocompleteTab('commands'); setShowCommandAutocomplete(true); - setShowAgentAutocomplete(false); setShowFileMention(false); setShowSkillAutocomplete(false); - }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); + }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); const insertTextAtSelection = React.useCallback((text: string) => { if (!text) { @@ -1087,6 +1053,25 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const handleTextChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPosition = e.target.selectionStart ?? value.length; + + if (inputMode === 'normal' && value.startsWith('!')) { + const shellCommand = value.slice(1); + const nextCursor = Math.max(0, cursorPosition - 1); + setInputMode('shell'); + setMessage(shellCommand); + adjustTextareaHeight(); + setShowCommandAutocomplete(false); + setShowSkillAutocomplete(false); + setShowFileMention(false); + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.selectionStart = nextCursor; + textareaRef.current.selectionEnd = nextCursor; + } + }); + return; + } + setMessage(value); adjustTextareaHeight(); updateAutocompleteState(value, cursorPosition); @@ -1186,16 +1171,16 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); - const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); + const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); - if (lastHashSymbol !== -1) { + if (lastAtSymbol !== -1) { const newMessage = - message.substring(0, lastHashSymbol) + - `#${agentName} ` + + message.substring(0, lastAtSymbol) + + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); - const nextCursor = lastHashSymbol + agentName.length + 2; + const nextCursor = lastAtSymbol + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; @@ -1207,7 +1192,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } else if (textareaRef.current) { const newMessage = message.substring(0, cursorPosition) + - `#${agentName} ` + + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); @@ -1222,8 +1207,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }); } - setShowAgentAutocomplete(false); - setAgentQuery(''); + setShowFileMention(false); + setMentionQuery(''); textareaRef.current?.focus(); }; @@ -1237,11 +1222,11 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (lastSlashSymbol !== -1) { const newMessage = message.substring(0, lastSlashSymbol) + - `${skillName} ` + + `/${skillName} ` + message.substring(cursorPosition); setMessage(newMessage); - const nextCursor = lastSlashSymbol + skillName.length + 1; + const nextCursor = lastSlashSymbol + skillName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; @@ -1914,7 +1899,10 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo className={cn( "flex flex-col relative overflow-visible", "border border-border/80", - "focus-within:ring-1 focus-within:ring-primary/50", + "focus-within:ring-1", + inputMode === 'shell' + ? 'focus-within:ring-[var(--status-info)]' + : 'focus-within:ring-primary/50', isDragging && "ring-2 ring-primary ring-offset-2" )} style={{ @@ -1958,18 +1946,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo /> )} { } - {showAgentAutocomplete && ( - setShowAgentAutocomplete(false)} - /> - )} - {showSkillAutocomplete && ( = ({ onOpenSettings, scrollToBo ref={mentionRef} searchQuery={mentionQuery} onFileSelect={handleFileSelect} + onAgentSelect={handleAgentSelect} showTabs={isMobile} activeTab={autocompleteTab} onTabSelect={handleAutocompleteTabSelect} @@ -2003,7 +1980,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo onDrop={handleDrop} onPointerDownCapture={handleTextareaPointerDownCapture} placeholder={currentSessionId || newSessionDraftOpen - ? "# for agents; @ for files; / for commands" + ? inputMode === 'shell' + ? "Enter shell command..." + : "@ for files/agents; / for commands; ! for shell" : "Select or create a session to start chatting"} disabled={!currentSessionId && !newSessionDraftOpen} autoCorrect={isMobile ? "on" : "off"} @@ -2012,6 +1991,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo outerClassName="focus-within:ring-0" className={cn( 'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent', + inputMode === 'shell' && 'font-mono', isMobile ? "py-2.5" : "pt-4 pb-2" )} style={{ diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 85554d85..adfbe781 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -160,6 +160,7 @@ const ChatMessage: React.FC = ({ const trimmed = text.trim(); if (trimmed.startsWith('User has requested to enter plan mode')) return true; if (trimmed.startsWith('The plan at ')) return true; + if (trimmed.startsWith('The following tool was executed by the user')) return true; return false; }; @@ -176,6 +177,12 @@ const ChatMessage: React.FC = ({ if (rawPart.type === 'compaction') { return { type: 'text', text: '/compact' } as Part; } + if (rawPart.type === 'text') { + const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : ''; + if (text.startsWith('The following tool was executed by the user')) { + return { type: 'text', text: '/shell' } as Part; + } + } return part; }); }, [isUser, message.parts]); @@ -460,10 +467,12 @@ const ChatMessage: React.FC = ({ } const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0 ? partWithName.source.value - : `#${name}`; + : `@${name}`; return { name, token: rawValue } satisfies AgentMentionInfo; }, [isUser, message.parts]); + const shouldHideUserMessage = isUser && displayParts.length === 0; + // Message is considered to have an "open step" if info.finish is not yet present const hasOpenStep = typeof messageFinish !== 'string'; @@ -642,6 +651,30 @@ const ChatMessage: React.FC = ({ const messageTextContent = React.useMemo(() => { if (isUser) { + const shellOutputs = displayParts + .filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text') + .map((part) => { + const output = part.shellAction?.output; + return typeof output === 'string' ? output.trim() : ''; + }) + .filter((output) => output.length > 0); + + if (shellOutputs.length > 0) { + return shellOutputs.join('\n\n'); + } + + const shellCommands = displayParts + .filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text') + .map((part) => { + const command = part.shellAction?.command; + return typeof command === 'string' ? command.trim() : ''; + }) + .filter((command) => command.length > 0); + + if (shellCommands.length > 0) { + return shellCommands.join('\n'); + } + const textParts = displayParts .filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text') .map((part) => { @@ -885,6 +918,10 @@ const ChatMessage: React.FC = ({ }; }, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]); + if (shouldHideUserMessage) { + return null; + } + return ( <>
([]); const [loading, setLoading] = React.useState(false); const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore(); + const { skills, loadSkills: refreshSkills } = useSkillsStore(); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); @@ -82,18 +85,21 @@ export const CommandAutocomplete = React.forwardRef { // Force refresh to get latest project context when mounting void refreshCommands(); - }, [refreshCommands]); + void refreshSkills(); + }, [refreshCommands, refreshSkills]); React.useEffect(() => { const loadCommands = async () => { setLoading(true); try { + const skillNames = new Set(skills.map((skill) => skill.name)); const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({ name: cmd.name, description: cmd.description, agent: cmd.agent ?? undefined, model: cmd.model ?? undefined, isBuiltIn: cmd.name === 'init' || cmd.name === 'review', + isSkill: skillNames.has(cmd.name), scope: cmd.scope, })); @@ -171,7 +177,7 @@ export const CommandAutocomplete = React.forwardRef { setSelectedIndex(0); @@ -356,6 +362,11 @@ export const CommandAutocomplete = React.forwardRef
/{command.name} + {command.isSkill ? ( + + skill + + ) : null} {isSystem ? ( system diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index dfe6071a..df86cf7e 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -4,6 +4,7 @@ import { cn, truncatePathMiddle } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { ProjectFileSearchHit } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; @@ -11,6 +12,11 @@ import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; type FileInfo = ProjectFileSearchHit; +type AgentInfo = { + name: string; + description?: string; + mode?: string | null; +}; export interface FileMentionHandle { handleKeyDown: (key: string) => void; @@ -21,6 +27,7 @@ type AutocompleteTab = 'commands' | 'agents' | 'files'; interface FileMentionAutocompleteProps { searchQuery: string; onFileSelect: (file: FileInfo) => void; + onAgentSelect?: (agentName: string) => void; onClose: () => void; showTabs?: boolean; activeTab?: AutocompleteTab; @@ -30,6 +37,7 @@ interface FileMentionAutocompleteProps { export const FileMentionAutocomplete = React.forwardRef(({ searchQuery, onFileSelect, + onAgentSelect, onClose, showTabs, activeTab = 'files', @@ -37,11 +45,13 @@ export const FileMentionAutocomplete = React.forwardRef { const { currentDirectory } = useDirectoryStore(); const { addServerFile } = useSessionStore(); + const { getVisibleAgents } = useConfigStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const [files, setFiles] = React.useState([]); + const [agents, setAgents] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [marqueeWidth, setMarqueeWidth] = React.useState(360); @@ -52,6 +62,8 @@ export const FileMentionAutocomplete = React.forwardRef([]); const containerRef = React.useRef(null); const ignoreTabClickRef = React.useRef(false); + const normalizedSearchQuery = (searchQuery ?? '').trim(); + const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2); const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { const q = query.trim().toLowerCase(); @@ -179,11 +191,30 @@ export const FileMentionAutocomplete = React.forwardRef { + const visibleAgents = getVisibleAgents(); + const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); + const filtered = visibleAgents + .filter((agent) => agent.mode && agent.mode !== 'primary') + .filter((agent) => { + if (!normalizedQuery) return true; + const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }) + .map((agent) => ({ + name: agent.name, + description: agent.description, + mode: agent.mode, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setAgents(filtered); + }, [getVisibleAgents, searchQuery]); + React.useEffect(() => { setSelectedIndex(0); setOverflowMap({}); setMarqueeDurations({}); - }, [files]); + }, [files, visibleAgents.length]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ @@ -265,6 +296,10 @@ export const FileMentionAutocomplete = React.forwardRef { + onAgentSelect?.(agentName); + }, [onAgentSelect]); + React.useImperativeHandle(ref, () => ({ handleKeyDown: (key: string) => { if (key === 'Escape') { @@ -272,7 +307,7 @@ export const FileMentionAutocomplete = React.forwardRef { const ext = file.extension?.toLowerCase(); @@ -373,26 +415,52 @@ export const FileMentionAutocomplete = React.forwardRef ) : (
+ {visibleAgents.map((agent, index) => { + const isSelected = selectedIndex === index; + return ( +
{ itemRefs.current[index] = el; }} + className={cn( + 'flex items-start gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg', + isSelected && 'bg-interactive-selection', + )} + onClick={() => handleAgentPick(agent.name)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+
@{agent.name}
+ {agent.description ? ( +
{agent.description}
+ ) : null} +
+
+ ); + })} + {visibleAgents.length > 0 && files.length > 0 && ( +
+ )} {files.map((file, index) => { + const rowIndex = visibleAgents.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 }); - const isSelected = selectedIndex === index; - const isOverflowing = overflowMap[index] ?? false; - const marqueeDuration = marqueeDurations[index] ?? 2.6; + const isSelected = selectedIndex === rowIndex; + const isOverflowing = overflowMap[rowIndex] ?? false; + const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6; const item = (
{ itemRefs.current[index] = el; }} + ref={(el) => { itemRefs.current[rowIndex] = el; }} className={cn( "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg", isSelected && "bg-interactive-selection" )} onClick={() => handleFileSelect(file)} - onMouseEnter={() => setSelectedIndex(index)} + onMouseEnter={() => setSelectedIndex(rowIndex)} > {getFileIcon(file)} { labelRefs.current[index] = el; }} + ref={(el) => { labelRefs.current[rowIndex] = el; }} className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container" style={isSelected ? { ['--file-mention-marquee-width' as string]: `${marqueeWidth}px`, @@ -401,7 +469,7 @@ export const FileMentionAutocomplete = React.forwardRef { measureRefs.current[index] = el; }} + ref={(el) => { measureRefs.current[rowIndex] = el; }} className="absolute invisible whitespace-nowrap pointer-events-none" aria-hidden > @@ -426,11 +494,14 @@ export const FileMentionAutocomplete = React.forwardRef ); })} - {} - {files.length > 0 &&
} - {files.length === 0 && ( + {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && ( +
+ Type to search more agents +
+ )} + {files.length === 0 && visibleAgents.length === 0 && (
- No files found + No matches found
)}
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 9bc1a7c6..9a8e290c 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -18,6 +18,221 @@ interface ChatMessageEntry { parts: Part[]; } +const USER_SHELL_MARKER = 'The following tool was executed by the user'; + +const resolveMessageRole = (message: ChatMessageEntry): string | null => { + const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined }; + return (typeof info.clientRole === 'string' ? info.clientRole : null) + ?? (typeof info.role === 'string' ? info.role : null) + ?? null; +}; + +const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => { + if (!message) return false; + if (resolveMessageRole(message) !== 'user') return false; + return message.parts.some((part) => part?.type === 'subtask'); +}; + +const getMessageId = (message: ChatMessageEntry | undefined): string | null => { + if (!message) return null; + const id = (message.info as unknown as { id?: unknown }).id; + return typeof id === 'string' && id.trim().length > 0 ? id : null; +}; + +const getMessageParentId = (message: ChatMessageEntry): string | null => { + const parentID = (message.info as unknown as { parentID?: unknown }).parentID; + return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null; +}; + +const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => { + if (!message) return false; + if (resolveMessageRole(message) !== 'user') return false; + + return message.parts.some((part) => { + if (part?.type !== 'text') return false; + const text = (part as unknown as { text?: unknown }).text; + const synthetic = (part as unknown as { synthetic?: unknown }).synthetic; + return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER); + }); +}; + +type ShellBridgeDetails = { + command?: string; + output?: string; + status?: string; +}; + +const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => { + if (resolveMessageRole(message) !== 'assistant') { + return { hide: false, details: null }; + } + + if (expectedParentId && getMessageParentId(message) !== expectedParentId) { + return { hide: false, details: null }; + } + + if (message.parts.length !== 1) { + return { hide: false, details: null }; + } + + const part = message.parts[0] as unknown as { + type?: unknown; + tool?: unknown; + state?: { + status?: unknown; + input?: { command?: unknown }; + output?: unknown; + metadata?: { output?: unknown }; + }; + }; + + if (part.type !== 'tool') { + return { hide: false, details: null }; + } + + const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : ''; + if (toolName !== 'bash') { + return { hide: false, details: null }; + } + + const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined; + const output = + (typeof part.state?.output === 'string' ? part.state.output : undefined) + ?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined); + const status = typeof part.state?.status === 'string' ? part.state.status : undefined; + + return { + hide: true, + details: { + command, + output, + status, + }, + }; +}; + +const readTaskSessionId = (toolPart: Part): string | null => { + const partRecord = toolPart as unknown as { + state?: { + metadata?: { sessionId?: unknown; sessionID?: unknown }; + output?: unknown; + }; + }; + const metadata = partRecord.state?.metadata; + const fromMetadata = + (typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0 + ? metadata.sessionId.trim() + : null) + ?? (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0 + ? metadata.sessionID.trim() + : null); + if (fromMetadata) return fromMetadata; + + const output = partRecord.state?.output; + if (typeof output === 'string') { + const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/); + if (match?.[1]) { + return match[1]; + } + } + + return null; +}; + +const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => { + if (resolveMessageRole(message) !== 'assistant') { + return { hide: false, taskSessionId: null }; + } + + if (message.parts.length !== 1) { + return { hide: false, taskSessionId: null }; + } + + const onlyPart = message.parts[0] as unknown as { + type?: unknown; + tool?: unknown; + }; + + if (onlyPart.type !== 'tool') { + return { hide: false, taskSessionId: null }; + } + + const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : ''; + if (toolName !== 'task') { + return { hide: false, taskSessionId: null }; + } + + return { + hide: true, + taskSessionId: readTaskSessionId(message.parts[0]), + }; +}; + +const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => { + if (!taskSessionId) return message; + const nextParts = message.parts.map((part) => { + if (part?.type !== 'subtask') return part; + const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID; + if (typeof existing === 'string' && existing.trim().length > 0) return part; + return { + ...part, + taskSessionID: taskSessionId, + } as Part; + }); + + return { + ...message, + parts: nextParts, + }; +}; + +const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => { + const command = typeof details?.command === 'string' ? details.command.trim() : ''; + const output = typeof details?.output === 'string' ? details.output : ''; + const status = typeof details?.status === 'string' ? details.status.trim() : ''; + + const nextParts: Part[] = []; + let injected = false; + + for (const part of message.parts) { + if (!injected && part?.type === 'text') { + const text = (part as unknown as { text?: unknown }).text; + const synthetic = (part as unknown as { synthetic?: unknown }).synthetic; + if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) { + nextParts.push({ + type: 'text', + text: '/shell', + shellAction: { + ...(command ? { command } : {}), + ...(output ? { output } : {}), + ...(status ? { status } : {}), + }, + } as unknown as Part); + injected = true; + continue; + } + } + nextParts.push(part); + } + + if (!injected) { + nextParts.push({ + type: 'text', + text: '/shell', + shellAction: { + ...(command ? { command } : {}), + ...(output ? { output } : {}), + ...(status ? { status } : {}), + }, + } as unknown as Part); + } + + return { + ...message, + parts: nextParts, + }; +}; + interface MessageListProps { messages: ChatMessageEntry[]; permissions: PermissionRequest[]; @@ -215,7 +430,7 @@ const MessageList: React.FC = ({ const baseDisplayMessages = React.useMemo(() => { const seenIds = new Set(); - return messages + const normalizedMessages = messages .filter((message) => { const messageId = message.info?.id; if (typeof messageId === 'string') { @@ -238,6 +453,33 @@ const MessageList: React.FC = ({ parts: filteredParts, }; }); + + const output: ChatMessageEntry[] = []; + + for (let index = 0; index < normalizedMessages.length; index += 1) { + const current = normalizedMessages[index]; + const previous = output.length > 0 ? output[output.length - 1] : undefined; + + if (isUserSubtaskMessage(previous)) { + const bridge = isSyntheticSubtaskBridgeAssistant(current); + if (bridge.hide) { + output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId); + continue; + } + } + + if (isUserShellMarkerMessage(previous)) { + const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous)); + if (bridge.hide) { + output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details); + continue; + } + } + + output.push(current); + } + + return output; }, [messages]); const activeRetryStatus = useSessionStore( @@ -266,16 +508,9 @@ const MessageList: React.FC = ({ data: { message: activeRetryStatus.message }, }; - const resolveRole = (message: ChatMessageEntry): string | null => { - const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined }; - return (typeof info.clientRole === 'string' ? info.clientRole : null) - ?? (typeof info.role === 'string' ? info.role : null) - ?? null; - }; - let lastUserIndex = -1; for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) { - if (resolveRole(baseDisplayMessages[index]) === 'user') { + if (resolveMessageRole(baseDisplayMessages[index]) === 'user') { lastUserIndex = index; break; } @@ -289,7 +524,7 @@ const MessageList: React.FC = ({ // to avoid rendering a separate header-only placeholder + error block. let targetAssistantIndex = -1; for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) { - if (resolveRole(baseDisplayMessages[index]) === 'assistant') { + if (resolveMessageRole(baseDisplayMessages[index]) === 'assistant') { targetAssistantIndex = index; break; } diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index 27a03835..2fee9c52 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -63,6 +63,14 @@ export const PermissionCard: React.FC = ({ const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); const { respondToPermission } = useSessionStore(); + const isFromSubagent = useSessionStore( + React.useCallback((state) => { + const currentSessionId = state.currentSessionId; + if (!currentSessionId || permission.sessionID === currentSessionId) return false; + const sourceSession = state.sessions.find((session) => session.id === permission.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [permission.sessionID]) + ); const { currentTheme } = useThemeSystem(); const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); @@ -317,6 +325,11 @@ export const PermissionCard: React.FC = ({ Permission Required + {isFromSubagent ? ( + + From subagent + + ) : null}
{getToolIcon(toolName)} diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index 8f6c7102..a645e04b 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -15,6 +15,14 @@ const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ question }) => { const { respondToQuestion, rejectQuestion } = useSessionStore(); + const isFromSubagent = useSessionStore( + React.useCallback((state) => { + const currentSessionId = state.currentSessionId; + if (!currentSessionId || question.sessionID === currentSessionId) return false; + const sourceSession = state.sessions.find((session) => session.id === question.sessionID); + return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId); + }, [question.sessionID]) + ); const [activeTab, setActiveTab] = React.useState('0'); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); @@ -169,6 +177,11 @@ export const QuestionCard: React.FC = ({ question }) => {
Input needed + {isFromSubagent ? ( + + From subagent + + ) : null} {activeHeader ? ( {activeHeader} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index afdd7c87..f8c62fbb 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -28,6 +28,235 @@ import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useConfigStore } from '@/stores/useConfigStore'; import { TextSelectionMenu } from './TextSelectionMenu'; +type SubtaskPartLike = Part & { + type: 'subtask'; + description?: unknown; + command?: unknown; + agent?: unknown; + prompt?: unknown; + taskSessionID?: unknown; + model?: { + providerID?: unknown; + modelID?: unknown; + }; +}; + +type ShellActionPartLike = Part & { + type: 'text'; + shellAction?: { + command?: unknown; + output?: unknown; + status?: unknown; + }; +}; + +const isSubtaskPart = (part: Part): part is SubtaskPartLike => { + return part.type === 'subtask'; +}; + +const isShellActionPart = (part: Part): part is ShellActionPartLike => { + const textPart = part as unknown as { type?: unknown; shellAction?: unknown }; + return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null; +}; + +const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => { + if (!model || typeof model !== 'object') return null; + const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : ''; + const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : ''; + if (!providerID || !modelID) return null; + return `${providerID}/${modelID}`; +}; + +const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => { + const [expanded, setExpanded] = React.useState(false); + const setCurrentSession = useSessionStore((state) => state.setCurrentSession); + + const description = typeof part.description === 'string' ? part.description.trim() : ''; + const command = typeof part.command === 'string' ? part.command.trim() : ''; + const agent = typeof part.agent === 'string' ? part.agent.trim() : ''; + const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : ''; + const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : ''; + const model = normalizeSubtaskModel(part.model); + + return ( +
+
+ Delegated task + {command ? ( + + /{command} + + ) : null} + {agent ? ( + + @{agent} + + ) : null} + {model ? ( + + {model} + + ) : null} +
+ + {description ? ( +
+ {description} +
+ ) : null} + + {prompt ? ( +
+ + {expanded ? ( +
+                            {prompt}
+                        
+ ) : null} +
+ ) : null} + + {taskSessionID ? ( +
+ +
+ ) : null} +
+ ); +}; + +const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => { + const [expanded, setExpanded] = React.useState(false); + const [copiedOutput, setCopiedOutput] = React.useState(false); + const copiedResetTimeoutRef = React.useRef(null); + + const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : ''; + const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : ''; + const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : ''; + const hasOutput = output.trim().length > 0; + + const clearCopiedResetTimeout = React.useCallback(() => { + if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') { + window.clearTimeout(copiedResetTimeoutRef.current); + copiedResetTimeoutRef.current = null; + } + }, []); + + React.useEffect(() => { + return () => { + clearCopiedResetTimeout(); + }; + }, [clearCopiedResetTimeout]); + + const copyOutputToClipboard = React.useCallback(async () => { + if (!hasOutput) return; + + let succeeded = false; + + if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) { + try { + await navigator.clipboard.writeText(output); + succeeded = true; + } catch { + succeeded = false; + } + } + + if (!succeeded && typeof document !== 'undefined') { + const textarea = document.createElement('textarea'); + textarea.value = output; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-1000px'; + textarea.style.left = '-1000px'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + succeeded = document.execCommand('copy'); + document.body.removeChild(textarea); + } + + if (!succeeded) return; + + clearCopiedResetTimeout(); + setCopiedOutput(true); + if (typeof window !== 'undefined') { + copiedResetTimeoutRef.current = window.setTimeout(() => { + setCopiedOutput(false); + copiedResetTimeoutRef.current = null; + }, 2000); + } + }, [clearCopiedResetTimeout, hasOutput, output]); + + return ( +
+
+ Shell command + {status ? ( + + {status} + + ) : null} +
+ + {command ? ( +
+                    {command}
+                
+ ) : null} + + {hasOutput ? ( +
+
+ + +
+ {expanded ? ( +
+                            {output}
+                        
+ ) : null} +
+ ) : null} +
+ ); +}; + const formatTurnDuration = (durationMs: number): string => { const totalSeconds = durationMs / 1000; if (totalSeconds < 60) { @@ -94,10 +323,18 @@ const UserMessageBody: React.FC<{ const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); - const textParts = React.useMemo(() => { + const userContentParts = React.useMemo(() => { return parts.filter((part) => { - if (part.type !== 'text') return false; - return !isEmptyTextPart(part); + if (part.type === 'text') { + return !isEmptyTextPart(part); + } + if (isSubtaskPart(part)) { + return true; + } + if (isShellActionPart(part)) { + return true; + } + return false; }); }, [parts]); @@ -160,7 +397,23 @@ const UserMessageBody: React.FC<{ onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined} >
- {textParts.map((part, index) => { + {userContentParts.map((part, index) => { + if (isSubtaskPart(part)) { + return ( + + + + ); + } + + if (isShellActionPart(part)) { + return ( + + + + ); + } + let mentionForPart: AgentMentionInfo | undefined; if (agentMention && mentionToken && !mentionInjected) { const candidateText = extractTextContent(part); @@ -170,7 +423,7 @@ const UserMessageBody: React.FC<{ } } return ( - + { if (tool === 'skill') { return ; } + if (tool === 'task') { + return ; + } if (tool === 'question') { return ; } @@ -277,6 +281,66 @@ type TaskToolSummaryEntry = { }; }; +type SessionMessageWithParts = { + info?: { + role?: string; + }; + parts?: Array<{ + id?: string; + type?: string; + tool?: string; + state?: { + status?: string; + title?: string; + }; + }>; +}; + +const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = []; + +const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => { + if (typeof output !== 'string' || output.trim().length === 0) { + return undefined; + } + const parsedMetadata = parseTaskMetadataBlock(output); + if (parsedMetadata.sessionId) { + return parsedMetadata.sessionId; + } + const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/); + const candidate = match?.[1]; + return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined; +}; + +const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => { + const entries: TaskToolSummaryEntry[] = []; + + for (const message of messages) { + if (message?.info?.role !== 'assistant') { + continue; + } + const parts = Array.isArray(message.parts) ? message.parts : []; + for (const part of parts) { + if (part?.type !== 'tool') { + continue; + } + const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : ''; + if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') { + continue; + } + entries.push({ + id: part.id, + tool: part.tool, + state: { + status: part.state?.status, + title: part.state?.title, + }, + }); + } + } + + return entries; +}; + const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { const title = entry.state?.title; if (typeof title === 'string' && title.trim().length > 0) { @@ -293,6 +357,97 @@ const stripTaskMetadataFromOutput = (output: string): string => { return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); }; +const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => { + if (!Array.isArray(value)) { + return []; + } + + const normalized: TaskToolSummaryEntry[] = []; + for (const entry of value) { + if (typeof entry === 'string') { + normalized.push({ + tool: 'tool', + state: { status: 'completed', title: entry }, + }); + continue; + } + + if (!entry || typeof entry !== 'object') { + continue; + } + + const record = entry as { + id?: unknown; + tool?: unknown; + title?: unknown; + status?: unknown; + state?: { status?: unknown; title?: unknown }; + }; + + const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined; + const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined; + const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined); + const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined); + + normalized.push({ + id: typeof record.id === 'string' ? record.id : undefined, + tool: typeof record.tool === 'string' ? record.tool : 'tool', + state: { + status, + title, + }, + }); + } + + return normalized; +}; + +const parseTaskMetadataBlock = (output: string | undefined): { + sessionId?: string; + summaryEntries: TaskToolSummaryEntry[]; +} => { + if (typeof output !== 'string' || output.trim().length === 0) { + return { summaryEntries: [] }; + } + + const blockMatch = output.match(/\s*([\s\S]*?)\s*<\/task_metadata>/i); + if (!blockMatch?.[1]) { + return { summaryEntries: [] }; + } + + const raw = blockMatch[1].trim(); + if (!raw) { + return { summaryEntries: [] }; + } + + try { + const parsed = JSON.parse(raw) as { + sessionId?: unknown; + sessionID?: unknown; + summary?: unknown; + entries?: unknown; + tools?: unknown; + calls?: unknown; + }; + + const summaryEntries = normalizeTaskSummaryEntries( + parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls + ); + + const sessionId = + (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0 + ? parsed.sessionId.trim() + : undefined) ?? + (typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0 + ? parsed.sessionID.trim() + : undefined); + + return { sessionId, summaryEntries }; + } catch { + return { summaryEntries: [] }; + } +}; + const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; @@ -302,8 +457,9 @@ const TaskToolSummary: React.FC<{ sessionId?: string; }> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => { const setCurrentSession = useSessionStore((state) => state.setCurrentSession); - const completedEntries = React.useMemo(() => { - return entries.filter((entry) => entry.state?.status === 'completed'); + const displayEntries = React.useMemo(() => { + const nonPending = entries.filter((entry) => entry.state?.status !== 'pending'); + return nonPending.length > 0 ? nonPending : entries; }, [entries]); const trimmedOutput = typeof output === 'string' @@ -319,12 +475,12 @@ const TaskToolSummary: React.FC<{ } }; - if (completedEntries.length === 0 && !hasOutput && !sessionId) { + if (displayEntries.length === 0 && !hasOutput && !sessionId) { return null; } - const visibleEntries = isExpanded ? completedEntries : completedEntries.slice(-6); - const hiddenCount = Math.max(0, completedEntries.length - visibleEntries.length); + const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6); + const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length); return (
- {completedEntries.length > 0 ? ( + {displayEntries.length > 0 ? (
{hiddenCount > 0 ? ( @@ -345,6 +501,7 @@ const TaskToolSummary: React.FC<{ {visibleEntries.map((entry, idx) => { const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool'; const label = getTaskSummaryLabel(entry); + const status = entry.state?.status; const displayName = getToolMetadata(toolName).displayName; @@ -352,7 +509,10 @@ const TaskToolSummary: React.FC<{
{getToolIcon(toolName)} {displayName} - {label} + {label}
); })} @@ -373,7 +533,7 @@ const TaskToolSummary: React.FC<{ )} {hasOutput ? ( -
0 || sessionId) && 'pt-1')} +
0 || sessionId) && 'pt-1')} >
- {commands.length === 0 ? ( + {commandOnlyItems.length === 0 ? (

No commands configured

diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 53169af1..c11da62d 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -793,6 +793,28 @@ export const useEventStream = () => { break; } + const shouldKeepSyntheticUserText = (value: unknown): boolean => { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return false; + return ( + text.startsWith('User has requested to enter plan mode') || + text.startsWith('The plan at ') || + text.startsWith('The following tool was executed by the user') + ); + }; + + const inferUserRoleFromPart = (): boolean => { + const partType = typeof partExt.type === 'string' ? partExt.type : ''; + if (partType === 'subtask' || partType === 'agent' || partType === 'file') { + return true; + } + if (partType === 'text' && partExt.synthetic === true) { + const text = (partExt as { text?: unknown }).text; + return shouldKeepSyntheticUserText(text); + } + return false; + }; + let roleInfo = 'assistant'; if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') { roleInfo = (messageInfo as { role?: string }).role as string; @@ -806,11 +828,18 @@ export const useEventStream = () => { } } + if (roleInfo !== 'user' && inferUserRoleFromPart()) { + roleInfo = 'user'; + } + trackMessage(messageId, 'part_received', { role: roleInfo }); if (roleInfo === 'user' && partExt.synthetic === true) { - trackMessage(messageId, 'skipped_synthetic_user_part'); - break; + const text = (partExt as { text?: unknown }).text; + if (!shouldKeepSyntheticUserText(text)) { + trackMessage(messageId, 'skipped_synthetic_user_part'); + break; + } } const messagePart: Part = { @@ -1162,7 +1191,8 @@ export const useEventStream = () => { const textStr = typeof text === 'string' ? text.trim() : ''; const shouldKeep = textStr.startsWith('User has requested to enter plan mode') || - textStr.startsWith('The plan at '); + textStr.startsWith('The plan at ') || + textStr.startsWith('The following tool was executed by the user'); if (!shouldKeep) continue; } @@ -1457,6 +1487,11 @@ export const useEventStream = () => { return; } + const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); + if (requestSession?.parentID && requestSession.parentID === current) { + return; + } + const pending = useSessionStore .getState() .permissions @@ -1514,6 +1549,11 @@ export const useEventStream = () => { return; } + const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID); + if (requestSession?.parentID && requestSession.parentID === current) { + return; + } + const pending = useSessionStore .getState() .questions diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 19e08d05..6b5fd23d 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1036,4 +1036,7 @@ export interface SkillsInstallResponse { installed?: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>; skipped?: Array<{ skillName: string; reason: string }>; error?: SkillsInstallError; + requiresReload?: boolean; + message?: string; + reloadDelayMs?: number; } diff --git a/packages/ui/src/lib/messages/agentMentions.ts b/packages/ui/src/lib/messages/agentMentions.ts index 4ac38b41..42ba488a 100644 --- a/packages/ui/src/lib/messages/agentMentions.ts +++ b/packages/ui/src/lib/messages/agentMentions.ts @@ -29,14 +29,15 @@ export const parseAgentMentions = (rawText: string, agents: Agent[]): ParsedAgen } const nonPrimaryAgents = agents.filter((agent) => agent.mode && agent.mode !== "primary"); - if (nonPrimaryAgents.length === 0 || !rawText.includes("#")) { + if (nonPrimaryAgents.length === 0 || !rawText.includes("@")) { return { sanitizedText: rawText, mention: null }; } let firstMention: ParsedAgentMention | null = null; for (const agent of nonPrimaryAgents) { - const pattern = new RegExp(`#${agent.name}\\b`, "gi"); + const escapedAgentName = agent.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`@${escapedAgentName}\\b`, "gi"); let match: RegExpExecArray | null; while ((match = pattern.exec(rawText)) !== null) { diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index b0522485..14f51baf 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -111,6 +111,14 @@ type AgentPartInputLite = { }; }; +type FileInputLite = { + id?: string; + type: 'file'; + mime: string; + filename?: string; + url: string; +}; + export type DirectorySwitchResult = { success: boolean; restarted: boolean; @@ -139,6 +147,7 @@ class OpencodeService { private scopedClients: Map = new Map(); private sseAbortControllers: Map = new Map(); private currentDirectory: string | undefined = undefined; + private directoryContextQueue: Promise = Promise.resolve(); private globalSseAbortController: AbortController | null = null; private globalSseTask: Promise | null = null; @@ -250,17 +259,27 @@ class OpencodeService { } async withDirectory(directory: string | undefined | null, fn: () => Promise): Promise { - if (directory === undefined || directory === null) { - return fn(); - } + const runWithContext = async (): Promise => { + if (directory === undefined || directory === null) { + return fn(); + } - const previousDirectory = this.currentDirectory; - this.currentDirectory = directory; - try { - return await fn(); - } finally { - this.currentDirectory = previousDirectory; - } + const previousDirectory = this.currentDirectory; + this.currentDirectory = directory; + try { + return await fn(); + } finally { + this.currentDirectory = previousDirectory; + } + }; + + const queuedRun = this.directoryContextQueue.then(runWithContext, runWithContext); + this.directoryContextQueue = queuedRun.then( + () => undefined, + () => undefined, + ); + + return queuedRun; } // Get the raw API client for direct access @@ -571,6 +590,17 @@ class OpencodeService { }; } + private async toNormalizedFilePartInput(file: FileInputLite): Promise { + const normalized = await this.normalizeFilePart(file); + return { + ...(file.id ? { id: file.id } : {}), + type: 'file', + mime: normalized.mime, + filename: normalized.filename, + url: normalized.url, + }; + } + async sendMessage(params: { id: string; providerID: string; @@ -580,24 +610,12 @@ class OpencodeService { prefaceTextSynthetic?: boolean; agent?: string; variant?: string; - files?: Array<{ - id?: string; - type: 'file'; - mime: string; - filename?: string; - url: string; - }>; + files?: Array; /** Additional text/file parts to include (for batch sending queued messages) */ additionalParts?: Array<{ text: string; synthetic?: boolean; - files?: Array<{ - id?: string; - type: 'file'; - mime: string; - filename?: string; - url: string; - }>; + files?: Array; }>; messageId?: string; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; @@ -630,14 +648,7 @@ class OpencodeService { // Add file parts if provided (normalizing MIME types for compatibility) if (params.files && params.files.length > 0) { for (const file of params.files) { - const normalized = await this.normalizeFilePart(file); - const filePart: FilePartInput = { - ...(file.id ? { id: file.id } : {}), - type: 'file', - mime: normalized.mime, - filename: normalized.filename, - url: normalized.url - }; + const filePart = await this.toNormalizedFilePartInput(file); parts.push(filePart); } } @@ -654,14 +665,7 @@ class OpencodeService { } if (additional.files && additional.files.length > 0) { for (const file of additional.files) { - const normalized = await this.normalizeFilePart(file); - const filePart: FilePartInput = { - ...(file.id ? { id: file.id } : {}), - type: 'file', - mime: normalized.mime, - filename: normalized.filename, - url: normalized.url - }; + const filePart = await this.toNormalizedFilePartInput(file); parts.push(filePart); } } @@ -669,12 +673,12 @@ class OpencodeService { } if (params.agentMentions && params.agentMentions.length > 0) { - const [first] = params.agentMentions; - if (first?.name) { + for (const mention of params.agentMentions) { + if (!mention?.name) continue; parts.push({ type: 'agent', - name: first.name, - ...(first.source ? { source: first.source } : {}), + name: mention.name, + ...(mention.source ? { source: mention.source } : {}), }); } } @@ -726,6 +730,66 @@ class OpencodeService { return tempMessageId; } + async sendCommand(params: { + id: string; + providerID: string; + modelID: string; + command: string; + arguments?: string; + agent?: string; + variant?: string; + files?: Array; + messageId?: string; + }): Promise { + const baseTimestamp = Date.now(); + const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`; + + const parts: FilePartInput[] = []; + if (params.files && params.files.length > 0) { + for (const file of params.files) { + parts.push(await this.toNormalizedFilePartInput(file)); + } + } + + const base = this.baseUrl.replace(/\/+$/, ''); + const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/command`); + if (this.currentDirectory) { + url.searchParams.set('directory', this.currentDirectory); + } + + const payload: Record = { + command: params.command, + arguments: params.arguments ?? '', + model: `${params.providerID}/${params.modelID}`, + ...(params.agent ? { agent: params.agent } : {}), + ...(params.variant ? { variant: params.variant } : {}), + ...(parts.length > 0 ? { parts } : {}), + ...(params.messageId ? { messageID: params.messageId } : {}), + }; + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : ''; + throw new Error(`Failed to run command (${response.status})${suffix}`); + } + + return tempMessageId; + } + async abortSession(id: string): Promise { const response = await this.client.session.abort( { diff --git a/packages/ui/src/stores/messageStore.ts b/packages/ui/src/stores/messageStore.ts index 3ab582ef..b50a36af 100644 --- a/packages/ui/src/stores/messageStore.ts +++ b/packages/ui/src/stores/messageStore.ts @@ -354,7 +354,7 @@ interface MessageState { interface MessageActions { loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise; abortCurrentOperation: (currentSessionId?: string) => Promise; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; @@ -579,7 +579,7 @@ export const useMessageStore = create()( }); }, - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => { + sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => { if (!currentSessionId) { throw new Error("No session selected"); } @@ -596,54 +596,47 @@ export const useMessageStore = create()( await executeWithSessionDirectory(sessionId, async () => { try { - let effectiveContent = content; - const isCommand = content.startsWith("/"); - - if (isCommand) { - const spaceIndex = content.indexOf(" "); - const command = spaceIndex === -1 ? content.substring(1) : content.substring(1, spaceIndex); - const commandArgs = spaceIndex === -1 ? "" : content.substring(spaceIndex + 1).trim(); - - const apiClient = opencodeClient.getApiClient(); - const directory = opencodeClient.getDirectory(); - - if (command === "init") { - const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - - await apiClient.session.init({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - messageID: messageId, - providerID, - modelID, - }); - - return; - } - - if (command === "summarize") { - await apiClient.session.summarize({ - sessionID: sessionId, - ...(directory ? { directory } : {}), - providerID, - modelID, - }); - - return; - } - - try { - const commandDetails = await opencodeClient.getCommandDetails(command); - if (commandDetails?.template) { - effectiveContent = commandDetails.template.replace(/\$ARGUMENTS/g, commandArgs); - } else { - effectiveContent = content; - } - } catch (error) { - console.error("Command template resolution failed:", error); - effectiveContent = content; - } - } + const trimmedContent = content.trimStart(); + const commandPayload = (() => { + if (inputMode === 'shell') return null; + if (!trimmedContent.startsWith("/")) return null; + const firstLineEnd = trimmedContent.indexOf("\n"); + const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); + const [commandToken, ...firstLineArgs] = firstLine.split(" "); + const command = commandToken.slice(1).trim(); + if (command.toLowerCase() === "shell") return null; + if (!command) return null; + const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); + const argsFromFirstLine = firstLineArgs.join(" ").trim(); + const args = restOfInput + ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) + : argsFromFirstLine; + return { + command, + arguments: args, + }; + })(); + const shellPayload = (() => { + if (inputMode !== 'shell') return null; + const command = content.trim(); + if (!command.trim()) return null; + return { command }; + })(); + const slashShellPayload = (() => { + if (!trimmedContent.startsWith("/")) return null; + const firstLineEnd = trimmedContent.indexOf("\n"); + const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd); + const [commandToken, ...firstLineArgs] = firstLine.split(" "); + const commandName = commandToken.slice(1).trim().toLowerCase(); + if (commandName !== "shell") return null; + const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1); + const argsFromFirstLine = firstLineArgs.join(" ").trim(); + const command = restOfInput + ? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput) + : argsFromFirstLine; + if (!command.trim()) return null; + return { command }; + })(); set({ lastUsedProvider: { providerID, modelID }, @@ -723,17 +716,51 @@ export const useMessageStore = create()( })), })); - await opencodeClient.sendMessage({ - id: sessionId, - providerID, - modelID, - text: effectiveContent, - agent, - variant, - files: filePayloads.length > 0 ? filePayloads : undefined, - additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, - agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, - }); + const apiClient = opencodeClient.getApiClient(); + const directory = opencodeClient.getDirectory(); + + if (shellPayload || slashShellPayload) { + await apiClient.session.shell({ + sessionID: sessionId, + ...(directory ? { directory } : {}), + ...(agent ? { agent } : {}), + model: { + providerID, + modelID, + }, + command: (shellPayload ?? slashShellPayload)!.command, + }); + } else if (commandPayload && commandPayload.command.toLowerCase() === 'compact') { + await apiClient.session.summarize({ + sessionID: sessionId, + ...(directory ? { directory } : {}), + providerID, + modelID, + }); + } else if (commandPayload) { + await opencodeClient.sendCommand({ + id: sessionId, + providerID, + modelID, + command: commandPayload.command, + arguments: commandPayload.arguments, + agent, + variant, + files: filePayloads.length > 0 ? filePayloads : undefined, + }); + } else { + await opencodeClient.sendMessage({ + id: sessionId, + providerID, + modelID, + text: content, + agent, + variant, + files: filePayloads.length > 0 ? filePayloads : undefined, + additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined, + agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, + }); + } if (filePayloads.length > 0) { try { @@ -1145,7 +1172,8 @@ export const useMessageStore = create()( const incomingText = extractTextFromPart(part).trim(); const shouldKeep = incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at '); + incomingText.startsWith('The plan at ') || + incomingText.startsWith('The following tool was executed by the user'); if (!shouldKeep) { (window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part'); return state; @@ -1226,7 +1254,8 @@ export const useMessageStore = create()( const incomingText = extractTextFromPart(part).trim(); const shouldKeep = incomingText.startsWith('User has requested to enter plan mode') || - incomingText.startsWith('The plan at '); + incomingText.startsWith('The plan at ') || + incomingText.startsWith('The following tool was executed by the user'); if (!shouldKeep) { (window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part'); return state; diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index 6097700a..457d5115 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -222,7 +222,7 @@ export interface SessionStore { unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise; abortCurrentOperation: () => Promise; acknowledgeSessionAbort: (sessionId: string) => void; armAbortPrompt: (durationMs?: number) => number | null; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index d80eb5bf..78df3343 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -335,7 +335,7 @@ export const useSessionStore = create()( get().evictLeastRecentlyUsed(); }, loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit), - sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => { + sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => { const draft = get().newSessionDraft; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; @@ -420,7 +420,7 @@ export const useSessionStore = create()( try { return await useMessageStore .getState() - .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant); + .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode); } catch (error) { setStatus(created.id, 'idle'); throw error; @@ -477,7 +477,7 @@ export const useSessionStore = create()( } try { - return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant); + return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode); } catch (error) { if (currentSessionId) { setStatus(currentSessionId, 'idle'); diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 0c64007b..8bcd1337 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -13,7 +13,7 @@ import type { SkillsCatalogSourceResponse, } from '@/lib/api/types'; -import { useSkillsStore } from '@/stores/useSkillsStore'; +import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { opencodeClient } from '@/lib/opencode/client'; const FALLBACK_SOURCES: SkillsCatalogSource[] = [ @@ -373,8 +373,14 @@ export const useSkillsCatalogStore = create()( return { ok: false, error }; } - // Refresh installed skills list. - void useSkillsStore.getState().loadSkills(); + if (payload.requiresReload) { + await refreshSkillsAfterOpenCodeRestart({ + message: payload.message, + delayMs: payload.reloadDelayMs, + }); + } else { + void useSkillsStore.getState().loadSkills(); + } return payload; } catch (error) { diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 69456259..c4229c42 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -5,6 +5,7 @@ import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/ import { startConfigUpdate, finishConfigUpdate, + updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { getSafeStorage } from "./utils/safeStorage"; @@ -136,6 +137,13 @@ declare global { } const CONFIG_EVENT_SOURCE = "useSkillsStore"; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const MAX_HEALTH_WAIT_MS = 20000; +const FAST_HEALTH_POLL_INTERVAL_MS = 300; +const FAST_HEALTH_POLL_ATTEMPTS = 4; +const SLOW_HEALTH_POLL_BASE_MS = 800; +const SLOW_HEALTH_POLL_INCREMENT_MS = 200; +const SLOW_HEALTH_POLL_MAX_MS = 2000; export const useSkillsStore = create()( devtools( @@ -211,6 +219,7 @@ export const useSkillsStore = create()( createSkill: async (config: SkillConfig) => { startConfigUpdate("Creating skill..."); + let requiresReload = false; try { const skillConfig: Record = { name: config.name, @@ -237,8 +246,16 @@ export const useSkillsStore = create()( throw new Error(message); } - // Skills are just files - no need to reload OpenCode - // Just refresh our local list + const needsReload = payload?.requiresReload ?? false; + if (needsReload) { + requiresReload = true; + await refreshSkillsAfterOpenCodeRestart({ + message: payload?.message, + delayMs: payload?.reloadDelayMs, + }); + return true; + } + const loaded = await get().loadSkills(); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); @@ -247,12 +264,15 @@ export const useSkillsStore = create()( } catch { return false; } finally { - finishConfigUpdate(); + if (!requiresReload) { + finishConfigUpdate(); + } } }, updateSkill: async (name: string, config: Partial) => { startConfigUpdate("Updating skill..."); + let requiresReload = false; try { const skillConfig: Record = {}; @@ -275,8 +295,16 @@ export const useSkillsStore = create()( throw new Error(message); } - // Skills are just files - no need to reload OpenCode - // Just refresh our local list + const needsReload = payload?.requiresReload ?? false; + if (needsReload) { + requiresReload = true; + await refreshSkillsAfterOpenCodeRestart({ + message: payload?.message, + delayMs: payload?.reloadDelayMs, + }); + return true; + } + const loaded = await get().loadSkills(); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); @@ -285,12 +313,15 @@ export const useSkillsStore = create()( } catch { return false; } finally { - finishConfigUpdate(); + if (!requiresReload) { + finishConfigUpdate(); + } } }, deleteSkill: async (name: string) => { startConfigUpdate("Deleting skill..."); + let requiresReload = false; try { const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; @@ -305,8 +336,16 @@ export const useSkillsStore = create()( throw new Error(message); } - // Skills are just files - no need to reload OpenCode - // Just refresh our local list + const needsReload = payload?.requiresReload ?? false; + if (needsReload) { + requiresReload = true; + await refreshSkillsAfterOpenCodeRestart({ + message: payload?.message, + delayMs: payload?.reloadDelayMs, + }); + return true; + } + const loaded = await get().loadSkills(); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); @@ -320,7 +359,9 @@ export const useSkillsStore = create()( } catch { return false; } finally { - finishConfigUpdate(); + if (!requiresReload) { + finishConfigUpdate(); + } } }, @@ -402,6 +443,73 @@ if (typeof window !== "undefined") { window.__zustand_skills_store__ = useSkillsStore; } +async function waitForOpenCodeConnection(delayMs?: number) { + const initialPause = typeof delayMs === "number" && delayMs > 0 + ? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS) + : 0; + + if (initialPause > 0) { + await sleep(initialPause); + } + + const start = Date.now(); + let attempt = 0; + let lastError: unknown = null; + + while (Date.now() - start < MAX_HEALTH_WAIT_MS) { + attempt += 1; + updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`); + + try { + const isHealthy = await opencodeClient.checkHealth(); + if (isHealthy) { + return; + } + lastError = new Error("OpenCode health check reported not ready"); + } catch (error) { + lastError = error; + } + + const elapsed = Date.now() - start; + + const waitMs = + attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200 + ? FAST_HEALTH_POLL_INTERVAL_MS + : Math.min( + SLOW_HEALTH_POLL_BASE_MS + + Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS, + SLOW_HEALTH_POLL_MAX_MS, + ); + + await sleep(waitMs); + } + + throw lastError || new Error("OpenCode did not become ready in time"); +} + +export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) { + try { + updateConfigUpdateMessage(options?.message || "Refreshing skills…"); + } catch { + // ignore + } + + try { + await waitForOpenCodeConnection(options?.delayMs); + updateConfigUpdateMessage("Refreshing skills…"); + const skillsStore = useSkillsStore.getState(); + const loaded = await skillsStore.loadSkills(); + if (loaded) { + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + } + } catch { + updateConfigUpdateMessage("OpenCode refresh failed. Please retry."); + await sleep(1500); + } finally { + finishConfigUpdate(); + } +} + // Subscribe to config changes from other stores let unsubscribeSkillsConfigChanges: (() => void) | null = null; diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index c789ece7..7ec10478 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2004,45 +2004,48 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, - requiresReload: false, - message: `Skill ${skillName} created successfully`, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'PATCH') { updateSkill(skillName, (body || {}) as Record, workingDirectory); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, - requiresReload: false, - message: `Skill ${skillName} updated successfully`, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } if (normalizedMethod === 'DELETE') { deleteSkill(skillName, workingDirectory); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await ctx?.manager?.restart(); return { id, type, success: true, data: { success: true, - requiresReload: false, - message: `Skill ${skillName} deleted successfully`, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }, }; } @@ -2117,6 +2120,30 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo conflictDecisions: body.conflictDecisions, }); + if (data.ok) { + const installed = data.installed || []; + const skipped = data.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await ctx?.manager?.restart(); + } + + return { + id, + type, + success: true, + data: { + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, + }, + }; + } + return { id, type, success: true, data }; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 3c8a5b2a..95e8ecbc 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -7297,7 +7297,22 @@ async function main(options = {}) { return res.status(400).json({ ok: false, error: result.error }); } - return res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] }); + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + return res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, + }); } // Handle GitHub sources (git clone based) @@ -7334,7 +7349,22 @@ async function main(options = {}) { return res.status(400).json({ ok: false, error: result.error }); } - res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] }); + const installed = result.installed || []; + const skipped = result.skipped || []; + const requiresReload = installed.length > 0; + + if (requiresReload) { + await refreshOpenCodeAfterConfigChange('skills install'); + } + + res.json({ + ok: true, + installed, + skipped, + requiresReload, + message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', + reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined, + }); } catch (error) { console.error('Failed to install skills:', error); res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } }); @@ -7409,12 +7439,13 @@ async function main(options = {}) { console.log('[Server] Scope:', scope, 'Working directory:', directory); createSkill(skillName, { ...config, source: skillSource }, directory, scope); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await refreshOpenCodeAfterConfigChange('skill creation'); res.json({ success: true, - requiresReload: false, - message: `Skill ${skillName} created successfully`, + requiresReload: true, + message: `Skill ${skillName} created successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }); } catch (error) { console.error('Failed to create skill:', error); @@ -7436,12 +7467,13 @@ async function main(options = {}) { console.log('[Server] Working directory:', directory); updateSkill(skillName, updates, directory); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await refreshOpenCodeAfterConfigChange('skill update'); res.json({ success: true, - requiresReload: false, - message: `Skill ${skillName} updated successfully`, + requiresReload: true, + message: `Skill ${skillName} updated successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }); } catch (error) { console.error('[Server] Failed to update skill:', error); @@ -7518,12 +7550,13 @@ async function main(options = {}) { } deleteSkill(skillName, directory); - // Skills are just files - OpenCode loads them on-demand, no restart needed + await refreshOpenCodeAfterConfigChange('skill deletion'); res.json({ success: true, - requiresReload: false, - message: `Skill ${skillName} deleted successfully`, + requiresReload: true, + message: `Skill ${skillName} deleted successfully. Reloading interface…`, + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, }); } catch (error) { console.error('Failed to delete skill:', error);