diff --git a/src/commands/README.md b/src/commands/README.md index 944db6ce..519eb6f4 100644 --- a/src/commands/README.md +++ b/src/commands/README.md @@ -24,6 +24,7 @@ Each command is a separate TypeScript file that exports: | `/sessions` | `sessions.ts` | List saved sessions | | `/rename` | `rename.ts` | Name the current session for /sessions and /resume | | `/resume` | `resume.ts` | Resume a previous session | +| `/mobile [ios\|android]` | `go.ts`, `mobile/MobileCommand.ts` | Pair the current session with Autohand mobile; `/mobile(ios)`, `/mobile(android)`, `/ios`, and `/android` use the same flow. `/go` remains compatible. The native iOS app is available first; Android uses the shared protocol entry point. | | `/handoff web` | `handoff-web.ts` | Continue this conversation in Web; `--workspace` includes repository changes, `--new` starts a new Web conversation instead of resuming the one this session was imported from, and `--no-open` prints the link | | `/memory` | `memory.ts` | List memory or inspect, zoom, forget derived summaries, rebuild projections, and delete entries | | `/feedback` | `feedback.ts` | Send feedback with the session transcript; without a message, opens the survey line | diff --git a/src/commands/go.ts b/src/commands/go.ts index 4475dd0e..f5696a97 100644 --- a/src/commands/go.ts +++ b/src/commands/go.ts @@ -39,6 +39,7 @@ import { type MobileRelayController, } from '../mobile/MobileRelay.js'; import { MobileTerminalReporter } from '../mobile/MobileTerminalReporter.js'; +import { mobilePairingOptions, MOBILE_COMMAND_USAGE } from '../mobile/MobileCommand.js'; export const metadata: SlashCommand = { command: '/go', @@ -46,6 +47,18 @@ export const metadata: SlashCommand = { implemented: true, }; +export const mobileMetadata: SlashCommand = { + command: '/mobile', + description: 'pair this session with the Autohand mobile app (iOS or Android)', + implemented: true, +}; +export const iosMetadata: SlashCommand = { + ...mobileMetadata, command: '/ios', description: 'open /mobile for iOS pairing', +}; +export const androidMetadata: SlashCommand = { + ...mobileMetadata, command: '/android', description: 'open /mobile for Android pairing', +}; + export const handoffSessionMetadata: SlashCommand = { command: '/handoff session', description: 'handoff this session to the Autohand Code iOS app', @@ -150,6 +163,8 @@ function parseMode(args: string[], canSteer: boolean): GoMode { } export async function go(ctx: GoContext, args: string[] = []): Promise { + const options = mobilePairingOptions(args); + if (!options || options.help) return MOBILE_COMMAND_USAGE; const mode = parseMode(args, Boolean(ctx.enqueueInstruction)); if (mode === 'steer' && !ctx.enqueueInstruction) { @@ -197,6 +212,7 @@ export async function go(ctx: GoContext, args: string[] = []): Promise { + const mobile = parseMobileSlashCommand([command, ...args].join(' ')); + if (mobile && command !== '/mobile') return this.handle('/mobile', mobile.args); if (command === LEGACY_BROWSER_SLASH_COMMAND) { if (!this.commandMap.has(BROWSER_SLASH_COMMAND)) { return this.formatUnsupported(command); @@ -385,6 +390,7 @@ export class SlashCommandHandler { }); return null; } + case '/mobile': case '/go': { const { go } = await import('../commands/go.js'); return go({ diff --git a/src/core/slashCommands.ts b/src/core/slashCommands.ts index ec45c17b..136adf89 100644 --- a/src/core/slashCommands.ts +++ b/src/core/slashCommands.ts @@ -136,6 +136,9 @@ export const SLASH_COMMANDS: SlashCommand[] = ([ automode.metadata, share.metadata, goCmd.metadata, + goCmd.mobileMetadata, + goCmd.iosMetadata, + goCmd.androidMetadata, handoffWebCmd.handoffMetadata, cloudCmd.metadata, cloudCmd.handoffMetadata, diff --git a/src/mobile/MobileCommand.ts b/src/mobile/MobileCommand.ts new file mode 100644 index 00000000..78828946 --- /dev/null +++ b/src/mobile/MobileCommand.ts @@ -0,0 +1,23 @@ +export type MobilePlatform = 'ios' | 'android'; + +export const MOBILE_COMMAND_USAGE = 'Usage: /mobile [ios|android] [--steer|--queue]'; + +export function parseMobileSlashCommand(input: string): { command: '/mobile'; args: string[] } | null { + const match = /^\/(mobile|ios|android)(?:\(([^)\s]+)\))?(?:\s+([\s\S]*))?$/i.exec(input.trim()); + if (!match) return null; + const name = match[1].toLowerCase(); + const platform = name === 'mobile' ? match[2]?.toLowerCase() : name; + if (name !== 'mobile' && match[2]) return null; + const args = match[3]?.trim().split(/\s+/).filter(Boolean) ?? []; + return { command: '/mobile', args: platform ? [platform, ...args] : args }; +} + +export function mobilePairingOptions(args: readonly string[]): + | { platform: MobilePlatform; help: boolean } + | null { + const platform = args.find(arg => !arg.startsWith('--'))?.toLowerCase() ?? 'ios'; + if (platform !== 'ios' && platform !== 'android') return null; + if (args.some(arg => !['ios', 'android'].includes(arg.toLowerCase()) && !['--steer', '--queue', '--help'].includes(arg))) return null; + if (args.filter(arg => !arg.startsWith('--')).length > 1) return null; + return { platform, help: args.includes('--help') }; +} diff --git a/tests/commands/go.test.ts b/tests/commands/go.test.ts index f148e40f..05814527 100644 --- a/tests/commands/go.test.ts +++ b/tests/commands/go.test.ts @@ -108,7 +108,20 @@ describe('/go command', () => { expect(stripAnsi(result || '')).toContain('No active session to pair.'); }); - it('creates a mobile handoff and renders the returned QR link', async () => { + it('rejects unsupported mobile platforms before registering a device', async () => { + const registerDevice = vi.fn(); + const result = await go({ + sessionManager: createSessionManager(createSession()), + workspaceRoot: '/Users/test/project', + model: 'test-model', + config: { configPath: '/unused/config.json', auth: { token: 'token' } }, + client: { registerDevice } as unknown as MobileHandoffClientLike, + }, ['windows']); + expect(stripAnsi(result || '')).toContain('Usage: /mobile [ios|android]'); + expect(registerDevice).not.toHaveBeenCalled(); + }); + + it.each(['ios', 'android'] as const)('creates a %s mobile handoff and renders the returned QR link', async (platform) => { const client: MobileHandoffClientLike = { getDeviceId: vi.fn().mockResolvedValue('device-1'), registerDevice: vi.fn().mockResolvedValue(undefined), @@ -140,7 +153,7 @@ describe('/go command', () => { auth: { token: 'token', user: { id: 'user-1', email: 'user@example.com', name: 'User' } }, }, client, - }); + }, [platform]); const output = stripAnsi(result || ''); expect(output).toContain('Autohand Code mobile handoff'); @@ -155,6 +168,7 @@ describe('/go command', () => { ); expect(output).toContain('autohand-code://go?pairing=pairing-1&token=secret'); expect(output).toContain('https://autohand.ai/code/go?pairing=pairing-1&token=secret'); + expect(output).toContain(`Platform: ${platform === 'ios' ? 'iOS' : 'Android'}`); expect(output).toContain('Mode: queue'); expect(output).toContain('Relay: prompts will wait in the queue'); expect(client.registerDevice).toHaveBeenCalledWith('token', expect.objectContaining({ @@ -174,6 +188,7 @@ describe('/go command', () => { capabilities: ['prompt', 'approval', 'notifications'], metadata: expect.objectContaining({ sessionSnapshot: expect.any(String), + targetPlatform: platform, }), })); const payload = (client.createPairing as ReturnType).mock.calls[0][1]; diff --git a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts index db441473..02a01f49 100644 --- a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts +++ b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts @@ -213,3 +213,17 @@ describe('runAgentSlashCommandWithInput', () => { } }); }); + + +describe('native mobile pairing shortcuts', () => { + it.each([ + ['/mobile', []], + ['/mobile ios', ['ios']], + ['/mobile(ios)', ['ios']], + ['/mobile(android) --queue', ['android', '--queue']], + ['/ios --steer', ['ios', '--steer']], + ['/android', ['android']], + ])('routes %s to the canonical mobile command', (input, args) => { + expect(parseAgentSlashCommand({} as never, input)).toEqual({ command: '/mobile', args }); + }); +}); diff --git a/tests/mobile/MobileCommand.test.ts b/tests/mobile/MobileCommand.test.ts new file mode 100644 index 00000000..f5beb79a --- /dev/null +++ b/tests/mobile/MobileCommand.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { mobilePairingOptions, parseMobileSlashCommand } from '../../src/mobile/MobileCommand.js'; + +describe('mobile pairing command syntax', () => { + it.each(['/mobile(ios)', '/ios', '/MOBILE(IOS)'])('normalizes %s without producing an agent prompt', input => { + expect(parseMobileSlashCommand(input)).toEqual({ command: '/mobile', args: ['ios'] }); + }); + it.each(['/mobilex', 'please /mobile', '/ios(foo)', '/model ios'])('does not capture unrelated input %s', input => { + expect(parseMobileSlashCommand(input)).toBeNull(); + }); + it.each([['windows'], ['ios', 'android'], ['--unknown'], ['--QUEUE'], ['--HELP']])('rejects invalid options %j', (...args) => { + expect(mobilePairingOptions(args)).toBeNull(); + }); + it('defaults bare /mobile to the native iOS app and retains explicit queue/steer options', () => { + expect(mobilePairingOptions([])).toEqual({ platform: 'ios', help: false }); + expect(mobilePairingOptions(['android', '--queue'])).toEqual({ platform: 'android', help: false }); + expect(mobilePairingOptions(['--help'])).toEqual({ platform: 'ios', help: true }); + }); +}); diff --git a/tests/slashCommands.spec.ts b/tests/slashCommands.spec.ts index e116825b..8d49bf85 100644 --- a/tests/slashCommands.spec.ts +++ b/tests/slashCommands.spec.ts @@ -13,7 +13,7 @@ describe('slash commands registry', () => { '/quit', '/exit', '/model', '/session', '/sessions', '/resume', '/init', '/agents', '/agents new', '/feedback', '/bug', '/bug-report', '/help', '/?', '/undo', '/new', '/memory', '/browser', '/review', '/pr-review', - '/usage', '/upgrade', '/go', '/handoff', '/handoff session', '/handoff web', '/statusline', '/goal', '/goals', '/whatityped', '/rename' + '/usage', '/upgrade', '/go', '/mobile', '/ios', '/android', '/handoff', '/handoff session', '/handoff web', '/statusline', '/goal', '/goals', '/whatityped', '/rename' ]; expected.forEach((cmd) => expect(commands).toContain(cmd)); expect(commands).not.toContain('/chrome'); diff --git a/tests/tuistory/built-cli-commands.tuistory.test.ts b/tests/tuistory/built-cli-commands.tuistory.test.ts index 70a4ce48..b61b697b 100644 --- a/tests/tuistory/built-cli-commands.tuistory.test.ts +++ b/tests/tuistory/built-cli-commands.tuistory.test.ts @@ -571,7 +571,7 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands, await exitInteractive(session); }); - it('renders a scan-grade, high-contrast QR code with a full quiet zone for /go', async () => { + it.each(['/go --queue', '/mobile(ios) --queue', '/mobile(android) --queue', '/ios --queue', '/android --queue'])('renders a scan-grade QR code and restores the composer for %s', async (command) => { const state = await createTempAutohandHome(); tempStates.push(state); const pairingPreload = await createMockMobilePairingFetchPreload(); @@ -597,7 +597,8 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands, })); await waitForComposer(session); - await session.type('/go --queue'); + await session.type(command); + await session.text({ waitFor: text => text.includes(`❯ ${command}`), timeout: 5_000 }); await session.press('enter'); const screen = stripAnsi(await session.text({ timeout: 15_000, @@ -610,7 +611,8 @@ describe('interactive built CLI Tuistory tests: steering, caret, slash commands, trimEnd: true, })); const lines = screen.split('\n'); - const instructionsIndex = lines.findIndex((line) => line.includes('Scan this with the iOS app')); + const instructionsIndex = lines.findIndex((line) => line.includes(command.includes('android') + ? 'Use the shared mobile pairing flow for Android' : 'Scan this with the iOS app')); const linkIndex = lines.findIndex((line) => line.includes('Scan or open:')); const qrLines = lines .slice(instructionsIndex + 1, linkIndex)