diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 17210119e..163fa95fc 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -13,6 +13,7 @@ import type { SessionInfo, SessionMessageInfo, SessionRevert, + SkillInfo, } from '@opencode-manager/shared/opencode' import type { SessionSnapshot } from '@/lib/session-projection' @@ -273,6 +274,11 @@ export async function listCommands(directory?: string): Promise { return data } +export async function listSkills(directory?: string): Promise { + const { data } = await callOpenCode((api) => api.skill.list(openCodeLocation(directory))) + return data +} + export async function listPendingPermissions(directory: string): Promise { const { data } = await callOpenCode((api) => api.permission.request.list(openCodeLocation(directory))) return data diff --git a/frontend/src/components/command/CommandSuggestions.tsx b/frontend/src/components/command/CommandSuggestions.tsx deleted file mode 100644 index 1a5bf4927..000000000 --- a/frontend/src/components/command/CommandSuggestions.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useRef } from 'react' -import { Command } from 'lucide-react' -import type { CommandInfo } from '@opencode-manager/shared/opencode' -import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' - -interface CommandSuggestionsProps { - isOpen: boolean - query: string - commands: CommandInfo[] - onSelect: (command: CommandInfo) => void - onClose: () => void - selectedIndex?: number -} - -export function CommandSuggestions({ - isOpen, - query, - commands, - onSelect, - onClose, - selectedIndex = 0 -}: CommandSuggestionsProps) { - const listRef = useRef(null) - const touchTapSelect = useTouchTapSelect(onSelect) - - const filteredCommands = commands.filter(command => - command.name.toLowerCase().includes(query.toLowerCase()) - ) - - useEffect(() => { - if (!isOpen) return - - const handleClickOutside = (e: MouseEvent) => { - if (listRef.current && !listRef.current.contains(e.target as Node)) { - onClose() - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, onClose]) - - useEffect(() => { - if (!isOpen || !listRef.current) return - - const selectedItem = listRef.current.children[selectedIndex] as HTMLElement - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest' }) - } - }, [selectedIndex, isOpen]) - - if (!isOpen || filteredCommands.length === 0) { - return null - } - - return ( -
- {filteredCommands.map((command, index) => { - const isSelected = index === selectedIndex - const displayName = `/${command.name}` - - return ( - - ) - })} -
- ) -} diff --git a/frontend/src/components/message/MentionSuggestions.tsx b/frontend/src/components/message/MentionSuggestions.tsx deleted file mode 100644 index e3ce2c308..000000000 --- a/frontend/src/components/message/MentionSuggestions.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useEffect, useRef } from 'react' -import { Bot, FileText } from 'lucide-react' -import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' - -export interface MentionItem { - type: 'file' | 'agent' - value: string - label: string - description?: string -} - -interface MentionSuggestionsProps { - isOpen: boolean - items: MentionItem[] - onSelect: (item: MentionItem) => void - onClose: () => void - selectedIndex?: number -} - -export function MentionSuggestions({ - isOpen, - items, - onSelect, - onClose, - selectedIndex = 0 -}: MentionSuggestionsProps) { - const listRef = useRef(null) - const touchTapSelect = useTouchTapSelect(onSelect) - - useEffect(() => { - if (!isOpen) return - - const handleClickOutside = (e: MouseEvent) => { - if (listRef.current && !listRef.current.contains(e.target as Node)) { - onClose() - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [isOpen, onClose]) - - useEffect(() => { - if (!isOpen || !listRef.current) return - - const selectedItem = listRef.current.children[selectedIndex] as HTMLElement - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest' }) - } - }, [selectedIndex, isOpen]) - - if (!isOpen || items.length === 0) return null - - const getFilename = (path: string) => path.split('/').pop() || path - const getDirectory = (path: string) => { - const parts = path.split('/') - return parts.slice(0, -1).join('/') || '.' - } - - return ( -
- {items.map((item, idx) => ( - - ))} -
- ) -} diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index 9c2deb31e..f52f3c36a 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ switchSessionModel: vi.fn(), switchSessionAgent: vi.fn(), agents: [] as Array<{ id: string; name: string; description?: string; mode?: string; hidden?: boolean }>, + skills: [] as Array<{ id: string; name: string; description?: string }>, setAgent: vi.fn(), cycleVariant: vi.fn(), showToast: { @@ -35,6 +36,8 @@ const mocks = vi.hoisted(() => ({ useSendErrorStore: vi.fn(), })) +const suggestionProps = vi.hoisted(() => ({ isOpen: false, selectedIndex: 0 })) + vi.mock('@/api/opencode', async () => { const actual = await vi.importActual('@/api/opencode') return { @@ -50,6 +53,7 @@ vi.mock('@/hooks/useOpenCode', async () => { return { ...actual, useAgents: () => ({ data: mocks.agents }), + useSkills: () => ({ data: mocks.skills }), } }) @@ -99,12 +103,18 @@ vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) -vi.mock('@/components/command/CommandSuggestions', () => ({ - CommandSuggestions: () =>
CommandSuggestions
, +vi.mock('./ComposerToolsMenu', () => ({ + ComposerToolsMenu: () => null, })) -vi.mock('./MentionSuggestions', () => ({ - MentionSuggestions: () =>
MentionSuggestions
, +vi.mock('./PromptSuggestions', () => ({ + PromptSuggestions: (props: { isOpen: boolean; selectedIndex: number }) => { + if (props.isOpen) { + suggestionProps.isOpen = true + suggestionProps.selectedIndex = props.selectedIndex + } + return null + }, })) const createTestQueryClient = () => new QueryClient({ @@ -174,11 +184,12 @@ describe('PromptInput command submission', () => { clear: vi.fn(), }) mocks.useCommands.mockReturnValue({ - filterCommands: (query: string) => { - if (query === 'review') return [{ name: 'review', description: 'Review' }] - const builtin = BUILTIN_COMMANDS.find((command) => command.name === query) - return builtin ? [builtin] : [] + searchCommands: () => [], + findCommand: (name: string) => { + if (name === 'review') return { name: 'review', description: 'Review' } + return BUILTIN_COMMANDS.find((command) => command.name === name) }, + recentNames: [], }) mocks.useFileSearch.mockReturnValue({ files: [] }) mocks.useModelSelection.mockReturnValue({ @@ -196,6 +207,8 @@ describe('PromptInput command submission', () => { mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: mocks.setAgent })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) + suggestionProps.isOpen = false + suggestionProps.selectedIndex = 0 useUIState.getState().clearPendingPromptCommand() useUIState.getState().clearPendingPromptFile() }) @@ -436,4 +449,113 @@ describe('PromptInput command submission', () => { await waitFor(() => expect(input).toHaveValue('')) expect(mocks.showToast.info).not.toHaveBeenCalled() }) + + const commandSuggestions = () => ({ + searchCommands: () => [ + { item: { name: 'alpha', description: 'Alpha' }, ranges: [] }, + { item: { name: 'beta', description: 'Beta' }, ranges: [] }, + { item: { name: 'gamma', description: 'Gamma' }, ranges: [] }, + ], + findCommand: () => undefined, + recentNames: [], + }) + + it('moves the command selection in the visual direction of the arrow on mobile', async () => { + mocks.useMobile.mockReturnValue(true) + mocks.useCommands.mockReturnValue(commandSuggestions()) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '/', selectionStart: 1 } }) + + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + expect(suggestionProps.selectedIndex).toBe(0) + + fireEvent.keyDown(input, { key: 'ArrowUp' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(1)) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(0)) + }) + + it('keeps the desktop command arrow direction unchanged', async () => { + mocks.useCommands.mockReturnValue(commandSuggestions()) + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '/', selectionStart: 1 } }) + + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + expect(suggestionProps.selectedIndex).toBe(0) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(1)) + + fireEvent.keyDown(input, { key: 'ArrowUp' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(0)) + }) + + describe('mobile keybar', () => { + beforeEach(() => { + mocks.useMobile.mockReturnValue(true) + }) + + it('shows only while the prompt is focused on mobile', async () => { + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + expect(screen.queryByRole('toolbar', { name: 'Insert' })).not.toBeInTheDocument() + + fireEvent.focus(input) + expect(screen.getByRole('toolbar', { name: 'Insert' })).toBeInTheDocument() + }) + + it('is not shown on desktop', async () => { + mocks.useMobile.mockReturnValue(false) + renderComponent() + + fireEvent.focus(await screen.findByPlaceholderText('Send a message...')) + expect(screen.queryByRole('toolbar', { name: 'Insert' })).not.toBeInTheDocument() + }) + + it('inserts a separated trigger at the cursor', async () => { + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') as HTMLTextAreaElement + fireEvent.change(input, { target: { value: 'hello world' } }) + fireEvent.focus(input) + input.setSelectionRange(5, 5) + + fireEvent.click(screen.getByRole('button', { name: 'mention @' })) + expect(input).toHaveValue('hello @ world') + + fireEvent.change(input, { target: { value: 'hello' } }) + input.setSelectionRange(5, 5) + fireEvent.click(screen.getByRole('button', { name: 'cmd /' })) + expect(input).toHaveValue('hello /') + }) + + it('enables bash only for an empty prompt and toggles bash mode', async () => { + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'text' } }) + expect(screen.getByRole('button', { name: 'bash !' })).toBeDisabled() + + fireEvent.change(input, { target: { value: '' } }) + const bashKey = screen.getByRole('button', { name: 'bash !' }) + expect(bashKey).toBeEnabled() + + fireEvent.click(bashKey) + expect(input).toHaveValue('!') + expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH') + expect(screen.getByRole('button', { name: 'cmd /' })).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'bash !' })) + expect(input).toHaveValue('') + expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument() + }) + }) }) + diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index e3162bd79..6268cdb56 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ useSessionGoal: vi.fn(), useSessionPermissionMode: vi.fn(), agents: [] as Array<{ id: string; name: string; description?: string; mode?: string; hidden?: boolean }>, + skills: [] as Array<{ id: string; name: string; description?: string }>, setAgent: vi.fn(), cycleVariant: vi.fn(), showToast: { @@ -44,6 +45,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { useSendShell: () => ({ mutate: mocks.sendShell, isPending: false }), useInterruptSession: () => ({ mutate: mocks.interrupt }), useAgents: () => ({ data: mocks.agents }), + useSkills: () => ({ data: mocks.skills }), } }) @@ -121,12 +123,8 @@ vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) -vi.mock('@/components/command/CommandSuggestions', () => ({ - CommandSuggestions: () =>
CommandSuggestions
, -})) - -vi.mock('./MentionSuggestions', () => ({ - MentionSuggestions: () =>
MentionSuggestions
, +vi.mock('./PromptSuggestions', () => ({ + PromptSuggestions: () => null, })) const createTestQueryClient = () => new QueryClient({ @@ -179,7 +177,7 @@ describe('PromptInput goal mode', () => { abortRecording: vi.fn(), clear: vi.fn(), }) - mocks.useCommands.mockReturnValue({ filterCommands: () => [] }) + mocks.useCommands.mockReturnValue({ searchCommands: () => [], findCommand: () => undefined, recentNames: [] }) mocks.useFileSearch.mockReturnValue({ files: [] }) mocks.useModelSelection.mockReturnValue({ model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, @@ -298,7 +296,11 @@ describe('PromptInput goal mode', () => { it('does not start an armed goal for a slash command', async () => { stubMatchMedia(true) - mocks.useCommands.mockReturnValue({ filterCommands: () => [{ name: 'review' }] }) + mocks.useCommands.mockReturnValue({ + searchCommands: () => [], + findCommand: (name: string) => (name === 'review' ? { name: 'review' } : undefined), + recentNames: [], + }) renderComponent({ isStreamingResponse: true }) const input = await screen.findByPlaceholderText('Send a message...') diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index 72ec29c9f..b348fd4e1 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ switchSessionAgent: vi.fn(), runCommand: vi.fn(), agents: [] as Array<{ name: string; description?: string }>, + skills: [] as Array<{ id: string; name: string; description?: string }>, useSTT: vi.fn(), useMobile: vi.fn(), useCommands: vi.fn(), @@ -23,6 +24,8 @@ const mocks = vi.hoisted(() => ({ useSendErrorStore: vi.fn(), })) +const suggestionProps = vi.hoisted(() => ({ isOpen: false, selectedIndex: 0 })) + vi.mock('@/api/opencode', async () => { const actual = await vi.importActual('@/api/opencode') return { @@ -39,6 +42,7 @@ vi.mock('@/hooks/useOpenCode', async () => { return { ...actual, useAgents: () => ({ data: mocks.agents }), + useSkills: () => ({ data: mocks.skills }), } }) @@ -90,12 +94,18 @@ vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) -vi.mock('@/components/command/CommandSuggestions', () => ({ - CommandSuggestions: () =>
CommandSuggestions
, +vi.mock('./PromptSuggestions', () => ({ + PromptSuggestions: (props: { isOpen: boolean; selectedIndex: number }) => { + if (props.isOpen) { + suggestionProps.isOpen = true + suggestionProps.selectedIndex = props.selectedIndex + } + return null + }, })) -vi.mock('./MentionSuggestions', () => ({ - MentionSuggestions: () =>
MentionSuggestions
, +vi.mock('./ComposerToolsMenu', () => ({ + ComposerToolsMenu: () => null, })) const createTestQueryClient = () => new QueryClient({ @@ -151,6 +161,7 @@ describe('PromptInput agent mention submission', () => { mocks.switchSessionAgent.mockResolvedValue(undefined) mocks.runCommand.mockResolvedValue(undefined) mocks.agents = [{ name: 'reviewer', description: 'Reviewer' }] + mocks.skills = [{ id: 'pr-review', name: 'PR Review', description: 'Review a PR' }] mocks.useMobile.mockReturnValue(false) mocks.useSTT.mockReturnValue({ isRecording: false, @@ -165,7 +176,7 @@ describe('PromptInput agent mention submission', () => { reset: vi.fn(), clear: vi.fn(), }) - mocks.useCommands.mockReturnValue({ filterCommands: () => [] }) + mocks.useCommands.mockReturnValue({ searchCommands: () => [], findCommand: () => undefined, recentNames: [] }) mocks.useFileSearch.mockReturnValue({ files: [] }) mocks.useModelSelection.mockReturnValue({ model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, @@ -181,6 +192,8 @@ describe('PromptInput agent mention submission', () => { mocks.useSessionAgent.mockReturnValue({ agent: 'build' }) mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) + suggestionProps.isOpen = false + suggestionProps.selectedIndex = 0 useUIState.getState().clearPendingPromptCommand() useUIState.getState().clearPendingPromptFile() }) @@ -221,6 +234,22 @@ describe('PromptInput agent mention submission', () => { expect(mocks.switchSessionAgent).not.toHaveBeenCalled() }) + it('sends an @skill mention as a skill attachment', async () => { + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'use @pr-review and @pr-review' } }) + fireEvent.click(screen.getByTitle('Send')) + + await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalled()) + + expect(mocks.sendPrompt).toHaveBeenCalledWith(expect.objectContaining({ + text: 'use @pr-review and @pr-review', + agents: [], + skills: [{ id: 'pr-review', mention: { start: 4, end: 14, text: '@pr-review' } }], + })) + }) + it('sends plain text without inferring an agent attachment or switching', async () => { renderComponent() @@ -238,4 +267,45 @@ describe('PromptInput agent mention submission', () => { expect(mocks.switchSessionAgent).not.toHaveBeenCalled() expect(mocks.switchSessionModel).not.toHaveBeenCalled() }) + + it('moves the mention selection in the visual direction of the arrow on mobile', async () => { + mocks.useMobile.mockReturnValue(true) + mocks.agents = [ + { name: 'alpha', description: 'Alpha agent' }, + { name: 'beta', description: 'Beta agent' }, + ] + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '@', selectionStart: 1 } }) + + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + expect(suggestionProps.selectedIndex).toBe(0) + + fireEvent.keyDown(input, { key: 'ArrowUp' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(1)) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(0)) + }) + + it('keeps the desktop arrow direction unchanged', async () => { + mocks.agents = [ + { name: 'alpha', description: 'Alpha agent' }, + { name: 'beta', description: 'Beta agent' }, + ] + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '@', selectionStart: 1 } }) + + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + expect(suggestionProps.selectedIndex).toBe(0) + + fireEvent.keyDown(input, { key: 'ArrowUp' }) + expect(suggestionProps.selectedIndex).toBe(0) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + await waitFor(() => expect(suggestionProps.selectedIndex).toBe(1)) + }) }) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index a854df3e7..7db04e553 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -46,6 +46,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ useInterruptSession: () => ({ mutate: vi.fn() }), useSendShell: () => ({ mutate: vi.fn(), isPending: false }), useAgents: () => ({ data: [] }), + useSkills: () => ({ data: [] }), })) vi.mock('@/hooks/useCommands', () => ({ @@ -121,12 +122,8 @@ vi.mock('@/components/ui/session-status-indicator', () => ({ SessionStatusIndicator: () =>
SessionStatus
, })) -vi.mock('@/components/command/CommandSuggestions', () => ({ - CommandSuggestions: () =>
CommandSuggestions
, -})) - -vi.mock('./MentionSuggestions', () => ({ - MentionSuggestions: () =>
MentionSuggestions
, +vi.mock('./PromptSuggestions', () => ({ + PromptSuggestions: () => null, })) interface MockSTTReturn { @@ -191,7 +188,7 @@ describe('PromptInput STT Gesture Tests', () => { clear: mockClear, } as unknown as MockSTTReturn) - mocks.useCommands.mockReturnValue({ filterCommands: vi.fn() }) + mocks.useCommands.mockReturnValue({ searchCommands: () => [], findCommand: vi.fn(), recentNames: [] }) mocks.useCommandHandler.mockReturnValue({ executeCommand: vi.fn() }) mocks.useFileSearch.mockReturnValue({ files: [] }) mocks.useModelSelection.mockReturnValue({ diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 59775e4cb..9fde6ba14 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react' -import { useSendPrompt, useInterruptSession, useSendShell, useAgents } from '@/hooks/useOpenCode' +import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills } from '@/hooks/useOpenCode' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' import { useFileSearch } from '@/hooks/useFileSearch' @@ -12,6 +12,8 @@ import { useUserBash } from '@/stores/userBashStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useUIState } from '@/stores/uiStateStore' import { useSendErrorStore } from '@/stores/sendErrorStore' +import { useRecentCommandsStore } from '@/stores/recentCommandsStore' +import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -21,8 +23,7 @@ import { ArrowDown, Upload, X, Mic, MicOff, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' import { IconToggleButton } from '@/components/ui/icon-toggle-button' -import { CommandSuggestions } from '@/components/command/CommandSuggestions' -import { MentionSuggestions, type MentionItem } from './MentionSuggestions' +import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' @@ -31,7 +32,8 @@ import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { ComposerToolsMenu } from './ComposerToolsMenu' import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' -import { detectMentionTrigger, parsePromptToInput, getFilename, filterAgentsByQuery } from '@/lib/promptParser' +import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, type MentionItem } from '@/lib/promptParser' +import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { randomId } from '@/lib/utils' import { showToast } from '@/lib/toast' @@ -61,6 +63,43 @@ const revokeBlobUrls = (attachments: ImageAttachment[]) => { const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] +const COMMAND_TRIGGER_PATTERN = /(^|\s)\/([a-zA-Z0-9_-]*)$/ +const FILE_FALLBACK_MATCH_TIER = 4 +const KEYBAR_HIDE_DELAY_MS = 250 + +type KeybarKey = 'command' | 'mention' | 'bash' +type ComposerMode = 'command' | 'mention' | 'bash' + +const KEYBAR_KEYS: Array<{ id: KeybarKey, symbol: string, label: string }> = [ + { id: 'command', symbol: '/', label: 'cmd' }, + { id: 'mention', symbol: '@', label: 'mention' }, + { id: 'bash', symbol: '!', label: 'bash' }, +] + +const COMPOSER_MODE_STYLES: Record = { + command: { + label: 'COMMAND', + frame: 'border-primary/70 ring-1 ring-primary/40 shadow-lg shadow-primary/20', + badge: 'bg-primary text-primary-foreground', + key: 'bg-primary text-primary-foreground border-primary', + }, + mention: { + label: 'MENTION', + frame: 'border-success/70 ring-1 ring-success/40 shadow-lg shadow-success/20', + badge: 'bg-success text-success-foreground', + key: 'bg-success text-success-foreground border-success', + }, + bash: { + label: 'BASH', + frame: 'border-warning/70 ring-1 ring-warning/40 shadow-lg shadow-warning/20', + badge: 'bg-warning text-warning-foreground', + key: 'bg-warning text-warning-foreground border-warning', + }, +} + +const shiftRanges = (ranges: MatchRange[], offset: number): MatchRange[] => + ranges.map(([start, end]) => [start + offset, end + offset]) + const VOICE_SEND_SWIPE_ARM_THRESHOLD = 24 const VOICE_SEND_SWIPE_DISARM_THRESHOLD = 8 type VoiceButtonVariant = 'desktop' | 'mobile' @@ -113,6 +152,9 @@ export const PromptInput = memo(forwardRef( const [isVoiceSwipeArmed, setIsVoiceSwipeArmed] = useState(false) const [isVoiceAutoSendPending, setIsVoiceAutoSendPending] = useState(false) const [isVoiceAutoSendWaitingForTranscript, setIsVoiceAutoSendWaitingForTranscript] = useState(false) + const [isPromptFocused, setIsPromptFocused] = useState(false) + const promptBlurTimerRef = useRef | null>(null) + const pendingCommandSubmitRef = useRef(false) const lastAddedTranscriptRef = useRef('') const voiceGestureStartYRef = useRef(null) const voiceSwipeArmedRef = useRef(false) @@ -238,12 +280,11 @@ export const PromptInput = memo(forwardRef( const interruptSession = useInterruptSession() const { data: sessionGoal } = useSessionGoal(sessionID) const { data: permissionMode } = useSessionPermissionMode(sessionID) - const { filterCommands } = useCommands({ directory }) + const { searchCommands, findCommand, recentNames } = useCommands({ directory }) + const recordCommand = useRecentCommandsStore((state) => state.recordCommand) const isExactCommandPrompt = (value: string) => { const commandPrompt = parseCommandPrompt(value) - if (!commandPrompt) return false - const [command] = filterCommands(commandPrompt.name) - return command?.name.toLowerCase() === commandPrompt.name.toLowerCase() + return Boolean(commandPrompt && findCommand(commandPrompt.name)) } const { files: searchResults } = useFileSearch( @@ -253,7 +294,9 @@ export const PromptInput = memo(forwardRef( ) const { data: agents = [] } = useAgents(directory) + const { data: skills = [] } = useSkills(directory) const agentNames = useMemo(() => agents.map((agent) => agent.name), [agents]) + const skillIds = useMemo(() => skills.map((skill) => skill.id), [skills]) const failedPrompt = useSendErrorStore((state) => state.errors[sessionID]?.failedPrompt) const restoredFailedPromptRef = useRef(null) @@ -274,28 +317,72 @@ export const PromptInput = memo(forwardRef( } }, [failedPrompt]) - const mentionItems = useMemo((): MentionItem[] => { - const filteredAgents = filterAgentsByQuery( - agents.map(a => ({ name: a.name, description: a.description })), - mentionQuery - ) - - const agentItems: MentionItem[] = filteredAgents.map(agent => ({ - type: 'agent', - value: agent.name, - label: agent.name, - description: agent.description + const mentionSuggestions = useMemo((): PromptSuggestion[] => { + const agentMatches = rankByMatch(agents, mentionQuery, { + getName: (agent) => agent.name, + getDescription: (agent) => agent.description, + }).map(({ item, tier, ranges }) => ({ + tier, + suggestion: { + key: `agent-${item.name}`, + value: { type: 'agent', value: item.name } satisfies MentionItem, + kind: 'agent' as const, + label: item.name, + ranges, + detail: item.description || 'Agent', + tag: 'agent', + }, })) - - const fileItems: MentionItem[] = searchResults.map(file => ({ - type: 'file', - value: file, - label: getFilename(file), - description: file + + const skillMatches = rankByMatch(skills, mentionQuery, { + getName: (skill) => skill.id, + getDescription: (skill) => skill.description, + }).map(({ item, tier, ranges }) => ({ + tier, + suggestion: { + key: `skill-${item.id}`, + value: { type: 'skill', value: item.id } satisfies MentionItem, + kind: 'skill' as const, + label: item.id, + ranges, + detail: item.description || item.name, + tag: 'skill', + }, })) - - return [...agentItems, ...fileItems] - }, [agents, searchResults, mentionQuery]) + + const fileMatches = searchResults.map((file) => { + const label = getFilename(file) + const match = matchText(mentionQuery, label) + return { + tier: match?.tier ?? FILE_FALLBACK_MATCH_TIER, + suggestion: { + key: `file-${file}`, + value: { type: 'file', value: file } satisfies MentionItem, + kind: 'file' as const, + label, + ranges: match?.ranges ?? [], + detail: getDirectory(file), + tag: 'file', + }, + } + }) + + return [...agentMatches, ...skillMatches, ...fileMatches] + .sort((a, b) => a.tier - b.tier) + .map(({ suggestion }) => suggestion) + }, [agents, skills, searchResults, mentionQuery]) + + const commandSuggestions = useMemo((): PromptSuggestion[] => + searchCommands(suggestionQuery).map(({ item, ranges }) => ({ + key: item.name, + value: item, + kind: 'command', + label: `/${item.name}`, + ranges: shiftRanges(ranges, 1), + detail: item.description, + tag: !suggestionQuery && recentNames.includes(item.name) ? 'recent' : undefined, + })), + [searchCommands, suggestionQuery, recentNames]) const addUserBashCommand = useUserBash((s) => s.addUserBashCommand) @@ -321,7 +408,7 @@ export const PromptInput = memo(forwardRef( if (isStreamingResponse) { onScrollToBottom() - const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, imageAttachments) + const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) const submittedPrompt = prompt const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments @@ -376,10 +463,11 @@ export const PromptInput = memo(forwardRef( const commandPrompt = parseCommandPrompt(prompt) if (commandPrompt) { - const command = filterCommands(commandPrompt.name)[0] + const command = findCommand(commandPrompt.name) if (command) { - const parsed = parsePromptToInput(commandPrompt.args?.trim() || '', attachedFiles, agentNames, imageAttachments) + recordCommand(command.name) + const parsed = parsePromptToInput(commandPrompt.args?.trim() || '', attachedFiles, agentNames, skillIds, imageAttachments) const submittedPrompt = prompt const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments @@ -403,7 +491,7 @@ export const PromptInput = memo(forwardRef( } } - const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, imageAttachments) + const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) const submittedPrompt = prompt const submittedAttachedFiles = attachedFiles const submittedImageAttachments = imageAttachments @@ -451,29 +539,46 @@ export const PromptInput = memo(forwardRef( interruptSession.mutate(sessionID) } - const handleCommandSelect = useCallback(async (command: CommandInfo) => { + const focusPromptAt = useCallback((cursorPosition: number) => { + setTimeout(() => { + if (!textareaRef.current) return + textareaRef.current.focus() + textareaRef.current.setSelectionRange(cursorPosition, cursorPosition) + textareaRef.current.scrollTop = textareaRef.current.scrollHeight + }, 0) + }, []) + + const handleCommandSelect = useCallback((command: CommandInfo, submitAfterInsert = false) => { if (!textareaRef.current) return setShowSuggestions(false) setSuggestionQuery('') const cursorPosition = textareaRef.current.selectionStart - const commandMatch = prompt.slice(0, cursorPosition).match(/(^|\s)\/([a-zA-Z0-9_-]*)$/) + const commandMatch = prompt.slice(0, cursorPosition).match(COMMAND_TRIGGER_PATTERN) - const beforeCommand = commandMatch ? prompt.slice(0, commandMatch.index) : '' + const beforeCommand = commandMatch ? prompt.slice(0, (commandMatch.index ?? 0) + commandMatch[1].length) : '' const afterCommand = commandMatch ? prompt.slice(cursorPosition) : '' const newPrompt = beforeCommand + '/' + command.name + ' ' + afterCommand + if (submitAfterInsert && newPrompt === prompt) { + handleSubmitRef.current() + return + } + + pendingCommandSubmitRef.current = submitAfterInsert setPrompt(newPrompt) + if (!submitAfterInsert) focusPromptAt(beforeCommand.length + command.name.length + 2) + }, [prompt, focusPromptAt]) - setTimeout(() => { - if (textareaRef.current) { - const newCursorPos = beforeCommand.length + command.name.length + 2 - textareaRef.current.focus() - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.current.scrollTop = textareaRef.current.scrollHeight - } - }, 0) + const handleCommandSwipe = useCallback((command: CommandInfo) => { + handleCommandSelect(command, true) + }, [handleCommandSelect]) + + useEffect(() => { + if (!pendingCommandSubmitRef.current) return + pendingCommandSubmitRef.current = false + handleSubmitRef.current() }, [prompt]) useEffect(() => { @@ -505,15 +610,8 @@ export const PromptInput = memo(forwardRef( return next }) - setTimeout(() => { - if (textareaRef.current) { - const newCursorPos = beforeMention.length + filename.length + 2 - textareaRef.current.focus() - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.current.scrollTop = textareaRef.current.scrollHeight - } - }, 0) - }, [directory, mentionRange, prompt]) + focusPromptAt(beforeMention.length + filename.length + 2) + }, [directory, mentionRange, prompt, focusPromptAt]) useEffect(() => { if (!pendingPromptFile) return @@ -527,20 +625,11 @@ export const PromptInput = memo(forwardRef( const beforeMention = prompt.slice(0, mentionRange.start) const afterMention = prompt.slice(mentionRange.end) - if (item.type === 'agent') { - const newPrompt = beforeMention + '@' + item.value + ' ' + afterMention - setPrompt(newPrompt) - - setTimeout(() => { - if (textareaRef.current) { - const newCursorPos = beforeMention.length + item.value.length + 2 - textareaRef.current.focus() - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.current.scrollTop = textareaRef.current.scrollHeight - } - }, 0) - } else { + if (item.type === 'file') { insertFileMention(item.value, mentionRange) + } else { + setPrompt(beforeMention + '@' + item.value + ' ' + afterMention) + focusPromptAt(beforeMention.length + item.value.length + 2) } setShowMentionSuggestions(false) @@ -941,25 +1030,32 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) return } - if (showMentionSuggestions && mentionItems.length > 0) { + const suggestionStep = isMobile ? -1 : 1 + + if (showMentionSuggestions && mentionSuggestions.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault() - setSelectedMentionIndex(prev => - prev < mentionItems.length - 1 ? prev + 1 : prev - ) + setSelectedMentionIndex(prev => { + const next = prev + suggestionStep + return next >= 0 && next < mentionSuggestions.length ? next : prev + }) return } if (e.key === 'ArrowUp') { e.preventDefault() - setSelectedMentionIndex(prev => prev > 0 ? prev - 1 : 0) + setSelectedMentionIndex(prev => { + const next = prev - suggestionStep + return next >= 0 && next < mentionSuggestions.length ? next : prev + }) return } if (e.key === 'Enter') { e.preventDefault() - if (mentionItems[selectedMentionIndex]) { - handleMentionSelect(mentionItems[selectedMentionIndex]) + const selectedMention = mentionSuggestions[selectedMentionIndex] + if (selectedMention) { + handleMentionSelect(selectedMention.value) } return } @@ -974,25 +1070,23 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) } if (showSuggestions) { - const filteredCommands = filterCommands(suggestionQuery) - - if (e.key === 'ArrowDown') { + if (e.key === 'ArrowDown' && commandSuggestions.length > 0) { e.preventDefault() - setSelectedCommandIndex(prev => (prev + 1) % filteredCommands.length) + setSelectedCommandIndex(prev => (prev + suggestionStep + commandSuggestions.length) % commandSuggestions.length) return } - if (e.key === 'ArrowUp') { + if (e.key === 'ArrowUp' && commandSuggestions.length > 0) { e.preventDefault() - setSelectedCommandIndex(prev => (prev - 1 + filteredCommands.length) % filteredCommands.length) + setSelectedCommandIndex(prev => (prev - suggestionStep + commandSuggestions.length) % commandSuggestions.length) return } if (e.key === 'Enter') { e.preventDefault() - const selectedCommand = filteredCommands[selectedCommandIndex] + const selectedCommand = commandSuggestions[selectedCommandIndex] if (selectedCommand) { - handleCommandSelect(selectedCommand) + handleCommandSelect(selectedCommand.value) } return } @@ -1013,11 +1107,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) } handleSubmit() } else if (e.key === 'Escape') { - setShowSuggestions(false) - setSuggestionQuery('') - setShowMentionSuggestions(false) - setMentionQuery('') - setMentionRange(null) + closeSuggestions() setPrompt('') revokeBlobUrls(imageAttachments) setImageAttachments([]) @@ -1048,8 +1138,18 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) return } - const cursorPosition = e.target.selectionStart - + updateSuggestionTriggers(value, e.target.selectionStart) + } + + const closeSuggestions = () => { + setShowSuggestions(false) + setSuggestionQuery('') + setShowMentionSuggestions(false) + setMentionQuery('') + setMentionRange(null) + } + + const updateSuggestionTriggers = (value: string, cursorPosition: number) => { const mentionTrigger = detectMentionTrigger(value, cursorPosition) if (mentionTrigger) { @@ -1057,25 +1157,98 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) setMentionRange({ start: mentionTrigger.start, end: mentionTrigger.end }) setShowMentionSuggestions(true) setSelectedMentionIndex(0) + return + } + + const commandMatch = value.slice(0, cursorPosition).match(COMMAND_TRIGGER_PATTERN) + + if (commandMatch) { + setSuggestionQuery(commandMatch[2]) + setShowSuggestions(true) + setSelectedCommandIndex(0) } else { - const commandMatch = value.slice(0, cursorPosition).match(/(^|\s)\/([a-zA-Z0-9_-]*)$/) - - if (commandMatch) { - const query = commandMatch[2] - setSuggestionQuery(query) - setShowSuggestions(true) - setSelectedCommandIndex(0) - } else { - setShowSuggestions(false) - setSuggestionQuery('') - } - - if (showMentionSuggestions) { - setShowMentionSuggestions(false) - setMentionQuery('') - setMentionRange(null) - } + setShowSuggestions(false) + setSuggestionQuery('') + } + + if (showMentionSuggestions) { + setShowMentionSuggestions(false) + setMentionQuery('') + setMentionRange(null) + } + } + + const insertTriggerAtCursor = (trigger: '/' | '@') => { + const selectionStart = textareaRef.current?.selectionStart ?? prompt.length + const selectionEnd = textareaRef.current?.selectionEnd ?? prompt.length + const before = prompt.slice(0, selectionStart) + const separator = before && !/\s$/.test(before) ? ' ' : '' + const nextPrompt = `${before}${separator}${trigger}${prompt.slice(selectionEnd)}` + const cursorPosition = before.length + separator.length + 1 + + setPrompt(nextPrompt) + updateSuggestionTriggers(nextPrompt, cursorPosition) + focusPromptAt(cursorPosition) + } + + const removeActiveTrigger = () => { + const cursorPosition = textareaRef.current?.selectionStart ?? prompt.length + const commandTokenStart = prompt.slice(0, cursorPosition).search(/\/[a-zA-Z0-9_-]*$/) + const tokenStart = showMentionSuggestions && mentionRange ? mentionRange.start : commandTokenStart + const start = tokenStart >= 0 ? tokenStart : cursorPosition + + setPrompt(prompt.slice(0, start) + prompt.slice(cursorPosition)) + closeSuggestions() + focusPromptAt(start) + } + + const toggleBashMode = () => { + closeSuggestions() + if (isBashMode) { + const nextPrompt = prompt.replace(/^!/, '') + setIsBashMode(false) + setPrompt(nextPrompt) + focusPromptAt(nextPrompt.length) + return + } + setIsBashMode(true) + setPrompt('!') + focusPromptAt(1) + } + + const isBashKeyEnabled = isBashMode || (prompt === '' && imageAttachments.length === 0) + + const handleKeybarKey = (key: KeybarKey) => { + if (key === 'bash') { + if (isBashKeyEnabled) toggleBashMode() + return + } + if (isBashMode) return + const isActive = key === 'command' ? showSuggestions : showMentionSuggestions + if (isActive) { + removeActiveTrigger() + return } + insertTriggerAtCursor(key === 'command' ? '/' : '@') + } + + const keybarTouch = useTouchTapSelect(handleKeybarKey) + + const clearPromptBlurTimer = useCallback(() => { + if (promptBlurTimerRef.current) clearTimeout(promptBlurTimerRef.current) + promptBlurTimerRef.current = null + }, []) + + useEffect(() => clearPromptBlurTimer, [clearPromptBlurTimer]) + + const handlePromptFocus = () => { + clearPromptBlurTimer() + setIsPromptFocused(true) + } + + const handlePromptBlur = () => { + clearPromptBlurTimer() + promptBlurTimerRef.current = setTimeout(() => setIsPromptFocused(false), KEYBAR_HIDE_DELAY_MS) } const { data: providersData } = useProviders(directory) @@ -1149,6 +1322,17 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) currentAgent: currentMode, actions: commandActionsWithPrompt, }) + const isCommandListOpen = showSuggestions && commandSuggestions.length > 0 + const isMentionListOpen = showMentionSuggestions && mentionSuggestions.length > 0 + const composerMode: ComposerMode | null = isBashMode + ? 'bash' + : isMentionListOpen + ? 'mention' + : isCommandListOpen || isExactCommandPrompt(prompt) + ? 'command' + : null + const composerModeStyle = composerMode ? COMPOSER_MODE_STYLES[composerMode] : null + const isTakeoverOpen = isMobile && (isCommandListOpen || isMentionListOpen) const showStopButton = isSessionActive const hideSecondaryButtons = isMobile && isSessionActive const showMobileScrollButton = isMobile && showScrollButton @@ -1287,11 +1471,21 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) return ( -
+
+ {composerModeStyle && ( + + {composerModeStyle.label} + + )}