Files
orca/mobile/src/session/quick-command-draft.ts
T
Brennan Benson 5fcf777617 feat(mobile): Quick Commands (terminal + agent-prompt presets) (#9298)
* 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
2026-07-19 23:42:31 -07:00

117 lines
3.3 KiB
TypeScript

import type {
TerminalQuickCommand,
TerminalQuickCommandAction,
TerminalQuickCommandScope,
TuiAgent
} from '../../../src/shared/types'
import {
isAgentQuickCommand,
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
MAX_QUICK_COMMAND_LABEL_LENGTH,
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
supportsTerminalAgentQuickCommand
} from '../terminal/quick-commands'
// A single mutable draft covering both quick-command actions. Both the command
// text and the agent/prompt are kept while editing so toggling Action back and
// forth doesn't discard what the user already typed (mirrors desktop's draft).
export type QuickCommandDraft = {
id: string | null
label: string
action: TerminalQuickCommandAction
command: string
appendEnter: boolean
agent: TuiAgent | null
prompt: string
scope: TerminalQuickCommandScope
}
export function createEmptyQuickCommandDraft(scope: TerminalQuickCommandScope): QuickCommandDraft {
return {
id: null,
label: '',
action: 'terminal-command',
command: '',
appendEnter: true,
agent: null,
prompt: '',
scope
}
}
export function quickCommandToDraft(command: TerminalQuickCommand): QuickCommandDraft {
const scope: TerminalQuickCommandScope =
command.scope?.type === 'repo' && command.scope.repoId
? { type: 'repo', repoId: command.scope.repoId }
: { type: 'global' }
if (isAgentQuickCommand(command)) {
return {
id: command.id,
label: command.label,
action: 'agent-prompt',
command: '',
appendEnter: true,
agent: command.agent,
prompt: command.prompt,
scope
}
}
return {
id: command.id,
label: command.label,
action: 'terminal-command',
command: command.command,
appendEnter: command.appendEnter !== false,
agent: null,
prompt: '',
scope
}
}
export function isQuickCommandDraftValid(draft: QuickCommandDraft): boolean {
if (!draft.label.trim()) {
return false
}
if (draft.action === 'agent-prompt') {
return Boolean(
draft.agent &&
supportsTerminalAgentQuickCommand(draft.agent) &&
draft.prompt.trim().length > 0
)
}
return draft.command.trim().length > 0
}
// Build the persisted command from a draft. Returns null when incomplete so the
// caller can keep the editor open. The server re-normalizes, but we trim/cap
// here so optimistic local state matches what will be saved.
export function draftToQuickCommand(draft: QuickCommandDraft): TerminalQuickCommand | null {
if (!isQuickCommandDraftValid(draft)) {
return null
}
// Why: timestamps alone collide when desktop and mobile add commands in the
// same millisecond, which would turn an atomic upsert into an overwrite.
const id =
draft.id ??
`quick-command-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
const label = draft.label.trim().slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH)
if (draft.action === 'agent-prompt' && draft.agent) {
return {
id,
label,
action: 'agent-prompt',
agent: draft.agent,
prompt: draft.prompt.trimEnd().slice(0, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH),
scope: draft.scope
}
}
return {
id,
label,
action: 'terminal-command',
command: draft.command.trimEnd().slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
appendEnter: draft.appendEnter,
scope: draft.scope
}
}