Update files

This commit is contained in:
Mohamed Boudra
2026-02-09 21:26:12 +07:00
parent ab23d5d9b1
commit 7a21a5cce1
7 changed files with 465 additions and 167 deletions

View File

@@ -31,6 +31,8 @@ import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
import { useVoiceOptional } from "@/contexts/voice-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
type QueuedMessage = {
id: string;
@@ -512,10 +514,23 @@ export function AgentInputArea({
},
[setUserInput]
);
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
// Handle keyboard navigation for command autocomplete
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (
event.key === "Escape" &&
isAgentRunning &&
!hasSendableContent &&
!isCancellingAgent &&
isConnected
) {
event.preventDefault();
handleCancelAgent();
return true;
}
if (!showCommandAutocomplete || filteredCommands.length === 0) {
return false;
}
@@ -558,54 +573,72 @@ export function AgentInputArea({
filteredCommands,
commandSelectedIndex,
handleCommandSelect,
hasSendableContent,
isAgentRunning,
isCancellingAgent,
isConnected,
setUserInput,
]
);
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
const cancelButton = isAgentRunning && !hasSendableContent ? (
<Pressable
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent
? styles.buttonDisabled
: undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={18} color="white" fill="white" />
)}
</Pressable>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton as any,
(!isConnected || isCancellingAgent
? styles.buttonDisabled
: undefined) as any,
]}
>
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={18} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null;
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent ? (
<Pressable
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable realtime voice mode"
accessibilityRole="button"
style={[
styles.realtimeVoiceButton as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={18} color={theme.colors.foreground} />
)}
</Pressable>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={[
styles.realtimeVoiceButton as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={18} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
<Shortcut keys={["mod", "shift", "D"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
@@ -647,7 +680,7 @@ export function AgentInputArea({
onPress={() => handleSendQueuedNow(item.id)}
style={[styles.queueActionButton, styles.queueSendButton]}
>
<ArrowUp size={14} color={theme.colors.background} />
<ArrowUp size={14} color="white" />
</Pressable>
</View>
</View>
@@ -752,6 +785,19 @@ const styles = StyleSheet.create(((theme: Theme) => ({
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},
@@ -765,7 +811,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -790,7 +836,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
backgroundColor: theme.colors.surface2,
},
queueSendButton: {
backgroundColor: theme.colors.palette.blue[600],
backgroundColor: theme.colors.accent,
},
sendErrorText: {
color: theme.colors.palette.red[500],

View File

@@ -377,14 +377,14 @@ const styles = StyleSheet.create((theme) => ({
modeBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius["2xl"],
},
modeBadgeHovered: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface2,
},
modeBadgePressed: {
backgroundColor: theme.colors.surface0,

View File

@@ -1,5 +1,6 @@
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
@@ -31,6 +32,8 @@ import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
export interface ImageAttachment {
uri: string;
@@ -217,6 +220,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const socketConnected = client?.isConnected ?? false;
return socketConnected;
}, [client]);
const isConnected = client?.isConnected ?? false;
const {
isRecording: isDictating,
@@ -263,9 +267,39 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
sendAfterTranscriptRef.current = false;
}, [dictationStatus, isDictating, isDictationProcessing]);
// Cmd+D to start/submit dictation, Escape to cancel
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
useEffect(() => {
if (!IS_WEB) return;
const toggleRealtimeVoice = () => {
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return;
}
if (voice.isVoiceSwitching) {
return;
}
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
const tasks: Promise<unknown>[] = [];
if (isAgentRunning && client) {
tasks.push(client.cancelAgent(voiceAgentId));
}
tasks.push(voice.stopVoice());
void Promise.allSettled(tasks).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.error(
"[MessageInput] Failed to stop realtime voice",
result.reason
);
}
});
});
return;
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
});
};
const resolveNativeInput = (): unknown => {
const current = textInputRef.current as any;
if (!current) return null;
@@ -280,12 +314,32 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
return;
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
event.preventDefault();
toggleRealtimeVoice();
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat
) {
event.preventDefault();
voice?.toggleMute();
return;
}
const dictating = isDictatingRef.current;
// Cmd+D: start dictation or submit if already dictating
if (
(event.metaKey || event.ctrlKey) &&
(event.code === "KeyD" || event.key.toLowerCase() === "d")
) {
if (isMod && isKeyD) {
event.preventDefault();
if (dictating) {
sendAfterTranscriptRef.current = true;
@@ -303,7 +357,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [cancelDictation, confirmDictation, isScreenFocused, startDictation]);
}, [
cancelDictation,
client,
confirmDictation,
disabled,
isAgentRunning,
isConnected,
isRealtimeVoiceForCurrentAgent,
isScreenFocused,
startDictation,
voiceAgentId,
voiceServerId,
voice,
]);
// Animate overlay
useEffect(() => {
@@ -381,6 +448,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
});
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]);
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return;
}
if (voice.isVoiceSwitching) {
return;
}
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
void handleStopRealtimeVoice();
return;
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
});
}, [
disabled,
handleStopRealtimeVoice,
isConnected,
voice,
voiceAgentId,
voiceServerId,
]);
const handleSendMessage = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
@@ -514,6 +604,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return;
}
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
event.preventDefault();
handleToggleRealtimeVoiceShortcut();
return;
}
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
if ((metaKey || ctrlKey) && key === "d") {
event.preventDefault();
@@ -533,6 +630,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!metaKey &&
!ctrlKey &&
!shiftKey &&
event.nativeEvent.key === " "
) {
event.preventDefault();
voice?.toggleMute();
return;
}
if (event.nativeEvent.key !== "Enter") return;
// Shift+Enter: add newline (default behavior, don't intercept)
@@ -555,7 +664,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const isConnected = client?.isConnected ?? false;
return (
<View style={styles.container}>
@@ -636,90 +744,130 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
style={[
styles.attachButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</Pressable>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={[
styles.attachButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
</TooltipContent>
</Tooltip>
)}
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
<Pressable
onPress={handleVoicePress}
disabled={!isConnected || disabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? "Unmute realtime voice"
: "Mute realtime voice"
: isDictating
? "Stop dictation"
: "Start dictation"
}
style={[
styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={14} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={20} color={theme.colors.foreground} />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</Pressable>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
disabled={!isConnected || disabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? "Unmute Voice mode"
: "Mute Voice mode"
: isDictating
? "Stop dictation"
: "Start dictation"
}
style={[
styles.voiceButton,
(!isConnected || disabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={14} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={20} color={theme.colors.foreground} />
) : (
<Mic size={20} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>
{isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? "Unmute voice"
: "Mute voice"
: "Dictation"}
</Text>
<Shortcut
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
style={styles.tooltipShortcut}
/>
</View>
</TooltipContent>
</Tooltip>
{rightContent}
{shouldShowSendButton && isAgentRunning && onQueue && (
<Pressable
onPress={handleQueueMessage}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={[
styles.queueButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={20} color="white" />
</Pressable>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={[
styles.queueButton,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={20} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
)}
{shouldShowSendButton && (
<Pressable
onPress={handleSendMessage}
disabled={
!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(!isConnected ||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={20} color="white" />
)}
</Pressable>
disabled
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(!isConnected ||
isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={20} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
)}
</View>
</View>
@@ -775,7 +923,7 @@ const styles = StyleSheet.create(((theme: any) => ({
inputWrapper: {
flexDirection: "column",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius["2xl"],
@@ -898,6 +1046,19 @@ const styles = StyleSheet.create(((theme: any) => ({
alignItems: "center",
justifyContent: "center",
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},

View File

@@ -463,9 +463,9 @@ const styles = StyleSheet.create((theme) => ({
content: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,

View File

@@ -68,6 +68,11 @@ import type {
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
} from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
listDirectoryEntries,
@@ -130,18 +135,6 @@ const MAX_AGENTS_PER_PROJECT = 5;
* Uses Claude Haiku for speed and cost efficiency.
*/
const AUTO_GEN_MODEL = "haiku";
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
"You are the Paseo voice assistant.",
"The user cannot see your chat messages or tool calls.",
"Always use the speak tool for all user-facing communication.",
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
"For long-running work, use speak to provide progress updates before and during execution.",
"Treat the user input as transcribed speech and confirm intent in your first speak response to catch transcription errors early.",
"If the transcription seems incomplete, cut off, or ambiguous, ask a clarifying question via speak before taking action.",
"Use concise plain language suitable for speech output.",
"Never use bash, file-edit, or web tools directly.",
"Only use the paseo MCP tools.",
].join(" ");
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
@@ -222,25 +215,6 @@ function escapeXmlText(value: string): string {
.replace(/>/g, "&gt;");
}
export function buildVoiceAgentMcpServerConfig(params: {
command: string;
baseArgs: string[];
socketPath: string;
env?: Record<string, string>;
}): {
type: "stdio";
command: string;
args: string[];
env?: Record<string, string>;
} {
return {
type: "stdio",
command: params.command,
args: [...params.baseArgs, "--socket", params.socketPath],
...(params.env ? { env: params.env } : {}),
};
}
type ProcessingPhase = "idle" | "transcribing" | "llm";
type NormalizedGitOptions = {
@@ -1541,13 +1515,6 @@ export class Session {
return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>;
}
private buildVoiceModeSystemPrompt(existing?: string): string {
const chunks = [existing?.trim(), VOICE_AGENT_SYSTEM_INSTRUCTION].filter(
(value): value is string => Boolean(value && value.length > 0)
);
return chunks.join("\n\n");
}
private buildVoiceModeMcpServers(
existing: Record<string, McpServerConfig> | undefined,
socketPath: string
@@ -1579,12 +1546,12 @@ export class Session {
this.registerVoiceBridgeForAgent(agentId);
const baseConfig: VoiceModeBaseConfig = {
systemPrompt: existing.config.systemPrompt,
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
};
this.voiceModeBaseConfig = baseConfig;
const refreshOverrides: Partial<AgentSessionConfig> = {
systemPrompt: this.buildVoiceModeSystemPrompt(baseConfig.systemPrompt),
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
};
@@ -1625,7 +1592,7 @@ export class Session {
const baseConfig = this.voiceModeBaseConfig;
try {
await this.agentManager.refreshAgentFromPersistence(agentId, {
systemPrompt: baseConfig.systemPrompt,
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
});
} catch (error) {

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { buildVoiceAgentMcpServerConfig } from "./session.js";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
} from "./voice-config.js";
describe("voice MCP stdio config", () => {
test("builds stdio MCP config for voice agent", () => {
@@ -25,3 +29,48 @@ describe("voice MCP stdio config", () => {
});
});
});
describe("voice mode prompt instructions", () => {
test("builds enabled voice instructions and preserves base prompt", () => {
const prompt = buildVoiceModeSystemPrompt("Base system prompt", true);
expect(prompt).toContain("Base system prompt");
expect(prompt).toContain("<paseo_voice_mode>");
expect(prompt).toContain("Paseo voice mode is now on.");
expect(prompt).toContain("Always use the speak tool for all user-facing communication.");
expect(prompt).toContain("</paseo_voice_mode>");
});
test("builds disabled voice instructions and supersedes previous voice block", () => {
const existing = [
"Base system prompt",
"<paseo_voice_mode>",
"legacy voice instruction",
"</paseo_voice_mode>",
].join("\n\n");
const prompt = buildVoiceModeSystemPrompt(existing, false);
expect(prompt).toContain("Base system prompt");
expect(prompt).toContain("Paseo voice mode is now off.");
expect(prompt).toContain("Ignore any earlier Paseo voice mode instructions in this thread.");
expect(prompt.match(/<paseo_voice_mode>/g)?.length ?? 0).toBe(1);
expect(prompt).not.toContain("legacy voice instruction");
});
test("strips voice blocks from persisted prompt", () => {
const existing = [
"Base system prompt",
"<paseo_voice_mode>",
"legacy voice instruction",
"</paseo_voice_mode>",
].join("\n\n");
expect(stripVoiceModeSystemPrompt(existing)).toBe("Base system prompt");
expect(
stripVoiceModeSystemPrompt(
["<paseo_voice_mode>", "legacy voice instruction", "</paseo_voice_mode>"].join("\n\n")
)
).toBeUndefined();
});
});

View File

@@ -0,0 +1,75 @@
const VOICE_PROMPT_BLOCK_START = "<paseo_voice_mode>";
const VOICE_PROMPT_BLOCK_END = "</paseo_voice_mode>";
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
"Paseo voice mode is now on.",
"You are the Paseo voice assistant.",
"The user cannot see your chat messages or tool calls.",
"Always use the speak tool for all user-facing communication.",
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
"For long-running work, use speak to provide progress updates before and during execution.",
"Treat the user input as transcribed speech.",
"If the user intent is clear, proceed without extra confirmation.",
"If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via speak before taking action.",
"Use concise plain language suitable for speech output.",
].join(" ");
const VOICE_AGENT_DISABLED_INSTRUCTION = [
"Paseo voice mode is now off.",
"Ignore any earlier Paseo voice mode instructions in this thread.",
].join(" ");
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function makeVoicePromptBlockRegex(): RegExp {
return new RegExp(
`${escapeRegExp(VOICE_PROMPT_BLOCK_START)}[\\s\\S]*?${escapeRegExp(
VOICE_PROMPT_BLOCK_END
)}`,
"g"
);
}
export function stripVoiceModeSystemPrompt(existing?: string): string | undefined {
const trimmed = existing?.trim();
if (!trimmed) {
return undefined;
}
const stripped = trimmed.replace(makeVoicePromptBlockRegex(), "").trim();
return stripped.length > 0 ? stripped : undefined;
}
export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled: boolean): string {
const basePrompt = stripVoiceModeSystemPrompt(existing);
const voiceInstruction = enabled
? VOICE_AGENT_SYSTEM_INSTRUCTION
: VOICE_AGENT_DISABLED_INSTRUCTION;
const voiceBlock = [VOICE_PROMPT_BLOCK_START, voiceInstruction, VOICE_PROMPT_BLOCK_END].join(
"\n"
);
return [basePrompt, voiceBlock]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
}
export function buildVoiceAgentMcpServerConfig(params: {
command: string;
baseArgs: string[];
socketPath: string;
env?: Record<string, string>;
}): {
type: "stdio";
command: string;
args: string[];
env?: Record<string, string>;
} {
return {
type: "stdio",
command: params.command,
args: [...params.baseArgs, "--socket", params.socketPath],
...(params.env ? { env: params.env } : {}),
};
}