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
14 changes: 13 additions & 1 deletion backend/src/services/assistant-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
fileExists,
ensureDirectoryExists,
} from './file-operations'
import { ASSISTANT_NOTIFICATION_LIMITS, OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
import { ASSISTANT_NOTIFICATION_LIMITS, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@opencode-manager/shared/schemas'
import { MANAGER_COLOR_THEME_ID, OPENCODE_THEMES } from '@opencode-manager/shared/themes'
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME } from '@opencode-manager/shared/utils'
import { getAssistantModePath, getReposPath } from '@opencode-manager/shared/config/env'
Expand Down Expand Up @@ -699,6 +699,18 @@ Partial object with any of the allowed keys.
**Response:**
Returns the updated settings object with the same structure as GET.

### Keyboard Shortcut Preferences

\`keyboardShortcuts\`, \`leaderKey\`, and \`directShortcuts\` control keyboard bindings.

**Actions:** ${Object.keys(DEFAULT_KEYBOARD_SHORTCUTS).join(', ')}

**Key format:** modifiers joined with \`+\`, using \`Ctrl\`, \`Cmd\`, \`Alt\`, and \`Shift\` (for example \`Ctrl+Alt+U\` or \`Shift+Tab\`). \`Cmd\` is the Command key on macOS and Ctrl on other platforms. An empty string unbinds the action.

**Leader vs direct:** by default a binding is a leader binding, pressed after the \`leaderKey\` (default \`${DEFAULT_LEADER_KEY}\`). List an action in \`directShortcuts\` to fire it without the leader key.

**Sending changes:** \`keyboardShortcuts\` and \`directShortcuts\` are replaced wholesale. Read the current \`keyboardShortcuts\` (and \`directShortcuts\` when changing binding kinds) from GET /settings, modify only the actions you need, and send the complete map or list back; actions absent from the stored map use their built-in defaults.

### POST /assistant/reload

Reload the OpenCode configuration without restarting the server, rebuilding every loaded location. OpenCode re-reads \`opencode.json\`/\`opencode.jsonc\`, agents, commands, skills, plugins, and \`AGENTS.md\`; running sessions, including this one, keep running. Use this after editing \`.opencode/agents/assistant.md\` or other workspace files so changes take effect on the next message. Returns \`400\` with \`validationIssues\` when the OpenCode configuration is invalid.
Expand Down
40 changes: 25 additions & 15 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar'
import { TTSProvider } from './contexts/TTSContext'
import { ThemeProvider, useThemeMode } from './contexts/ThemeContext'
import { AuthProvider } from './contexts/AuthContext'
import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext'
import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext'
import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext'
import { PermissionRequestDialog } from './components/session/PermissionRequestDialog'
Expand All @@ -34,6 +35,7 @@ import { onNotificationClick } from '@/lib/serviceWorker'
import { useAuth } from '@/hooks/useAuth'
import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast'
import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions'
import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions'
import { RestartServerDialog } from '@/components/settings/RestartServerDialog'

const queryClient = new QueryClient({
Expand Down Expand Up @@ -116,6 +118,11 @@ function PermissionDialogWrapper() {
)
}

function GlobalShortcuts() {
useGlobalShortcutActions()
return null
}

function AppShell() {
const navigate = useNavigate()
const location = useLocation()
Expand Down Expand Up @@ -188,21 +195,24 @@ function AppShell() {
<ThemeProvider>
<AuthProvider>
<EventProvider>
<div ref={rootRef} className="flex h-dvh w-full min-w-0">
<DesktopSidebar />
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
<Outlet />
</main>
</div>
<MobileTabBar />
<MobileSheetHost />
<PermissionDialogWrapper />
<SSHHostKeyDialogWrapper />
<SettingsDialog />
<HealthMonitor />
<VersionNotifier />
<PwaUpdatePrompt />
<ThemedToaster />
<KeyboardShortcutsProvider>
<GlobalShortcuts />
<div ref={rootRef} className="flex h-dvh w-full min-w-0">
<DesktopSidebar />
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
<Outlet />
</main>
</div>
<MobileTabBar />
<MobileSheetHost />
<PermissionDialogWrapper />
<SSHHostKeyDialogWrapper />
<SettingsDialog />
<HealthMonitor />
<VersionNotifier />
<PwaUpdatePrompt />
<ThemedToaster />
</KeyboardShortcutsProvider>
</EventProvider>
</AuthProvider>
</ThemeProvider>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/api/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DEFAULT_KEYBOARD_SHORTCUTS,
DEFAULT_USER_PREFERENCES,
DEFAULT_LEADER_KEY,
DEFAULT_DIRECT_SHORTCUTS,
BLOCKED_SERVER_ENV_KEYS,
selectPreferredOpenCodeConfigSourceName,
type TTSConfig,
Expand All @@ -27,8 +28,8 @@ import type { NotificationPreferences } from '@opencode-manager/shared/types'
import { saveFile } from '@/lib/download'

export type { TTSConfig, STTConfig, OpenCodeConfigFile, OpenCodeConfigSourceFile, OpenCodeConfigSourceName, UpdateOpenCodeConfigRequest, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse }
export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity, GitIdentityProfile } from '@opencode-manager/shared'
export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, BLOCKED_SERVER_ENV_KEYS }
export type { UserPreferences, UpdateSettingsRequest, CustomCommand, GitCredential, GitIdentity, GitIdentityProfile, KeyboardShortcutAction } from '@opencode-manager/shared'
export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, DEFAULT_DIRECT_SHORTCUTS, BLOCKED_SERVER_ENV_KEYS }
export { isOpenCodeConfigSourceName } from '@opencode-manager/shared'

export function getOpenCodeConfigSources(config: OpenCodeConfigFile): OpenCodeConfigSourceFile[] {
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/agent/AgentQuickSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export function AgentQuickSelect({

const buttonContent = (
<button
data-toggle-mode
style={styleVars as React.CSSProperties}
className="px-2 md:px-3.5 py-1 h-[36px] rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color)] bg-[var(--agent-bg)] border-[var(--agent-border)] hover:bg-[var(--agent-bg-hover)] hover:border-[var(--agent-border-hover)] shadow-[var(--agent-shadow)] hover:shadow-[var(--agent-shadow-hover)]"
>
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/message/PromptInput.command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,47 @@ describe('PromptInput command submission', () => {
expect(screen.getByTestId('model-quick-select')).toHaveAttribute('data-open', 'true')
})

it('resets bash mode through the imperative clearPrompt', async () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['opencode', 'session', 'test-session', '/test'], sessionInfo())
const ref = createRef<PromptInputHandle>()

render(
<QueryClientProvider client={queryClient}>
<PromptInput {...defaultProps} ref={ref} />
</QueryClientProvider>
)

const input = await screen.findByPlaceholderText('Send a message...')
fireEvent.change(input, { target: { value: '!' } })
expect(input).toHaveValue('!')
expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH')

act(() => {
ref.current?.clearPrompt()
})

expect(input).toHaveValue('')
expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument()
})

it('clears attachments and exits bash mode on Escape while idle', async () => {
const { container } = renderComponent()

const input = await screen.findByPlaceholderText('Send a message...')
fireEvent.change(input, { target: { value: '!' } })
expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH')

await attachImage(container)
expect(screen.getByText('pic.png')).toBeInTheDocument()

fireEvent.keyDown(input, { key: 'Escape' })

expect(input).toHaveValue('')
expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument()
expect(screen.queryByText('pic.png')).not.toBeInTheDocument()
})

it('keeps attachments and clears only the text when a built-in command is submitted', async () => {
const { container } = renderComponent()

Expand Down
96 changes: 57 additions & 39 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode'
import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, type MentionItem } from '@/lib/promptParser'
import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch'
import { getNextPrimaryAgentId } from '@/lib/primaryAgents'
import { getNextFavoriteModel } from '@/lib/favoriteModels'
import { randomId } from '@/lib/utils'
import { showToast } from '@/lib/toast'
import { findModelInfo } from '@opencode-manager/shared/opencode'
import { findModelInfo, isSameModelSelection } from '@opencode-manager/shared/opencode'
import { isOpenSessionGoal } from '@opencode-manager/shared/schemas'
import { useProviders } from '@/hooks/useProviders'

Expand Down Expand Up @@ -110,6 +111,9 @@ export interface PromptInputHandle {
clearPrompt: () => void
triggerFileUpload: () => void
openModelPicker: () => void
cycleAgent: () => void
cycleVariant: () => void
cycleFavoriteModel: () => void
}

interface PromptInputProps {
Expand Down Expand Up @@ -243,29 +247,6 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
fileInputRef.current?.click()
}, [])

useImperativeHandle(ref, () => ({
setPromptValue: (value: string) => {
setPrompt(value)
textareaRef.current?.focus()
},
clearPrompt: () => {
setPrompt('')
setAttachedFiles(new Map())
revokeBlobUrls(imageAttachments)
setImageAttachments([])
resetVoiceGestureState()
if (isRecording) {
abortRecording()
} else {
clearSTT()
}
textareaRef.current?.focus()
},
triggerFileUpload: openFilePicker,
openModelPicker: () => {
setIsModelPickerOpen(true)
}
}), [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState, openFilePicker])
const sessionAgent = useSessionAgent(sessionID, directory)
const currentMode = localMode ?? sessionAgent.agent
const setStoredAgent = useSessionAgentStore((s) => s.setAgent)
Expand Down Expand Up @@ -1009,8 +990,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (isBashMode && e.key === 'Escape') {
e.preventDefault()
setIsBashMode(false)
setPrompt('')
resetPrompt()
return
}

Expand Down Expand Up @@ -1090,16 +1070,8 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
textareaRef.current?.blur()
}
handleSubmit()
} else if (e.key === 'Escape') {
closeSuggestions()
setPrompt('')
revokeBlobUrls(imageAttachments)
setImageAttachments([])
resetVoiceGestureState()
clearSTT()
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 't') {
e.preventDefault()
handleCycleVariant()
} else if (e.key === 'Escape' && !isSessionActive) {
resetPrompt()
}
}

Expand All @@ -1125,13 +1097,28 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
updateSuggestionTriggers(value, e.target.selectionStart)
}

const closeSuggestions = () => {
const closeSuggestions = useCallback(() => {
setShowSuggestions(false)
setSuggestionQuery('')
setShowMentionSuggestions(false)
setMentionQuery('')
setMentionRange(null)
}
}, [])

const resetPrompt = useCallback(() => {
setPrompt('')
setAttachedFiles(new Map())
revokeBlobUrls(imageAttachments)
setImageAttachments([])
closeSuggestions()
setIsBashMode(false)
resetVoiceGestureState()
if (isRecording) {
abortRecording()
} else {
clearSTT()
}
}, [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState, closeSuggestions])

const updateSuggestionTriggers = (value: string, cursorPosition: number) => {
const mentionTrigger = detectMentionTrigger(value, cursorPosition)
Expand Down Expand Up @@ -1244,7 +1231,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
[sessionID, sessionAgent.sessionAgentId, sessionAgent.modelRef],
)

const { model, modelString, modelRef, setActiveAgent, isModelReady } = useModelSelection(directory, modelSelectionSession)
const { model, modelString, modelRef, favoriteModels, setModel, setActiveAgent, isModelReady } = useModelSelection(directory, modelSelectionSession)

useEffect(() => {
setActiveAgent({
Expand Down Expand Up @@ -1281,6 +1268,18 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
cycleVariant()
}, [hasVariants, cycleVariant])

const handleCycleFavoriteModel = useCallback(() => {
const next = getNextFavoriteModel(favoriteModels, model)
if (!next) {
showToast.info('No favorite models')
return
}
if (model && isSameModelSelection(model, next)) {
return
}
setModel(next)
}, [favoriteModels, model, setModel])

const handleCycleAgent = useCallback(() => {
const next = getNextPrimaryAgentId(agents, currentMode)
if (!next) {
Expand All @@ -1290,6 +1289,24 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
handleAgentChange(next)
}, [agents, currentMode, handleAgentChange])

useImperativeHandle(ref, () => ({
setPromptValue: (value: string) => {
setPrompt(value)
textareaRef.current?.focus()
},
clearPrompt: () => {
resetPrompt()
textareaRef.current?.focus()
},
triggerFileUpload: openFilePicker,
openModelPicker: () => {
setIsModelPickerOpen(true)
},
cycleAgent: handleCycleAgent,
cycleVariant: handleCycleVariant,
cycleFavoriteModel: handleCycleFavoriteModel,
}), [resetPrompt, openFilePicker, handleCycleAgent, handleCycleVariant, handleCycleFavoriteModel])

const commandActionsWithPrompt = useMemo<CommandActions>(
() => ({
...commandActions,
Expand Down Expand Up @@ -1466,6 +1483,7 @@ return (
)}
<textarea
ref={textareaRef}
data-prompt-input
value={prompt}
onChange={handleInput}
onFocus={handlePromptFocus}
Expand Down
Loading