mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
* feat(mobile): add Quick Commands (terminal + agent-prompt presets)
Brings the desktop Terminal Quick Commands feature to mobile: saved
agent-prompt or terminal-command presets that launch a new terminal tab.
Entry point sits in the session tab strip next to the "+" new-terminal
button (with a divider) — quick commands spawn a tab, so they live with
tab creation, mirroring desktop's tab-bar split button.
- Launcher button + Quick Commands bottom sheet (search, This project /
Global groups, run/edit/delete rows, add row).
- Add/Edit sheet mirroring desktop TerminalQuickCommandDialog: Label,
Action toggle (Terminal Command | Agent Prompt), Agent select, Prompt /
Command Text, Advanced (Append Enter, Scope Global/Project), validation
and save-failure feedback.
- Launch reuses handleCreateTerminal (extended with enter + toast copy):
agent prompts launch the agent then deliver the prompt; terminal
commands run the (Enter-appended) command text.
- Expose terminalQuickCommands over the remote/mobile RPC surface
(getClientSettings/updateClientSettings allowlists, RuntimeStore type,
and the strict SettingsUpdate zod schema).
- Mirror the agent-prompt support predicate mobile-side (stdin-after-start
agents are unsupported) with a parity test guarding drift from desktop.
- Mock server: sample quick commands + settings.update handler for QA.
* fix(mobile): harden quick command execution
* fix(mobile): harden quick command persistence and launch
* test(mobile): preserve unexpected quick command errors
* fix(mobile): harden quick command launch performance
* fix(runtime): reject malformed quick command updates
* refactor(mobile): reuse shared quick-command logic instead of mirroring
The mobile quick-commands mirror was built on a false premise — that
runtime-importing src/shared/terminal-quick-commands breaks the RN bundle
/ Vitest. It doesn't: tui-agent-config → orca-cli-command-name is a pure
leaf with no module-load Node APIs (verified via probe + bundle-graph).
- Mobile now reuses the canonical desktop helpers (action/agent/scope/
matchesRepo/support/flatten) directly from src/shared; only genuinely
mobile-specific pieces (agent-branded labels, native row truncation,
the launch plan) stay local.
- Multiline runnable terminal commands now flatten via the shared
flattenTerminalQuickCommand (";"-join) — unity with desktop, so a
command saved on one runs identically on the other.
- Drop the MOBILE_TUI_AGENT_PROMPT_COMMAND_UNSUPPORTED mirror + its parity
test; use the shared supportsTerminalAgentQuickCommand predicate.
- Export the shared MAX_QUICK_COMMAND_* length caps for reuse.
* fix(mobile): protect quick command data boundaries
* fix(mobile): enforce quick command limits
* fix(mobile): make quick command updates atomic
* fix(mobile): keep quick command filters recoverable
* fix(mobile): use filled play icon for quick commands
* Revert "fix(mobile): use filled play icon for quick commands"
This reverts commit 169bf053b0.
* fix(mobile): gate quick commands on host capability
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
|
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
|
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
|
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
|
import {
|
|
getQuickCommandAgentLabel,
|
|
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
|
MAX_QUICK_COMMAND_LABEL_LENGTH,
|
|
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
|
|
} from '../terminal/quick-commands'
|
|
import type { QuickCommandDraft } from './quick-command-draft'
|
|
import { isQuickCommandDraftValid } from './quick-command-draft'
|
|
|
|
type Props = {
|
|
draft: QuickCommandDraft
|
|
mode: 'add' | 'edit'
|
|
saving: boolean
|
|
error: string | null
|
|
// A mobile session lives in one repo, so "Project" scope means the current
|
|
// worktree's repo — no cross-repo picker like desktop.
|
|
repoId: string | null
|
|
repoName: string | null
|
|
onChange: (patch: Partial<QuickCommandDraft>) => void
|
|
onOpenAgentPicker: () => void
|
|
onCancel: () => void
|
|
onSave: () => void
|
|
}
|
|
|
|
function ActionToggle({
|
|
value,
|
|
onChange
|
|
}: {
|
|
value: QuickCommandDraft['action']
|
|
onChange: (action: QuickCommandDraft['action']) => void
|
|
}) {
|
|
return (
|
|
<View style={styles.toggleGroup}>
|
|
{(['terminal-command', 'agent-prompt'] as const).map((action) => {
|
|
const selected = value === action
|
|
return (
|
|
<Pressable
|
|
key={action}
|
|
style={({ pressed }) => [
|
|
styles.toggleItem,
|
|
selected && styles.toggleItemSelected,
|
|
pressed && !selected && styles.pressed
|
|
]}
|
|
onPress={() => onChange(action)}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected }}
|
|
>
|
|
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
|
{action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function QuickCommandEditorForm({
|
|
draft,
|
|
mode,
|
|
saving,
|
|
error,
|
|
repoId,
|
|
repoName,
|
|
onChange,
|
|
onOpenAgentPicker,
|
|
onCancel,
|
|
onSave
|
|
}: Props) {
|
|
const hasRepoScope = repoId !== null
|
|
const [advancedOpen, setAdvancedOpen] = useState(draft.scope.type === 'repo')
|
|
const isAgent = draft.action === 'agent-prompt'
|
|
const canSave = isQuickCommandDraftValid(draft) && !saving
|
|
|
|
return (
|
|
<View style={styles.form}>
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Label</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={draft.label}
|
|
onChangeText={(label) => onChange({ label })}
|
|
placeholder="Start dev server"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Action</Text>
|
|
<ActionToggle value={draft.action} onChange={(action) => onChange({ action })} />
|
|
</View>
|
|
|
|
{isAgent ? (
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Agent</Text>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.select, pressed && styles.pressed]}
|
|
onPress={onOpenAgentPicker}
|
|
accessibilityRole="button"
|
|
>
|
|
{draft.agent ? (
|
|
<View style={styles.selectValue}>
|
|
<MobileAgentIcon agentId={draft.agent} size={16} />
|
|
<Text style={styles.selectValueText}>{getQuickCommandAgentLabel(draft.agent)}</Text>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.selectPlaceholder}>Choose agent</Text>
|
|
)}
|
|
<ChevronDown size={16} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>{isAgent ? 'Prompt' : 'Command Text'}</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textarea, !isAgent && styles.mono]}
|
|
value={isAgent ? draft.prompt : draft.command}
|
|
onChangeText={(text) => onChange(isAgent ? { prompt: text } : { command: text })}
|
|
placeholder={isAgent ? 'Ask the agent to investigate this workspace' : 'npm run dev'}
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
multiline
|
|
maxLength={
|
|
isAgent ? MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH : MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH
|
|
}
|
|
/>
|
|
{isAgent ? (
|
|
<Text style={styles.hint}>Supports skills, file paths, and built-in commands.</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.field}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.advancedToggle, pressed && styles.pressed]}
|
|
onPress={() => setAdvancedOpen((open) => !open)}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: advancedOpen }}
|
|
>
|
|
{advancedOpen ? (
|
|
<ChevronDown size={16} color={colors.textSecondary} />
|
|
) : (
|
|
<ChevronRight size={16} color={colors.textSecondary} />
|
|
)}
|
|
<Text style={styles.advancedText}>Advanced</Text>
|
|
</Pressable>
|
|
|
|
{advancedOpen ? (
|
|
<View style={styles.advancedBody}>
|
|
{!isAgent ? (
|
|
<View style={styles.switchRow}>
|
|
<View style={styles.switchText}>
|
|
<Text style={styles.switchTitle}>Append Enter</Text>
|
|
<Text style={styles.switchDesc}>
|
|
Submit immediately instead of only inserting text.
|
|
</Text>
|
|
</View>
|
|
<Switch
|
|
value={draft.appendEnter}
|
|
onValueChange={(appendEnter) => onChange({ appendEnter })}
|
|
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
|
|
thumbColor={colors.surfaceBright}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Scope</Text>
|
|
<View style={styles.toggleGroup}>
|
|
{(['global', 'repo'] as const).map((scopeType) => {
|
|
const selected = draft.scope.type === scopeType
|
|
const disabled = scopeType === 'repo' && !hasRepoScope
|
|
return (
|
|
<Pressable
|
|
key={scopeType}
|
|
disabled={disabled}
|
|
style={({ pressed }) => [
|
|
styles.toggleItem,
|
|
selected && styles.toggleItemSelected,
|
|
disabled && styles.toggleItemDisabled,
|
|
pressed && !selected && !disabled && styles.pressed
|
|
]}
|
|
onPress={() =>
|
|
onChange({
|
|
scope:
|
|
scopeType === 'repo' && repoId
|
|
? { type: 'repo', repoId }
|
|
: { type: 'global' }
|
|
})
|
|
}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected, disabled }}
|
|
>
|
|
<Text style={[styles.toggleText, selected && styles.toggleTextSelected]}>
|
|
{scopeType === 'global' ? 'Global' : 'Project'}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
{draft.scope.type === 'repo' && repoName ? (
|
|
<Text style={styles.scopeRepoName}>{repoName}</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
|
|
<View style={styles.footer}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
|
|
onPress={onCancel}
|
|
accessibilityRole="button"
|
|
>
|
|
<Text style={styles.cancelText}>Cancel</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[styles.button, styles.saveButton, !canSave && styles.saveButtonDisabled]}
|
|
disabled={!canSave}
|
|
onPress={onSave}
|
|
accessibilityRole="button"
|
|
>
|
|
<Text style={[styles.saveText, !canSave && styles.saveTextDisabled]}>
|
|
{mode === 'edit' ? 'Save' : 'Add Quick Command'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
form: { gap: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
|
field: { gap: spacing.sm },
|
|
label: { fontSize: 12, fontWeight: '600', color: colors.textSecondary },
|
|
input: {
|
|
backgroundColor: colors.bgPanel,
|
|
color: colors.textPrimary,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm + 2,
|
|
fontSize: 14,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
textarea: { minHeight: 92, textAlignVertical: 'top' },
|
|
mono: { fontFamily: typography.monoFamily },
|
|
hint: { fontSize: 12, color: colors.textMuted },
|
|
error: { fontSize: 13, color: colors.statusRed, marginTop: spacing.xs },
|
|
pressed: { backgroundColor: colors.bgRaised },
|
|
toggleGroup: { flexDirection: 'row', gap: spacing.sm },
|
|
toggleItem: {
|
|
flex: 1,
|
|
height: 40,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
backgroundColor: colors.bgPanel,
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
},
|
|
toggleItemSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textMuted },
|
|
toggleItemDisabled: { opacity: 0.4 },
|
|
toggleText: { fontSize: 13, fontWeight: '500', color: colors.textSecondary },
|
|
toggleTextSelected: { color: colors.textPrimary },
|
|
select: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: radii.input,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm + 2
|
|
},
|
|
selectValue: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
|
selectValueText: { fontSize: 14, color: colors.textPrimary },
|
|
selectPlaceholder: { fontSize: 14, color: colors.textMuted },
|
|
scopeRepoName: {
|
|
fontSize: 13,
|
|
color: colors.textSecondary,
|
|
fontFamily: typography.monoFamily,
|
|
paddingHorizontal: spacing.xs
|
|
},
|
|
advancedToggle: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
advancedText: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
|
advancedBody: { gap: spacing.md, paddingTop: spacing.xs },
|
|
switchRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
|
switchText: { flex: 1 },
|
|
switchTitle: { fontSize: 14, color: colors.textPrimary },
|
|
switchDesc: { fontSize: 12, color: colors.textMuted, marginTop: 1 },
|
|
footer: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
|
button: { flex: 1, borderRadius: 8, paddingVertical: spacing.md, alignItems: 'center' },
|
|
cancelButton: { borderWidth: 1, borderColor: colors.borderSubtle },
|
|
cancelText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
|
|
saveButton: { backgroundColor: colors.textPrimary },
|
|
saveButtonDisabled: { backgroundColor: colors.bgRaised },
|
|
saveText: { fontSize: 14, fontWeight: '700', color: colors.bgBase },
|
|
saveTextDisabled: { color: colors.textMuted }
|
|
})
|