Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
22 changes: 21 additions & 1 deletion src/commands/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,26 @@ 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',
description: 'pair this session with the Autohand Code iOS app',
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',
Expand Down Expand Up @@ -150,6 +163,8 @@ function parseMode(args: string[], canSteer: boolean): GoMode {
}

export async function go(ctx: GoContext, args: string[] = []): Promise<string | null> {
const options = mobilePairingOptions(args);
if (!options || options.help) return MOBILE_COMMAND_USAGE;
const mode = parseMode(args, Boolean(ctx.enqueueInstruction));

if (mode === 'steer' && !ctx.enqueueInstruction) {
Expand Down Expand Up @@ -197,6 +212,7 @@ export async function go(ctx: GoContext, args: string[] = []): Promise<string |
hostname: os.hostname(),
client: session.metadata.client,
clientVersion: session.metadata.clientVersion,
targetPlatform: options.platform,
},
});

Expand Down Expand Up @@ -226,6 +242,7 @@ export async function go(ctx: GoContext, args: string[] = []): Promise<string |
client: session.metadata.client,
clientVersion: session.metadata.clientVersion,
sessionSnapshot: JSON.stringify(buildMobileSessionSnapshot(session)),
targetPlatform: options.platform,
},
});

Expand Down Expand Up @@ -278,13 +295,16 @@ export async function go(ctx: GoContext, args: string[] = []): Promise<string |
return [
'',
chalk.bold('Autohand Code mobile handoff'),
chalk.gray('Scan this with the iOS app to continue this session from your phone.'),
chalk.gray(options.platform === 'ios'
? 'Scan this with the iOS app to continue this session from your phone.'
: 'Use the shared mobile pairing flow for Android. The native iOS app is available first.'),
'',
qr,
'',
`${chalk.gray('Scan or open:')} ${formatUrl(pairing.pairingUrl)}`,
`${chalk.gray('Simulator fallback:')} ${formatUrl(appUrl)}`,
`${chalk.gray('Project:')} ${chalk.cyan(session.metadata.projectName)}`,
`${chalk.gray('Platform:')} ${options.platform === 'ios' ? 'iOS' : 'Android'}`,
`${chalk.gray('Session:')} ${chalk.cyan(session.metadata.sessionId)}`,
`${chalk.gray('Mode:')} ${mode === 'steer' ? chalk.green('steer live') : chalk.yellow('queue')}`,
`${chalk.gray('Relay:')} ${mode === 'steer' ? chalk.green('listening for mobile prompts') : chalk.yellow('prompts will wait in the queue')}`,
Expand Down
3 changes: 3 additions & 0 deletions src/core/agent/AgentCommandRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { withProgramStatusWait } from './AgentProgramStatus.js';
import type { AgentProgramStatus } from './AgentProgramStatus.js';
import { isAdaptiveSkillsEnabled } from '../../skills/feature.js';
import { isWorkflowDiscoveryShortcut } from '../../discovery/preview.js';
import { parseMobileSlashCommand } from '../../mobile/MobileCommand.js';

export interface AgentCommandRuntimeHost {
[key: string]: any;
Expand Down Expand Up @@ -332,6 +333,8 @@ export function isAgentSlashCommandSupported(host: AgentCommandRuntimeHost, comm

export function parseAgentSlashCommand(host: AgentCommandRuntimeHost, input: string): { command: string; args: string[] } {
if (isWorkflowDiscoveryShortcut(input, host.skillsRegistry, host.runtime?.options?.bare)) return { command: '/discovery', args: [] };
const mobile = parseMobileSlashCommand(input);
if (mobile) return mobile;
const trimmed = input.trim();
const parts = trimmed.split(/\s+/);

Expand Down
6 changes: 6 additions & 0 deletions src/core/slashCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { SlashCommand } from './slashCommands.js';

import type { SlashCommandContext } from './slashCommandTypes.js';
import type { ExtensionRuntimeHost } from '../extensions/ExtensionRuntimeHost.js';
import { parseMobileSlashCommand } from '../mobile/MobileCommand.js';
import {
BROWSER_SLASH_COMMAND,
LEGACY_BROWSER_SLASH_COMMAND,
Expand All @@ -32,6 +33,8 @@ export class SlashCommandHandler {
* Check if a command is supported (exists in the command map)
*/
isCommandSupported(command: string): boolean {
const mobile = parseMobileSlashCommand(command);
if (mobile) return this.commandMap.has('/mobile');
if (command === LEGACY_BROWSER_SLASH_COMMAND) {
return this.commandMap.has(BROWSER_SLASH_COMMAND);
}
Expand All @@ -43,6 +46,8 @@ export class SlashCommandHandler {
}

async handle(command: string, args: string[] = []): Promise<string | null> {
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);
Expand Down Expand Up @@ -385,6 +390,7 @@ export class SlashCommandHandler {
});
return null;
}
case '/mobile':
case '/go': {
const { go } = await import('../commands/go.js');
return go({
Expand Down
3 changes: 3 additions & 0 deletions src/core/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/mobile/MobileCommand.ts
Original file line number Diff line number Diff line change
@@ -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') };
}
19 changes: 17 additions & 2 deletions tests/commands/go.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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');
Expand All @@ -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({
Expand All @@ -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<typeof vi.fn>).mock.calls[0][1];
Expand Down
14 changes: 14 additions & 0 deletions tests/core/agent/AgentCommandRuntime.slashParsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
19 changes: 19 additions & 0 deletions tests/mobile/MobileCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
2 changes: 1 addition & 1 deletion tests/slashCommands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 5 additions & 3 deletions tests/tuistory/built-cli-commands.tuistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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)
Expand Down
Loading