mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +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
273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
|
|
import type {
|
|
TerminalAgentQuickCommand,
|
|
TerminalCommandQuickCommand,
|
|
TerminalQuickCommand,
|
|
TerminalQuickCommandAction,
|
|
TerminalQuickCommandScope
|
|
} from './types'
|
|
|
|
export const MAX_QUICK_COMMANDS = 40
|
|
export const MAX_QUICK_COMMAND_ID_LENGTH = 80
|
|
export const MAX_QUICK_COMMAND_LABEL_LENGTH = 80
|
|
export const MAX_QUICK_COMMAND_REPO_ID_LENGTH = 200
|
|
export const MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH = 4000
|
|
// Why: agent prompt quick commands still launch through startup commands for
|
|
// argv/flag agents, so this must stay within Orca's Windows shell safety cap.
|
|
export const MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH = 6000
|
|
const REMOVED_PRESET_IDS = new Set(['default-pwd', 'default-git-status'])
|
|
|
|
const DEFAULT_TERMINAL_QUICK_COMMANDS: TerminalQuickCommand[] = []
|
|
|
|
export type TerminalQuickCommandMutation =
|
|
| { type: 'upsert'; command: TerminalQuickCommand }
|
|
| { type: 'delete'; id: string }
|
|
|
|
export function getDefaultTerminalQuickCommands(): TerminalQuickCommand[] {
|
|
return DEFAULT_TERMINAL_QUICK_COMMANDS.map((command) => ({ ...command }))
|
|
}
|
|
|
|
function normalizeTerminalQuickCommandScope(input: unknown): TerminalQuickCommandScope {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
return { type: 'global' }
|
|
}
|
|
const record = input as Record<string, unknown>
|
|
if (record.type !== 'repo') {
|
|
return { type: 'global' }
|
|
}
|
|
const repoId = typeof record.repoId === 'string' ? record.repoId.trim() : ''
|
|
if (!repoId) {
|
|
return { type: 'global' }
|
|
}
|
|
return { type: 'repo', repoId: repoId.slice(0, MAX_QUICK_COMMAND_REPO_ID_LENGTH) }
|
|
}
|
|
|
|
export function getTerminalQuickCommandScope(
|
|
command: TerminalQuickCommand
|
|
): TerminalQuickCommandScope {
|
|
return normalizeTerminalQuickCommandScope(command.scope)
|
|
}
|
|
|
|
export function terminalQuickCommandMatchesRepo(
|
|
command: TerminalQuickCommand,
|
|
repoId: string | null
|
|
): boolean {
|
|
const scope = getTerminalQuickCommandScope(command)
|
|
return scope.type === 'global' || (repoId !== null && scope.repoId === repoId)
|
|
}
|
|
|
|
export function getTerminalQuickCommandAction(
|
|
command: TerminalQuickCommand
|
|
): TerminalQuickCommandAction {
|
|
return command.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
|
|
}
|
|
|
|
export function isTerminalAgentQuickCommand(
|
|
command: TerminalQuickCommand
|
|
): command is TerminalAgentQuickCommand {
|
|
return getTerminalQuickCommandAction(command) === 'agent-prompt'
|
|
}
|
|
|
|
export function supportsTerminalAgentQuickCommand(
|
|
agent: unknown
|
|
): agent is TerminalAgentQuickCommand['agent'] {
|
|
return isTuiAgent(agent) && TUI_AGENT_CONFIG[agent].promptInjectionMode !== 'stdin-after-start'
|
|
}
|
|
|
|
export function getTerminalQuickCommandBody(command: TerminalQuickCommand): string {
|
|
return isTerminalAgentQuickCommand(command) ? command.prompt : command.command
|
|
}
|
|
|
|
export function isTerminalQuickCommandComplete(command: TerminalQuickCommand): boolean {
|
|
return command.label.trim().length > 0 && getTerminalQuickCommandBody(command).trim().length > 0
|
|
}
|
|
|
|
export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCommand[] {
|
|
if (!Array.isArray(input)) {
|
|
return getDefaultTerminalQuickCommands()
|
|
}
|
|
|
|
const normalized: TerminalQuickCommand[] = []
|
|
const seenIds = new Set<string>()
|
|
|
|
for (const item of input) {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
continue
|
|
}
|
|
const record = item as Record<string, unknown>
|
|
const rawId = typeof record.id === 'string' ? record.id.trim() : ''
|
|
if (REMOVED_PRESET_IDS.has(rawId)) {
|
|
continue
|
|
}
|
|
const hasLabel = typeof record.label === 'string'
|
|
const action: TerminalQuickCommandAction =
|
|
record.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
|
|
const hasCommand = typeof record.command === 'string'
|
|
const hasPrompt = typeof record.prompt === 'string'
|
|
// Why: settings saves on every edit; preserve incomplete rows so a newly
|
|
// added command is not deleted before the user fills in the command text.
|
|
if (!hasLabel && !hasCommand && !hasPrompt) {
|
|
continue
|
|
}
|
|
const agent = supportsTerminalAgentQuickCommand(record.agent) ? record.agent : null
|
|
if (action === 'agent-prompt' && agent === null) {
|
|
continue
|
|
}
|
|
const label = hasLabel ? String(record.label).trim() : ''
|
|
|
|
const idBase = rawId || `quick-command-${normalized.length + 1}`
|
|
let id = idBase.slice(0, MAX_QUICK_COMMAND_ID_LENGTH)
|
|
let suffix = 2
|
|
while (seenIds.has(id)) {
|
|
id = `${idBase.slice(0, MAX_QUICK_COMMAND_ID_LENGTH - 4)}-${suffix}`
|
|
suffix += 1
|
|
}
|
|
seenIds.add(id)
|
|
|
|
const base = {
|
|
id,
|
|
label: label.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH),
|
|
scope: normalizeTerminalQuickCommandScope(record.scope)
|
|
}
|
|
|
|
if (action === 'agent-prompt') {
|
|
if (agent === null) {
|
|
continue
|
|
}
|
|
const agentId = agent
|
|
normalized.push({
|
|
...base,
|
|
action: 'agent-prompt',
|
|
agent: agentId,
|
|
prompt: (hasPrompt ? String(record.prompt).trimEnd() : '').slice(
|
|
0,
|
|
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH
|
|
)
|
|
})
|
|
} else {
|
|
const command = hasCommand ? String(record.command).trimEnd() : ''
|
|
normalized.push({
|
|
...base,
|
|
action: 'terminal-command',
|
|
command: command.slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
|
|
appendEnter: record.appendEnter !== false
|
|
})
|
|
}
|
|
|
|
if (normalized.length >= MAX_QUICK_COMMANDS) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function hasExactKeys(record: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
const actual = Object.keys(record)
|
|
return actual.length === keys.length && keys.every((key) => Object.hasOwn(record, key))
|
|
}
|
|
|
|
function isNormalizedTerminalQuickCommandScope(
|
|
value: unknown,
|
|
expected: TerminalQuickCommandScope
|
|
): boolean {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const scope = value as Record<string, unknown>
|
|
if (expected.type === 'global') {
|
|
return hasExactKeys(scope, ['type']) && scope.type === 'global'
|
|
}
|
|
return (
|
|
hasExactKeys(scope, ['type', 'repoId']) &&
|
|
scope.type === 'repo' &&
|
|
scope.repoId === expected.repoId
|
|
)
|
|
}
|
|
|
|
function isNormalizedTerminalQuickCommand(value: unknown, expected: TerminalQuickCommand): boolean {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const command = value as Record<string, unknown>
|
|
if (
|
|
command.id !== expected.id ||
|
|
command.label !== expected.label ||
|
|
!isNormalizedTerminalQuickCommandScope(command.scope, expected.scope ?? { type: 'global' })
|
|
) {
|
|
return false
|
|
}
|
|
if (isTerminalAgentQuickCommand(expected)) {
|
|
return (
|
|
hasExactKeys(command, ['id', 'label', 'action', 'agent', 'prompt', 'scope']) &&
|
|
command.action === 'agent-prompt' &&
|
|
command.agent === expected.agent &&
|
|
command.prompt === expected.prompt
|
|
)
|
|
}
|
|
return (
|
|
hasExactKeys(command, ['id', 'label', 'action', 'command', 'appendEnter', 'scope']) &&
|
|
command.action === 'terminal-command' &&
|
|
command.command === expected.command &&
|
|
command.appendEnter === expected.appendEnter
|
|
)
|
|
}
|
|
|
|
// Why: a full-list client must reject any "authoritative" payload that would
|
|
// change under normalization, or its next mutation could persist silent loss.
|
|
export function parseNormalizedTerminalQuickCommands(
|
|
input: unknown
|
|
): TerminalQuickCommand[] | null {
|
|
if (!Array.isArray(input) || input.length > MAX_QUICK_COMMANDS) {
|
|
return null
|
|
}
|
|
const normalized = normalizeTerminalQuickCommands(input)
|
|
if (
|
|
normalized.length !== input.length ||
|
|
normalized.some((command, index) => !isNormalizedTerminalQuickCommand(input[index], command))
|
|
) {
|
|
return null
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// Why: paired clients can edit settings concurrently. Applying one command at
|
|
// the host boundary preserves unrelated commands added by another client.
|
|
export function applyTerminalQuickCommandMutation(
|
|
commands: readonly TerminalQuickCommand[],
|
|
mutation: TerminalQuickCommandMutation
|
|
): TerminalQuickCommand[] {
|
|
if (mutation.type === 'delete') {
|
|
return commands.filter((command) => command.id !== mutation.id)
|
|
}
|
|
const existingIndex = commands.findIndex((command) => command.id === mutation.command.id)
|
|
if (existingIndex === -1) {
|
|
return [...commands, mutation.command]
|
|
}
|
|
return commands.map((command, index) => (index === existingIndex ? mutation.command : command))
|
|
}
|
|
|
|
export function buildTerminalQuickCommandInput(command: TerminalCommandQuickCommand): string {
|
|
return command.appendEnter ? `${command.command}\r` : command.command
|
|
}
|
|
|
|
const LINE_BREAK_RE = /\r\n|\r|\n/
|
|
|
|
// Why: quick-command lines are independent shell commands; one shell command
|
|
// list prevents foreground programs from reading later lines as stdin.
|
|
export function flattenTerminalQuickCommand(
|
|
command: TerminalCommandQuickCommand
|
|
): TerminalCommandQuickCommand {
|
|
if (!LINE_BREAK_RE.test(command.command)) {
|
|
return command
|
|
}
|
|
return {
|
|
...command,
|
|
command: command.command
|
|
.split(LINE_BREAK_RE)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.join('; ')
|
|
}
|
|
}
|