Files
orca/src/shared/terminal-quick-commands.test.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

389 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
applyTerminalQuickCommandMutation,
buildTerminalQuickCommandInput,
flattenTerminalQuickCommand,
getTerminalQuickCommandAction,
getTerminalQuickCommandBody,
getDefaultTerminalQuickCommands,
isTerminalQuickCommandComplete,
normalizeTerminalQuickCommands,
parseNormalizedTerminalQuickCommands,
supportsTerminalAgentQuickCommand,
terminalQuickCommandMatchesRepo
} from './terminal-quick-commands'
describe('terminal quick commands', () => {
it('returns safe defaults when persisted settings are missing', () => {
expect(normalizeTerminalQuickCommands(undefined)).toEqual([])
expect(getDefaultTerminalQuickCommands()).toEqual([])
})
it('keeps an intentionally empty command list', () => {
expect(normalizeTerminalQuickCommands([])).toEqual([])
})
it('removes quick commands from the abandoned preset rollout', () => {
expect(
normalizeTerminalQuickCommands([
{
id: 'default-pwd',
label: 'Print Working Directory',
command: 'pwd',
appendEnter: true
},
{
id: 'default-git-status',
label: 'Git Status',
command: 'git status',
appendEnter: true
}
])
).toEqual([])
})
it('drops malformed entries and normalizes valid commands and drafts', () => {
expect(
normalizeTerminalQuickCommands([
null,
{ id: 'status', label: ' Status ', command: 'git status\n', appendEnter: false },
{ id: 'empty-command', label: 'Empty', command: ' ' },
{ id: 'status', label: 'Duplicate', command: 'pwd' },
{ label: 'No ID', command: 'date' }
])
).toEqual([
{
id: 'status',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: false,
scope: { type: 'global' }
},
{
id: 'empty-command',
label: 'Empty',
action: 'terminal-command',
command: '',
appendEnter: true,
scope: { type: 'global' }
},
{
id: 'status-2',
label: 'Duplicate',
action: 'terminal-command',
command: 'pwd',
appendEnter: true,
scope: { type: 'global' }
},
{
id: 'quick-command-4',
label: 'No ID',
action: 'terminal-command',
command: 'date',
appendEnter: true,
scope: { type: 'global' }
}
])
})
it('normalizes repository scoped commands and falls back to global for invalid scopes', () => {
expect(
normalizeTerminalQuickCommands([
{
id: 'repo-dev',
label: 'Dev',
command: 'pnpm dev',
scope: { type: 'repo', repoId: ' repo-1 ' }
},
{
id: 'bad-repo',
label: 'Bad',
command: 'echo bad',
scope: { type: 'repo', repoId: ' ' }
}
])
).toEqual([
{
id: 'repo-dev',
label: 'Dev',
action: 'terminal-command',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
{
id: 'bad-repo',
label: 'Bad',
action: 'terminal-command',
command: 'echo bad',
appendEnter: true,
scope: { type: 'global' }
}
])
})
it('normalizes agent prompt commands without storing generated shell text', () => {
expect(
normalizeTerminalQuickCommands([
{
id: 'agent-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: ' Review this diff\n',
command: "codex 'old workaround'"
},
{
id: 'unknown-agent',
label: 'Unknown',
action: 'agent-prompt',
agent: 'not-real',
prompt: 'Do work'
},
{
id: 'post-start-agent',
label: 'Aider',
action: 'agent-prompt',
agent: 'aider',
prompt: 'Do work'
}
])
).toEqual([
{
id: 'agent-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: ' Review this diff',
scope: { type: 'global' }
}
])
})
it('keeps larger reusable agent prompts while bounding shell commands separately', () => {
const largePrompt = 'Review this diff.\n'.repeat(320)
const overLimitPrompt = 'x'.repeat(6001)
const overLimitCommand = 'y'.repeat(4001)
expect(
normalizeTerminalQuickCommands([
{
id: 'large-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: largePrompt
},
{
id: 'over-limit-review',
label: 'Review with cap',
action: 'agent-prompt',
agent: 'codex',
prompt: overLimitPrompt
},
{
id: 'over-limit-command',
label: 'Run long command',
command: overLimitCommand
}
])
).toEqual([
{
id: 'large-review',
label: 'Review',
action: 'agent-prompt',
agent: 'codex',
prompt: largePrompt.trimEnd(),
scope: { type: 'global' }
},
{
id: 'over-limit-review',
label: 'Review with cap',
action: 'agent-prompt',
agent: 'codex',
prompt: 'x'.repeat(6000),
scope: { type: 'global' }
},
{
id: 'over-limit-command',
label: 'Run long command',
action: 'terminal-command',
command: 'y'.repeat(4000),
appendEnter: true,
scope: { type: 'global' }
}
])
})
it('accepts only complete canonical command lists at protocol boundaries', () => {
const canonical = normalizeTerminalQuickCommands([
{ id: 'status', label: 'Status', command: 'git status', appendEnter: true }
])
expect(parseNormalizedTerminalQuickCommands(canonical)).toEqual(canonical)
expect(parseNormalizedTerminalQuickCommands([{ ...canonical[0], command: 42 }])).toBeNull()
expect(
parseNormalizedTerminalQuickCommands([...canonical, ...canonical.slice(0, 1)])
).toBeNull()
})
it('applies targeted mutations without replacing unrelated commands', () => {
const [first, second] = normalizeTerminalQuickCommands([
{ id: 'first', label: 'First', command: 'echo first', appendEnter: true },
{ id: 'second', label: 'Second', command: 'echo second', appendEnter: true }
])
const edited = { ...first!, label: 'Edited' }
expect(
applyTerminalQuickCommandMutation([first!, second!], {
type: 'upsert',
command: edited
})
).toEqual([edited, second])
expect(
applyTerminalQuickCommandMutation([first!, second!], { type: 'delete', id: first!.id })
).toEqual([second])
})
it('matches global commands everywhere and repo commands only in their repo', () => {
expect(
terminalQuickCommandMatchesRepo(
{
id: 'global',
label: 'Global',
command: 'date',
appendEnter: true,
scope: { type: 'global' }
},
null
)
).toBe(true)
expect(
terminalQuickCommandMatchesRepo(
{
id: 'repo',
label: 'Repo',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
'repo-1'
)
).toBe(true)
expect(
terminalQuickCommandMatchesRepo(
{
id: 'repo',
label: 'Repo',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'repo', repoId: 'repo-1' }
},
'repo-2'
)
).toBe(false)
})
it('formats terminal input without assuming shell semantics', () => {
expect(
buildTerminalQuickCommandInput({
id: 'status',
label: 'Status',
command: 'git status',
appendEnter: true
})
).toBe('git status\r')
expect(
buildTerminalQuickCommandInput({
id: 'status',
label: 'Status',
command: 'git status',
appendEnter: false
})
).toBe('git status')
})
it('classifies quick command actions and body text', () => {
const terminal = {
id: 'status',
label: 'Status',
command: 'git status',
appendEnter: true
}
const agent = {
id: 'agent',
label: 'Agent',
action: 'agent-prompt' as const,
agent: 'claude' as const,
prompt: 'Fix the tests'
}
expect(getTerminalQuickCommandAction(terminal)).toBe('terminal-command')
expect(getTerminalQuickCommandBody(terminal)).toBe('git status')
expect(isTerminalQuickCommandComplete(terminal)).toBe(true)
expect(getTerminalQuickCommandAction(agent)).toBe('agent-prompt')
expect(getTerminalQuickCommandBody(agent)).toBe('Fix the tests')
expect(isTerminalQuickCommandComplete(agent)).toBe(true)
})
it('only allows agent prompt quick commands for launch-time prompt agents', () => {
expect(supportsTerminalAgentQuickCommand('claude')).toBe(true)
expect(supportsTerminalAgentQuickCommand('gemini')).toBe(true)
expect(supportsTerminalAgentQuickCommand('aider')).toBe(false)
expect(supportsTerminalAgentQuickCommand('not-real')).toBe(false)
})
})
describe('flattenTerminalQuickCommand', () => {
it('returns the same object when there are no line breaks', () => {
const command = {
id: 'test',
label: 'Test',
command: 'git status',
appendEnter: true
} as const
expect(flattenTerminalQuickCommand(command)).toBe(command)
})
it('replaces newlines with semicolons and spaces', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'cd packages\nbun run build\ncd ..',
appendEnter: true
})
expect(result.command).toBe('cd packages; bun run build; cd ..')
})
it('collapses consecutive newlines into a single separator', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'echo one\n\n\necho two',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
it('handles Windows-style CRLF endings', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'echo one\r\necho two',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
it('drops empty edge lines without leaving dangling separators', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: '\n echo one \n\n echo two\n',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
})