mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +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
389 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|