Allow saving quick command lists beyond forty (#32972)

* Allow saving quick command lists beyond forty

Preserve compatibility by limiting older clients to readable commands

* Cover quick command persistence across client capabilities

* Reject edits to hidden quick commands from stale clients
This commit is contained in:
Jinjing
2026-10-24 10:42:55 -07:00
committed by GitHub
parent b24c4f6eb0
commit 42f59f3460
23 changed files with 539 additions and 204 deletions
@@ -57,7 +57,6 @@ describe('QuickCommandsList search', () => {
query: 'missing',
loading: false,
disabled: false,
canAdd: true,
error: null,
onQueryChange: vi.fn(),
onLaunch: vi.fn(),
@@ -83,7 +82,6 @@ describe('QuickCommandsList search', () => {
query: '',
loading: false,
disabled: false,
canAdd: true,
error: null,
onQueryChange: vi.fn(),
onLaunch: vi.fn(),
+4 -9
View File
@@ -22,7 +22,6 @@ type ListProps = {
query: string
loading: boolean
disabled: boolean
canAdd: boolean
error: string | null
onQueryChange: (value: string) => void
onLaunch: (command: TerminalQuickCommand) => void
@@ -38,7 +37,6 @@ export function QuickCommandsList({
query,
loading,
disabled,
canAdd,
error,
onQueryChange,
onLaunch,
@@ -47,7 +45,6 @@ export function QuickCommandsList({
onAdd
}: ListProps) {
const hasVisible = repoCommands.length + globalCommands.length > 0
const addDisabled = disabled || !canAdd
// Why: keep an active filter clearable if a delete or paired desktop edit
// leaves only one command while the sheet is open.
const showSearch = totalCount > 1 || query.length > 0
@@ -110,17 +107,15 @@ export function QuickCommandsList({
<Pressable
style={({ pressed }) => [
styles.addRow,
addDisabled && styles.disabled,
pressed && !addDisabled && styles.pressed
disabled && styles.disabled,
pressed && !disabled && styles.pressed
]}
disabled={addDisabled}
disabled={disabled}
onPress={onAdd}
accessibilityRole="button"
>
<Plus size={18} color={colors.textSecondary} />
<Text style={styles.addText}>
{canAdd ? 'New quick command' : 'Quick command limit reached'}
</Text>
<Text style={styles.addText}>New quick command</Text>
</Pressable>
</View>
)
+10 -4
View File
@@ -3,7 +3,6 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { RpcClient } from '../transport/rpc-client'
import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
import { QuickCommandsSheet } from './QuickCommandsSheet'
import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
@@ -342,8 +341,8 @@ describe('QuickCommandsSheet', () => {
client.close()
})
it('keeps creation closed when the host command limit is reached', async () => {
mocks.commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({
it('allows saving another command when forty commands already exist', async () => {
mocks.commands = Array.from({ length: 40 }, (_, index) => ({
...command,
id: `command-${index}`
}))
@@ -362,8 +361,15 @@ describe('QuickCommandsSheet', () => {
})
const list = renderer!.root.findByType(quickCommandsList)
expect(list.props.canAdd).toBe(false)
act(() => list.props.onAdd())
const editor = () => renderer!.root.findByType(quickCommandEditorForm)
act(() => editor().props.onChange({ label: 'Another command', command: 'echo saved' }))
mocks.persist.mockResolvedValue(true)
await act(async () => editor().props.onSave())
expect(mocks.persist).toHaveBeenCalledWith({
type: 'upsert',
command: expect.objectContaining({ label: 'Another command', command: 'echo saved' })
})
expect(renderer!.root.findAllByType(quickCommandEditorForm)).toHaveLength(0)
})
+1 -11
View File
@@ -5,11 +5,7 @@ import { colors, spacing } from '../theme/mobile-theme'
import { BottomDrawer } from '../components/BottomDrawer'
import type { RpcClient } from '../transport/rpc-client'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import {
getQuickCommandPreview,
MAX_QUICK_COMMANDS,
quickCommandMatchesRepo
} from '../terminal/quick-commands'
import { getQuickCommandPreview, quickCommandMatchesRepo } from '../terminal/quick-commands'
import { useQuickCommands } from './use-quick-commands'
import { QuickCommandEditorForm } from './QuickCommandEditorForm'
import { QuickCommandAgentPicker, QuickCommandsList } from './QuickCommandsList'
@@ -96,11 +92,6 @@ export function QuickCommandsSheet({
const globalCommands = visibleCommands.filter((command) => command.scope?.type !== 'repo')
const openEditor = (command?: TerminalQuickCommand) => {
// Why: the host rejects full-list updates above this cap; existing rows
// must remain editable/deletable when creation is no longer possible.
if (!command && commands.length >= MAX_QUICK_COMMANDS) {
return
}
draftRevisionRef.current += 1
editorGenerationRef.current += 1
setDraft(
@@ -213,7 +204,6 @@ export function QuickCommandsSheet({
query={query}
loading={loading}
disabled={!ready}
canAdd={commands.length < MAX_QUICK_COMMANDS}
error={error}
onQueryChange={setQuery}
onLaunch={handleLaunch}
-2
View File
@@ -5,7 +5,6 @@ import {
flattenTerminalQuickCommand,
getTerminalQuickCommandBody,
isTerminalAgentQuickCommand,
MAX_QUICK_COMMANDS,
MAX_QUICK_COMMAND_LABEL_LENGTH,
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
parseNormalizedTerminalQuickCommands,
@@ -31,7 +30,6 @@ export {
supportsTerminalAgentQuickCommand,
getTerminalQuickCommandBodySize,
formatKilobytes,
MAX_QUICK_COMMANDS,
MAX_QUICK_COMMAND_LABEL_LENGTH,
MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
parseNormalizedTerminalQuickCommands,
+5 -1
View File
@@ -34,7 +34,10 @@ import {
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../shared/terminal-quick-command-capabilities'
import {
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from '../../shared/terminal-quick-command-capabilities'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability'
vi.mock('./launch', () => ({
@@ -91,6 +94,7 @@ describe('CLI remote WebSocket transport', () => {
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
]
})
@@ -8,7 +8,10 @@ import {
AGENT_LAUNCH_RUNTIME_CAPABILITY,
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
} from '../../shared/agent-launch-runtime-capability'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../shared/terminal-quick-command-capabilities'
import {
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from '../../shared/terminal-quick-command-capabilities'
/** Curate one capability set for unary/streaming desktop calls; remote-only gates can alter local semantics even on the same build. */
export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [
@@ -20,5 +23,6 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
// Treat closed launch tabs as silent cancellation instead of unknown failure.
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY
] as const
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { TerminalQuickCommand } from '../../../shared/terminal-quick-command-types'
import { MAX_QUICK_COMMANDS } from '../../../shared/terminal-quick-commands'
import {
getTerminalQuickCommandPromptMessageBytes,
MAX_QUICK_COMMAND_AGENT_PROMPT_MESSAGE_BYTES,
@@ -49,6 +48,19 @@ function agentCommand(id: string, prompt: string): TerminalQuickCommand {
// Local saves and paired saves both reach the store here, so this is the one place the bound holds.
describe('updateSettings terminalQuickCommands', () => {
it('stores every command in a list larger than forty, including missing projects', () => {
const commands = Array.from({ length: 100 }, (_, index) => ({
...agentCommand(`command-${index}`, 'Review this project'),
scope: { type: 'repo' as const, repoId: `missing-project-${index}` }
}))
const operations = makeOperations()
const saved = updateSettings(operations, { terminalQuickCommands: commands })
expect(saved.terminalQuickCommands).toEqual(commands)
expect(operations.state.settings.terminalQuickCommands).toEqual(commands)
})
it('stores a prompt at the limit whole', () => {
const prompt = promptAtLimit('x')
const saved = updateSettings(makeOperations(), {
@@ -74,7 +86,7 @@ describe('updateSettings terminalQuickCommands', () => {
it('refuses a list too large for one paired reply', () => {
// Forty prompts at the limit total about 10 MB of JSON.
const commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) =>
const commands = Array.from({ length: 40 }, (_, index) =>
agentCommand(`c${index}`, promptAtLimit('界'))
)
@@ -22,7 +22,6 @@ export const FOLDER_WORKSPACE_INSTANCE_SEPARATOR =
export const HEADLESS_RUNTIME_WINDOW_ID = importedValues.exportedHEADLESS_RUNTIME_WINDOW_ID
export const HeadlessEmulator = importedValues.exportedHeadlessEmulator
export const MAX_OSC_TITLE_CHARS = importedValues.exportedMAX_OSC_TITLE_CHARS
export const MAX_QUICK_COMMANDS = importedValues.exportedMAX_QUICK_COMMANDS
export const OrcaRuntimeService = importedValues.exportedOrcaRuntimeService
export const OrchestrationDb = importedValues.exportedOrchestrationDb
export const REVIEW_HEAD_FETCH_TIMEOUT_MS = importedValues.exportedREVIEW_HEAD_FETCH_TIMEOUT_MS
@@ -81,7 +81,6 @@ import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR
} from '../../../shared/terminal-input'
import { MAX_QUICK_COMMANDS } from '../../../shared/terminal-quick-commands'
import {
AGENT_PROMPT_BRACKETED_PASTE_END,
AGENT_PROMPT_BRACKETED_PASTE_START,
@@ -136,7 +135,6 @@ export const exportedFOLDER_WORKSPACE_INSTANCE_SEPARATOR = FOLDER_WORKSPACE_INST
export const exportedHEADLESS_RUNTIME_WINDOW_ID = HEADLESS_RUNTIME_WINDOW_ID
export const exportedHeadlessEmulator = HeadlessEmulator
export const exportedMAX_OSC_TITLE_CHARS = MAX_OSC_TITLE_CHARS
export const exportedMAX_QUICK_COMMANDS = MAX_QUICK_COMMANDS
export const exportedOrcaRuntimeService = OrcaRuntimeServiceConstructor
export const exportedOrchestrationDb = RuntimeOrchestrationDb
export const exportedREVIEW_HEAD_FETCH_TIMEOUT_MS = REVIEW_HEAD_FETCH_TIMEOUT_MS
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import {
MAX_QUICK_COMMANDS,
OrcaRuntimeService,
applyAgentStatusHooksEnabledMock,
electronMocks
@@ -172,8 +171,8 @@ describe('OrcaRuntimeService', () => {
expect(settings.nativeChatSessionOptions).toEqual({ grok: {} })
})
it('rejects a concurrent add after the quick command limit is reached', () => {
const terminalQuickCommands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({
it('preserves concurrent additions after forty quick commands', () => {
const terminalQuickCommands = Array.from({ length: 40 }, (_, index) => ({
id: `command-${index}`,
label: `Command ${index}`,
action: 'terminal-command' as const,
@@ -181,27 +180,25 @@ describe('OrcaRuntimeService', () => {
appendEnter: true,
scope: { type: 'global' as const }
}))
const updateSettings = vi.fn()
let settings = { ...store.getSettings(), terminalQuickCommands }
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
})
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({ ...store.getSettings(), terminalQuickCommands }),
getSettings: () => settings,
updateSettings
} as never)
expect(() =>
runtime.updateClientTerminalQuickCommands({
type: 'upsert',
command: {
id: 'one-too-many',
label: 'One too many',
action: 'terminal-command',
command: 'true',
appendEnter: true,
scope: { type: 'global' }
}
})
).toThrow('Quick command limit reached')
expect(updateSettings).not.toHaveBeenCalled()
const first = { ...terminalQuickCommands[0], id: 'desktop-addition' }
const second = { ...terminalQuickCommands[0], id: 'mobile-addition' }
runtime.updateClientTerminalQuickCommands({ type: 'upsert', command: first })
expect(runtime.updateClientTerminalQuickCommands({ type: 'upsert', command: second })).toEqual([
...terminalQuickCommands,
first,
second
])
expect(settings.terminalQuickCommands).toEqual([...terminalQuickCommands, first, second])
})
it('accepts runtime-backed setting updates from paired clients', async () => {
+15 -5
View File
@@ -26,7 +26,10 @@ import {
import { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/terminal-quick-command-params'
import { AgentLaunchSettingsMutation } from '../../../../shared/rpc-contract/agent-launch-settings-params'
import { projectTerminalQuickCommandsForClient } from './terminal-quick-command-long-prompt-capability'
import {
assertTerminalQuickCommandMutationReadableForClient,
projectTerminalQuickCommandsForClient
} from './terminal-quick-command-client-capabilities'
function uiForCaller(
state: PersistedUIState,
@@ -120,12 +123,19 @@ export const CLIENT_UI_METHODS = [
name: 'settings.updateTerminalQuickCommands',
permission: 'settings-write',
params: TerminalQuickCommandsUpdate,
handler: (params, ctx) => ({
terminalQuickCommands: projectTerminalQuickCommandsForClient(
ctx.runtime.updateClientTerminalQuickCommands(params.mutation),
handler: (params, ctx) => {
assertTerminalQuickCommandMutationReadableForClient(
ctx.runtime.getClientTerminalQuickCommands(),
params.mutation,
ctx
)
})
return {
terminalQuickCommands: projectTerminalQuickCommandsForClient(
ctx.runtime.updateClientTerminalQuickCommands(params.mutation),
ctx
)
}
}
}),
defineMethod({
name: 'settings.updatePRBotAuthorOverride',
@@ -0,0 +1,364 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../../../shared/constants'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities'
import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities'
import { LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-command-prompt-limit'
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import type { TerminalQuickCommandMutation } from '../../../../shared/terminal-quick-commands'
import { WEB_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/web-runtime-client-capabilities'
import {
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from '../../../../shared/terminal-quick-command-capabilities'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities'
import { RuntimeClientSettingsController } from '../../runtime-client-settings'
import {
updateSettings,
type SettingsMutationOperations
} from '../../../persistence/applying-settings/settings-update'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { parseRuntimeClientCapabilities } from '../runtime-client-capabilities'
import { CLIENT_UI_METHODS } from './client-ui'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('quick-command client compatibility', () => {
const shortCommand: TerminalQuickCommand = {
id: 'status',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: true,
scope: { type: 'global' }
}
const longCommand: TerminalQuickCommand = {
id: 'review',
label: 'Review',
action: 'agent-prompt',
agent: 'claude',
prompt: 'x'.repeat(LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH + 1),
scope: { type: 'global' }
}
function makeDispatcher(commands = [shortCommand, longCommand]) {
const state = getDefaultPersistedState('/tmp')
state.settings.terminalQuickCommands = commands
const scheduleSave = vi.fn()
const operations: SettingsMutationOperations = {
state,
bumpLocalWorktreeScanGeneration: vi.fn(),
removeRetainedBlob: vi.fn(),
scheduleSave,
notifySettingsChanged: vi.fn()
}
const settings = new RuntimeClientSettingsController({
getSettings: () => state.settings,
updateSettings: (updates, options) => updateSettings(operations, updates, options)
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers call only the quick-command methods stubbed here.
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientTerminalQuickCommands: () => settings.getTerminalQuickCommands(),
updateClientTerminalQuickCommands: (mutation: TerminalQuickCommandMutation) =>
settings.updateTerminalQuickCommands(mutation)
} as unknown as OrcaRuntimeService
return {
dispatcher: new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }),
state,
scheduleSave
}
}
it('refuses a legacy client addition beyond forty before saving anything', async () => {
const commands = Array.from({ length: 40 }, (_, index) => ({
...shortCommand,
id: `command-${index}`
}))
const { dispatcher, state, scheduleSave } = makeDispatcher(commands)
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: shortCommand }
}),
{ clientKind: 'runtime', clientCapabilities: [] }
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: expect.stringContaining('Update this Orca client')
}
})
expect(state.settings.terminalQuickCommands).toEqual(commands)
expect(scheduleSave).not.toHaveBeenCalled()
})
it.each([
{
name: 'local desktop',
caller: {
clientKind: 'runtime' as const,
clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES
}
},
{
name: 'paired desktop',
caller: {
clientKind: 'runtime' as const,
clientCapabilities: parseRuntimeClientCapabilities(
remoteRuntimeClientCapabilities(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES)
)
}
},
{
name: 'web',
caller: {
clientKind: 'runtime' as const,
clientCapabilities: parseRuntimeClientCapabilities(WEB_RUNTIME_CLIENT_CAPABILITIES)
}
},
{
name: 'CLI',
caller: {
clientKind: 'runtime' as const,
clientCapabilities: parseRuntimeClientCapabilities(remoteRuntimeClientCapabilities())
}
},
{ name: 'in-process', caller: undefined }
])('saves and acknowledges command forty-one using $name capabilities', async ({ caller }) => {
const commands = Array.from({ length: 40 }, (_, index) => ({
...shortCommand,
id: `command-${index}`
}))
const { dispatcher, state, scheduleSave } = makeDispatcher(commands)
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: longCommand }
}),
caller
)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [...commands, longCommand] }
})
expect(state.settings.terminalQuickCommands).toEqual([...commands, longCommand])
expect(scheduleSave).toHaveBeenCalledOnce()
})
it('lets legacy clients edit and delete visible commands while preserving hidden commands', async () => {
const commands = Array.from({ length: 45 }, (_, index) => ({
...shortCommand,
id: `command-${index}`
}))
const { dispatcher, state } = makeDispatcher(commands)
const caller = { clientKind: 'runtime' as const, clientCapabilities: [] }
const edited = { ...commands[0], label: 'Edited command' }
const editResponse = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: edited }
}),
caller
)
expect(editResponse).toMatchObject({
ok: true,
result: { terminalQuickCommands: [edited, ...commands.slice(1, 40)] }
})
const deleteResponse = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'delete', id: commands[1].id }
}),
caller
)
expect(deleteResponse).toMatchObject({
ok: true,
result: { terminalQuickCommands: [edited, ...commands.slice(2, 41)] }
})
expect(state.settings.terminalQuickCommands).toEqual([edited, ...commands.slice(2)])
})
it('does not count withheld prompts against a legacy client readable list', async () => {
const commands = Array.from({ length: 39 }, (_, index) => ({
...shortCommand,
id: `command-${index}`
}))
const { dispatcher, state } = makeDispatcher([longCommand, ...commands])
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: shortCommand }
}),
{ clientKind: 'runtime', clientCapabilities: [] }
)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [...commands, shortCommand] }
})
expect(state.settings.terminalQuickCommands).toEqual([longCommand, ...commands, shortCommand])
})
it('refuses an edit that would be withheld from its caller before changing the saved command', async () => {
const { dispatcher, state, scheduleSave } = makeDispatcher([shortCommand])
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: { ...longCommand, id: shortCommand.id } }
}),
{
clientKind: 'runtime',
clientCapabilities: [TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY]
}
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: expect.stringContaining('Update this Orca client')
}
})
expect(state.settings.terminalQuickCommands).toEqual([shortCommand])
expect(scheduleSave).not.toHaveBeenCalled()
})
it.each(['runtime', 'mobile'] as const)(
'refuses a stale %s client edit of a hidden prompt before saving anything',
async (clientKind) => {
const { dispatcher, state, scheduleSave } = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: { ...longCommand, prompt: 'Stale short prompt' } }
}),
{
clientKind,
clientCapabilities: [TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY]
}
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'invalid_argument',
message: expect.stringContaining('Update this Orca client')
}
})
expect(state.settings.terminalQuickCommands).toEqual([shortCommand, longCommand])
expect(scheduleSave).not.toHaveBeenCalled()
}
)
it('stores a long prompt instead of refusing it', async () => {
const { dispatcher, state } = makeDispatcher([shortCommand])
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: longCommand }
})
)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [shortCommand, longCommand] }
})
expect(state.settings.terminalQuickCommands).toEqual([shortCommand, longCommand])
})
it('withholds long prompts from a client that cannot read them', async () => {
const { dispatcher } = makeDispatcher()
const olderClient = { clientKind: 'mobile' as const, clientCapabilities: [] }
for (const request of [
makeRequest('settings.getTerminalQuickCommands'),
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'delete', id: 'other' }
})
]) {
const response = await dispatcher.dispatch(request, olderClient)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [shortCommand] }
})
}
})
it('publishes long prompts to clients that advertise support and to in-process callers', async () => {
const { dispatcher } = makeDispatcher()
for (const options of [
{
clientKind: 'mobile' as const,
clientCapabilities: [TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY]
},
undefined
]) {
const response = await dispatcher.dispatch(
makeRequest('settings.getTerminalQuickCommands'),
options
)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [shortCommand, longCommand] }
})
}
})
it.each([false, true])(
'projects large lists for older and current clients (support: %s)',
async (supported) => {
const commands = Array.from({ length: 100 }, (_, index) => ({
...shortCommand,
id: `command-${index}`
}))
const { dispatcher } = makeDispatcher([longCommand, ...commands])
const olderClient = {
clientKind: 'mobile' as const,
clientCapabilities: supported ? [TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY] : []
}
for (const request of [
makeRequest('settings.getTerminalQuickCommands'),
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'delete', id: 'other' }
})
]) {
const response = await dispatcher.dispatch(request, olderClient)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: supported ? commands : commands.slice(0, 40) }
})
}
}
)
it('publishes every command and prompt to current clients and in-process callers', async () => {
const commands = Array.from({ length: 100 }, (_, index) => ({
...longCommand,
id: `command-${index}`
}))
const { dispatcher } = makeDispatcher(commands)
for (const options of [
{
clientKind: 'runtime' as const,
clientCapabilities: [
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
]
},
undefined
]) {
const response = await dispatcher.dispatch(
makeRequest('settings.getTerminalQuickCommands'),
options
)
expect(response).toMatchObject({ ok: true, result: { terminalQuickCommands: commands } })
}
})
})
@@ -0,0 +1,56 @@
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import {
applyTerminalQuickCommandMutation,
type TerminalQuickCommandMutation
} from '../../../../shared/terminal-quick-commands'
import { isLegacyReadableTerminalQuickCommand } from '../../../../shared/terminal-quick-command-prompt-limit'
import {
LEGACY_MAX_QUICK_COMMANDS,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from '../../../../shared/terminal-quick-command-capabilities'
import { InvalidArgumentError, type RpcContext } from '../core'
type QuickCommandReader = Pick<RpcContext, 'clientKind' | 'clientCapabilities'>
export function projectTerminalQuickCommandsForClient(
commands: TerminalQuickCommand[],
ctx: QuickCommandReader
): TerminalQuickCommand[] {
if (ctx.clientKind === undefined) {
return commands
}
const readable = ctx.clientCapabilities?.includes(
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
)
? commands
: commands.filter(isLegacyReadableTerminalQuickCommand)
return ctx.clientCapabilities?.includes(TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY)
? readable
: readable.slice(0, LEGACY_MAX_QUICK_COMMANDS)
}
export function assertTerminalQuickCommandMutationReadableForClient(
commands: TerminalQuickCommand[],
mutation: TerminalQuickCommandMutation,
ctx: QuickCommandReader
): void {
if (mutation.type === 'delete') {
return
}
const existing = commands.find((command) => command.id === mutation.command.id)
const next = applyTerminalQuickCommandMutation(commands, mutation)
if (
(existing &&
!projectTerminalQuickCommandsForClient(commands, ctx).some(
(command) => command.id === existing.id
)) ||
!projectTerminalQuickCommandsForClient(next, ctx).some(
(command) => command.id === mutation.command.id
)
) {
throw new InvalidArgumentError(
'Update this Orca client to save this quick command. This version cannot display it in the saved list.'
)
}
}
@@ -1,95 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-command-prompt-limit'
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../../../shared/terminal-quick-command-capabilities'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { CLIENT_UI_METHODS } from './client-ui'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('quick-command prompts longer than older clients accept', () => {
const shortCommand: TerminalQuickCommand = {
id: 'status',
label: 'Status',
action: 'terminal-command',
command: 'git status',
appendEnter: true,
scope: { type: 'global' }
}
const longCommand: TerminalQuickCommand = {
id: 'review',
label: 'Review',
action: 'agent-prompt',
agent: 'claude',
prompt: 'x'.repeat(LEGACY_MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH + 1),
scope: { type: 'global' }
}
function makeDispatcher(): {
dispatcher: RpcDispatcher
update: ReturnType<typeof vi.fn>
} {
const update = vi.fn(() => [shortCommand, longCommand])
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers call only the quick-command methods stubbed here.
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientTerminalQuickCommands: vi.fn(() => [shortCommand, longCommand]),
updateClientTerminalQuickCommands: update
} as unknown as OrcaRuntimeService
return { dispatcher: new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }), update }
}
it('stores a long prompt instead of refusing it', async () => {
const { dispatcher, update } = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'upsert', command: longCommand }
})
)
expect(response).toMatchObject({ ok: true })
expect(update).toHaveBeenCalledWith({ type: 'upsert', command: longCommand })
})
it('withholds long prompts from a client that cannot read them', async () => {
const { dispatcher } = makeDispatcher()
const olderClient = { clientKind: 'mobile' as const, clientCapabilities: [] }
for (const request of [
makeRequest('settings.getTerminalQuickCommands'),
makeRequest('settings.updateTerminalQuickCommands', {
mutation: { type: 'delete', id: 'other' }
})
]) {
const response = await dispatcher.dispatch(request, olderClient)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [shortCommand] }
})
}
})
it('publishes long prompts to clients that advertise support and to in-process callers', async () => {
const { dispatcher } = makeDispatcher()
for (const options of [
{
clientKind: 'mobile' as const,
clientCapabilities: [TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY]
},
undefined
]) {
const response = await dispatcher.dispatch(
makeRequest('settings.getTerminalQuickCommands'),
options
)
expect(response).toMatchObject({
ok: true,
result: { terminalQuickCommands: [shortCommand, longCommand] }
})
}
})
})
@@ -1,32 +0,0 @@
// Transitional: remove once no supported release lacks TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY.
//
// A client from before long prompts rejects the whole quick-command list if one prompt is longer
// than its old cap, so the host leaves those commands out of what it publishes to that client.
// Omitting beats trimming: a trimmed copy would run the wrong prompt, and saving it would
// overwrite the stored one.
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import { isLegacyReadableTerminalQuickCommand } from '../../../../shared/terminal-quick-command-prompt-limit'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from '../../../../shared/terminal-quick-command-capabilities'
import type { RpcContext } from '../core'
type QuickCommandReader = Pick<RpcContext, 'clientKind' | 'clientCapabilities'>
function readsLongPrompts(ctx: QuickCommandReader): boolean {
// An in-process caller is this build; only a negotiated client can predate long prompts.
return (
ctx.clientKind === undefined ||
ctx.clientCapabilities?.includes(TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY) ===
true
)
}
export function projectTerminalQuickCommandsForClient(
commands: TerminalQuickCommand[],
ctx: QuickCommandReader
): TerminalQuickCommand[] {
if (readsLongPrompts(ctx) || commands.every(isLegacyReadableTerminalQuickCommand)) {
return commands
}
return commands.filter(isLegacyReadableTerminalQuickCommand)
}
@@ -5,7 +5,6 @@ import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
@@ -213,13 +212,6 @@ export class RuntimeClientSettingsController {
throw new Error('runtime_unavailable')
}
const current = this.getTerminalQuickCommands()
if (
mutation.type === 'upsert' &&
!current.some((command) => command.id === mutation.command.id) &&
current.length >= MAX_QUICK_COMMANDS
) {
throw new Error('Quick command limit reached')
}
const next = applyTerminalQuickCommandMutation(current, mutation)
this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true })
return this.getTerminalQuickCommands()
@@ -178,4 +178,20 @@ describe('terminal quick command host collections', () => {
expect(set).toHaveBeenCalledWith({ terminalQuickCommands: [savedCommand] })
expect(store.getState().settings?.terminalQuickCommands).toEqual([savedCommand])
})
it('saves a new local command when forty commands already exist', async () => {
const commands = Array.from({ length: 40 }, (_, index) => ({
...savedCommand,
id: `command-${index}`,
scope: { type: 'repo' as const, repoId: `missing-project-${index}` }
}))
const set = vi.fn(async (updates: { terminalQuickCommands: TerminalQuickCommand[] }) => updates)
vi.stubGlobal('window', { api: { settings: { set } } })
const store = createTestStore()
store.setState({ settings: { ...getDefaultSettings('/tmp'), terminalQuickCommands: commands } })
expect(await store.getState().upsertTerminalQuickCommand('local', savedCommand)).toBe(true)
expect(set).toHaveBeenCalledWith({ terminalQuickCommands: [...commands, savedCommand] })
expect(store.getState().settings?.terminalQuickCommands).toEqual([...commands, savedCommand])
})
})
@@ -12,7 +12,10 @@ import {
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
type RuntimeCapability
} from './protocol-version'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from './terminal-quick-command-capabilities'
import {
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from './terminal-quick-command-capabilities'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from './sidebar-inputs-switch'
@@ -38,6 +41,7 @@ export function remoteRuntimeClientCapabilities(
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
// Switch point: every desktop socket, browser hosting's included, leaves the unread flag to
// a host that raises it.
...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? [WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY] : []),
@@ -7,7 +7,13 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
export const TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY =
'terminal.quick-commands.long-prompts.v1' as const
// Older clients reject lists above forty; publish only a readable subset to them.
export const LEGACY_MAX_QUICK_COMMANDS = 40
export const TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY =
'terminal.quick-commands.large-lists.v1' as const
export const TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES = [
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY
] as const
@@ -23,6 +23,20 @@ describe('terminal quick commands', () => {
expect(normalizeTerminalQuickCommands([])).toEqual([])
})
it('preserves lists larger than forty commands across loading and protocol parsing', () => {
const commands = Array.from({ length: 100 }, (_, index) => ({
id: `command-${index}`,
label: `Command ${index}`,
action: 'terminal-command' as const,
command: 'echo saved',
appendEnter: true,
scope: { type: 'repo' as const, repoId: `project-${index}` }
}))
expect(normalizeTerminalQuickCommands(commands)).toEqual(commands)
expect(parseNormalizedTerminalQuickCommands(commands)).toEqual(commands)
})
it('removes quick commands from the abandoned preset rollout', () => {
expect(
normalizeTerminalQuickCommands([
+1 -6
View File
@@ -7,7 +7,6 @@ import type {
TerminalQuickCommandScope
} from './terminal-quick-command-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
@@ -147,10 +146,6 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
appendEnter: record.appendEnter !== false
})
}
if (normalized.length >= MAX_QUICK_COMMANDS) {
break
}
}
return normalized
@@ -212,7 +207,7 @@ function isNormalizedTerminalQuickCommand(value: unknown, expected: TerminalQuic
export function parseNormalizedTerminalQuickCommands(
input: unknown
): TerminalQuickCommand[] | null {
if (!Array.isArray(input) || input.length > MAX_QUICK_COMMANDS) {
if (!Array.isArray(input)) {
return null
}
const normalized = normalizeTerminalQuickCommands(input)
@@ -11,7 +11,10 @@ import {
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from './terminal-quick-command-capabilities'
import {
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY
} from './terminal-quick-command-capabilities'
/** What the browser client advertises in its handshake with the host it is paired to. */
export const WEB_RUNTIME_CLIENT_CAPABILITIES = [
@@ -27,6 +30,7 @@ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMAND_LARGE_LISTS_RUNTIME_CAPABILITY,
// Same renderer as the desktop: a restored, unconfirmed agent row shows as identity only.
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
] as const