Add device pairing command and voice mute toggle

Introduce `paseo daemon pair` CLI command that generates a local pairing offer URL with QR code without requiring a running daemon. Update message input to support mute/unmute toggle during realtime voice sessions with visual feedback (MicOff icon). Fix android:prod build script to use prebuild + assembleRelease flow.
This commit is contained in:
Mohamed Boudra
2026-02-07 15:56:14 +07:00
parent 902a415911
commit f9ebf6b523
8 changed files with 164 additions and 21 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh", "android:debug": "APP_VARIANT=debug bash ./scripts/android-run-variant.sh",
"android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-variant.sh", "android:fast:build": "APP_VARIANT=fast bash ./scripts/android-build-variant.sh",
"android:debug:build": "APP_VARIANT=debug bash ./scripts/android-build-variant.sh", "android:debug:build": "APP_VARIANT=debug bash ./scripts/android-build-variant.sh",
"android:prod": "APP_VARIANT=prod expo run:android --variant=release", "android:prod": "APP_VARIANT=prod npx expo prebuild --platform android --clean --no-install && (cd android && ./gradlew clean app:assembleRelease --rerun-tasks) && adb install -r android/app/build/outputs/apk/release/app-release.apk",
"android:release": "npm run android:prod", "android:release": "npm run android:prod",
"ios": "expo run:ios", "ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release", "ios:release": "expo run:ios --configuration Release",
+31 -10
View File
@@ -18,7 +18,7 @@ import {
forwardRef, forwardRef,
} from "react"; } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, ArrowUp, Paperclip, X, Square } from "lucide-react-native"; import { Mic, MicOff, ArrowUp, Paperclip, X, Square } from "lucide-react-native";
import Animated, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
@@ -244,6 +244,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictatingRef.current = isDictating; isDictatingRef.current = isDictating;
}, [isDictating]); }, [isDictating]);
const isRealtimeVoiceForCurrentAgent =
!!voice &&
!!voiceServerId &&
!!voiceAgentId &&
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
useEffect(() => { useEffect(() => {
if (isDictating || isDictationProcessing) { if (isDictating || isDictationProcessing) {
return; return;
@@ -314,20 +320,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
})); }));
const handleVoicePress = useCallback(async () => { const handleVoicePress = useCallback(async () => {
const isRealtimeVoiceForCurrentAgent = if (isRealtimeVoiceForCurrentAgent && voice) {
voice && voice.toggleMute();
!!voiceServerId && return;
!!voiceAgentId && }
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
if (isDictating) { if (isDictating) {
await cancelDictation(); await cancelDictation();
} else { } else {
if (isRealtimeVoiceForCurrentAgent) {
await voice.stopVoice();
}
await startDictation(); await startDictation();
} }
}, [isDictating, cancelDictation, startDictation, voice, voiceAgentId, voiceServerId]); }, [
cancelDictation,
isDictating,
isRealtimeVoiceForCurrentAgent,
startDictation,
voice,
]);
const handleCancelRecording = useCallback(async () => { const handleCancelRecording = useCallback(async () => {
await cancelDictation(); await cancelDictation();
@@ -623,6 +632,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Pressable <Pressable
onPress={handleVoicePress} onPress={handleVoicePress}
disabled={!isConnected || disabled} disabled={!isConnected || disabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? "Unmute realtime voice"
: "Mute realtime voice"
: isDictating
? "Stop dictation"
: "Start dictation"
}
style={[ style={[
styles.voiceButton, styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled, (!isConnected || disabled) && styles.buttonDisabled,
@@ -631,6 +650,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
> >
{isDictating ? ( {isDictating ? (
<Square size={14} color="white" fill="white" /> <Square size={14} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={20} color={theme.colors.foreground} />
) : ( ) : (
<Mic size={20} color={theme.colors.foreground} /> <Mic size={20} color={theme.colors.foreground} />
)} )}
@@ -3,12 +3,14 @@ import { startCommand } from './start.js'
import { runStatusCommand } from './status.js' import { runStatusCommand } from './status.js'
import { runStopCommand } from './stop.js' import { runStopCommand } from './stop.js'
import { runRestartCommand } from './restart.js' import { runRestartCommand } from './restart.js'
import { pairCommand } from './pair.js'
import { withOutput } from '../../output/index.js' import { withOutput } from '../../output/index.js'
export function createDaemonCommand(): Command { export function createDaemonCommand(): Command {
const daemon = new Command('daemon').description('Manage the Paseo daemon') const daemon = new Command('daemon').description('Manage the Paseo daemon')
daemon.addCommand(startCommand()) daemon.addCommand(startCommand())
daemon.addCommand(pairCommand())
daemon daemon
.command('status') .command('status')
+42
View File
@@ -0,0 +1,42 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server'
interface PairOptions {
home?: string
}
export function pairCommand(): Command {
return new Command('pair')
.description('Print the daemon pairing QR code and link')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(async (options: PairOptions) => {
await runPairCommand(options)
})
}
export async function runPairCommand(options: PairOptions): Promise<void> {
if (options.home) {
process.env.PASEO_HOME = options.home
}
const paseoHome = resolvePaseoHome()
const config = loadConfig(paseoHome)
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
if (!pairing.relayEnabled || !pairing.url) {
console.error(chalk.red('Relay pairing is disabled for this daemon config.'))
console.error(chalk.yellow('Enable relay and run this command again.'))
process.exit(1)
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
}
+21 -8
View File
@@ -8,6 +8,7 @@
* *
* Tests: * Tests:
* - daemon --help shows subcommands * - daemon --help shows subcommands
* - daemon pair prints a local pairing link without requiring a running daemon
* - daemon status fails gracefully when daemon not running * - daemon status fails gracefully when daemon not running
* - daemon status --json outputs valid JSON (even for errors) * - daemon status --json outputs valid JSON (even for errors)
* - daemon stop handles daemon not running gracefully * - daemon stop handles daemon not running gracefully
@@ -37,12 +38,24 @@ try {
assert(result.stdout.includes('status'), 'help should mention status') assert(result.stdout.includes('status'), 'help should mention status')
assert(result.stdout.includes('stop'), 'help should mention stop') assert(result.stdout.includes('stop'), 'help should mention stop')
assert(result.stdout.includes('restart'), 'help should mention restart') assert(result.stdout.includes('restart'), 'help should mention restart')
assert(result.stdout.includes('pair'), 'help should mention pair')
console.log('✓ daemon --help shows subcommands\n') console.log('✓ daemon --help shows subcommands\n')
} }
// Test 2: daemon status fails gracefully when daemon not running // Test 2: daemon pair works without daemon process
{ {
console.log('Test 2: daemon status fails gracefully when not running') console.log('Test 2: daemon pair prints local pairing URL')
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon pair`.nothrow()
assert.strictEqual(result.exitCode, 0, 'daemon pair should succeed')
assert(result.stdout.includes('Scan to pair:'), 'output should include scan header')
assert(result.stdout.includes('#offer='), 'output should include pairing offer fragment')
console.log('✓ daemon pair prints local pairing URL\n')
}
// Test 3: daemon status fails gracefully when daemon not running
{
console.log('Test 3: daemon status fails gracefully when not running')
const result = const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow() await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
@@ -56,9 +69,9 @@ try {
console.log('✓ daemon status fails gracefully when not running\n') console.log('✓ daemon status fails gracefully when not running\n')
} }
// Test 3: daemon status --json outputs valid JSON (even for errors) // Test 4: daemon status --json outputs valid JSON (even for errors)
{ {
console.log('Test 3: daemon status --json outputs JSON') console.log('Test 4: daemon status --json outputs JSON')
const result = const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow()
// Should still fail (daemon not running) // Should still fail (daemon not running)
@@ -79,9 +92,9 @@ try {
} }
} }
// Test 4: daemon stop handles daemon not running gracefully // Test 5: daemon stop handles daemon not running gracefully
{ {
console.log('Test 4: daemon stop handles daemon not running') console.log('Test 5: daemon stop handles daemon not running')
const result = const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow() await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow()
// Stop should succeed even if daemon not running (idempotent) // Stop should succeed even if daemon not running (idempotent)
@@ -105,9 +118,9 @@ try {
} }
} }
// Test 5: daemon restart fails when daemon not running // Test 6: daemon restart fails when daemon not running
{ {
console.log('Test 5: daemon restart fails when daemon not running') console.log('Test 6: daemon restart fails when daemon not running')
const result = const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow() await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon restart`.nothrow()
// Restart should fail when daemon not running (can't restart something that's not running) // Restart should fail when daemon not running (can't restart something that's not running)
+1
View File
@@ -4,6 +4,7 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js"; export { resolvePaseoHome } from "./paseo-home.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js"; export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
// Agent SDK types for CLI commands // Agent SDK types for CLI commands
@@ -0,0 +1,64 @@
import type { Logger } from "pino";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { renderPairingQr } from "./pairing-qr.js";
import { getOrCreateServerId } from "./server-id.js";
export type LocalPairingOffer = {
relayEnabled: boolean;
url: string | null;
qr: string | null;
};
export async function generateLocalPairingOffer(args: {
paseoHome: string;
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
includeQr?: boolean;
logger?: Logger;
}): Promise<LocalPairingOffer> {
const relayEnabled = args.relayEnabled ?? true;
if (!relayEnabled) {
return {
relayEnabled: false,
url: null,
qr: null,
};
}
const relayEndpoint = args.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = args.relayPublicEndpoint ?? relayEndpoint;
const appBaseUrl = args.appBaseUrl ?? "https://app.paseo.sh";
const serverId = getOrCreateServerId(args.paseoHome, { logger: args.logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(args.paseoHome, args.logger);
const offer = await createConnectionOfferV2({
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint },
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
if (args.includeQr === false) {
return {
relayEnabled: true,
url,
qr: null,
};
}
let qr: string | null = null;
try {
qr = await renderPairingQr(url);
} catch (error) {
args.logger?.debug({ error }, "Failed to render pairing QR");
}
return {
relayEnabled: true,
url,
qr,
};
}
+2 -2
View File
@@ -16,7 +16,7 @@ function shouldPrintPairingQr(): boolean {
return Boolean(process.stdout.isTTY); return Boolean(process.stdout.isTTY);
} }
async function renderQr(url: string): Promise<string> { export async function renderPairingQr(url: string): Promise<string> {
const terminalOptions: QRCodeToStringOptionsTerminal = { const terminalOptions: QRCodeToStringOptionsTerminal = {
type: "terminal", type: "terminal",
small: true, small: true,
@@ -39,7 +39,7 @@ export async function printPairingQrIfEnabled(args: {
}): Promise<void> { }): Promise<void> {
if (!shouldPrintPairingQr()) return; if (!shouldPrintPairingQr()) return;
const qr = await renderQr(args.url); const qr = await renderPairingQr(args.url);
const out = `\nScan to pair:\n${qr}\n${args.url}\n`; const out = `\nScan to pair:\n${qr}\n${args.url}\n`;
try { try {