Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
SessionInfo,
SessionMessageInfo,
SessionRevert,
SkillInfo,
} from '@opencode-manager/shared/opencode'
import type { SessionSnapshot } from '@/lib/session-projection'

Expand Down Expand Up @@ -273,6 +274,11 @@ export async function listCommands(directory?: string): Promise<CommandInfo[]> {
return data
}

export async function listSkills(directory?: string): Promise<SkillInfo[]> {
const { data } = await callOpenCode((api) => api.skill.list(openCodeLocation(directory)))
return data
}

export async function listPendingPermissions(directory: string): Promise<PermissionRequest[]> {
const { data } = await callOpenCode((api) => api.permission.request.list(openCodeLocation(directory)))
return data
Expand Down
91 changes: 0 additions & 91 deletions frontend/src/components/command/CommandSuggestions.tsx

This file was deleted.

96 changes: 0 additions & 96 deletions frontend/src/components/message/MentionSuggestions.tsx

This file was deleted.

138 changes: 130 additions & 8 deletions frontend/src/components/message/PromptInput.command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 {
Expand All @@ -50,6 +53,7 @@ vi.mock('@/hooks/useOpenCode', async () => {
return {
...actual,
useAgents: () => ({ data: mocks.agents }),
useSkills: () => ({ data: mocks.skills }),
}
})

Expand Down Expand Up @@ -99,12 +103,18 @@ vi.mock('@/components/ui/session-status-indicator', () => ({
SessionStatusIndicator: () => <div>SessionStatus</div>,
}))

vi.mock('@/components/command/CommandSuggestions', () => ({
CommandSuggestions: () => <div>CommandSuggestions</div>,
vi.mock('./ComposerToolsMenu', () => ({
ComposerToolsMenu: () => null,
}))

vi.mock('./MentionSuggestions', () => ({
MentionSuggestions: () => <div>MentionSuggestions</div>,
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({
Expand Down Expand Up @@ -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({
Expand All @@ -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()
})
Expand Down Expand Up @@ -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()
})
})
})

Loading