From 6fdfdc15973d8a079c6219ce8cc83207430ce615 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:41:56 -0700 Subject: [PATCH] feat(voice): "Say to end the voice chat" notice on voice-mode start (CLI/TUI/desktop, i18n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One owner for the wording: voice_stop_hint() in tools/voice_mode.py — sources the phrase from voice.stop_phrases (first entry) so a custom phrase renders correctly, and returns "" when the feature is disabled (stop_phrases: []) so no surface shows a hint. - CLI: printed in /voice on output (style-matched dim notice). - TUI: voice.toggle action=on now carries stop_hint; the Ink client renders it in the "Voice mode enabled" block (older gateways omit the field — no hint, no crash). - Desktop: the renderer voice loop never touches tools/voice_mode.py, so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase store, seeded in use-hermes-config) and shown as an info toast when a voice conversation starts. i18n: en/ja/zh/zh-hant/ar. --- .../chat/composer/hooks/use-composer-voice.ts | 24 ++++++++++- .../app/session/hooks/use-hermes-config.ts | 3 +- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/voice-prefs.test.ts | 38 +++++++++++++++++ apps/desktop/src/store/voice-prefs.ts | 25 +++++++++++ apps/desktop/src/types/hermes.ts | 1 + cli.py | 9 ++++ tests/test_tui_gateway_server.py | 41 +++++++++++++++++++ tests/tools/test_voice_stop_phrase.py | 20 +++++++++ tools/voice_mode.py | 15 +++++++ tui_gateway/server.py | 13 ++++++ ui-tui/src/app/slash/commands/session.ts | 8 ++++ ui-tui/src/gatewayTypes.ts | 1 + 18 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/store/voice-prefs.test.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 5184232f87b..3321f909148 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -7,8 +7,8 @@ import { triggerHaptic } from '@/lib/haptics' import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $gateway } from '@/store/gateway' -import { notifyError } from '@/store/notifications' -import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' +import { notify, notifyError } from '@/store/notifications' +import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs' import { resumeWakeAfterVoice } from '@/store/wake-word' import type { ComposerTarget } from '../focus' @@ -208,6 +208,26 @@ export function useComposerVoice({ } }, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive]) + // 'Say "stop" to end the voice chat.' notice when the conversation starts. + // Phrase comes from voice.stop_phrases (first entry) so a custom phrase + // renders correctly; a null phrase (stop_phrases: []) shows no notice. + useEffect(() => { + if (!voiceConversationActive) { + return + } + + const phrase = $voiceStopPhrase.get() + + if (phrase) { + notify({ + id: 'voice-stop-hint', + kind: 'info', + icon: 'mic', + message: t.notifications.voice.sayStopToEnd(phrase) + }) + } + }, [t, voiceConversationActive]) + useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused]) // Explicit start/end for the on-screen conversation controls (the hotkey uses diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 06f5a139367..eeed199a529 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -14,7 +14,7 @@ import { setDefaultReasoningEffort, setIntroPersonality } from '@/store/session' -import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' +import { applyAutoSpeakFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs' const DEFAULT_VOICE_SECONDS = 120 const FAST_TIERS = new Set(['fast', 'priority', 'on']) @@ -105,6 +105,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) applyAutoSpeakFromConfig(config) + applyVoiceStopPhraseFromConfig(config) } catch { // Config is nice-to-have; chat still works without it. } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index a0c2fe06c05..c72898a9cea 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -137,6 +137,7 @@ export const ar = defineLocale({ noSpeechDetected: 'لم يتم اكتشاف كلام', playbackFailed: 'فشل تشغيل الصوت', recordingFailed: 'فشل التسجيل', + sayStopToEnd: phrase => `قل "${phrase}" لإنهاء المحادثة الصوتية.`, transcriptionFailed: 'فشل التفريغ النصي', transcriptionUnavailable: 'التفريغ النصي غير متاح.', tryRecordingAgain: 'حاول التسجيل مرة أخرى.', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 29fa06c439f..d55c494e070 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -154,6 +154,7 @@ export const en: Translations = { noSpeechDetected: 'No speech detected', playbackFailed: 'Voice playback failed', recordingFailed: 'Voice recording failed', + sayStopToEnd: phrase => `Say "${phrase}" to end the voice chat.`, transcriptionFailed: 'Voice transcription failed', transcriptionUnavailable: 'Voice transcription is not available yet.', tryRecordingAgain: 'Try recording again.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 22a11179e08..ad8f206abe2 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -155,6 +155,7 @@ export const ja = defineLocale({ noSpeechDetected: '音声が検出されませんでした', playbackFailed: '音声再生に失敗しました', recordingFailed: '音声録音に失敗しました', + sayStopToEnd: phrase => `「${phrase}」と言うと音声チャットを終了できます。`, transcriptionFailed: '音声文字起こしに失敗しました', transcriptionUnavailable: '音声文字起こしはまだ利用できません。', tryRecordingAgain: 'もう一度録音してください。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9b986fd90a9..8ccc8a034ca 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -195,6 +195,7 @@ export interface Translations { noSpeechDetected: string playbackFailed: string recordingFailed: string + sayStopToEnd: (phrase: string) => string transcriptionFailed: string transcriptionUnavailable: string tryRecordingAgain: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9f1c2474e40..5343d395724 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -150,6 +150,7 @@ export const zhHant = defineLocale({ noSpeechDetected: '未偵測到語音', playbackFailed: '語音播放失敗', recordingFailed: '語音錄製失敗', + sayStopToEnd: phrase => `說「${phrase}」即可結束語音對話。`, transcriptionFailed: '語音轉寫失敗', transcriptionUnavailable: '語音轉寫暫不可用。', tryRecordingAgain: '請再錄製一次。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 7a881a76319..86049412b4c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -150,6 +150,7 @@ export const zh: Translations = { noSpeechDetected: '没有检测到语音', playbackFailed: '语音播放失败', recordingFailed: '语音录制失败', + sayStopToEnd: phrase => `说“${phrase}”即可结束语音对话。`, transcriptionFailed: '语音转写失败', transcriptionUnavailable: '语音转写暂不可用。', tryRecordingAgain: '请再录一次。', diff --git a/apps/desktop/src/store/voice-prefs.test.ts b/apps/desktop/src/store/voice-prefs.test.ts new file mode 100644 index 00000000000..e648aa744be --- /dev/null +++ b/apps/desktop/src/store/voice-prefs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/hermes', () => ({ + getHermesConfigRecord: vi.fn(async () => ({})), + saveHermesConfig: vi.fn(async () => undefined) +})) + +import { $voiceStopPhrase, applyVoiceStopPhraseFromConfig } from './voice-prefs' + +describe('applyVoiceStopPhraseFromConfig', () => { + it('defaults to "stop" when the key is absent (backend default applies)', () => { + applyVoiceStopPhraseFromConfig({ voice: {} }) + expect($voiceStopPhrase.get()).toBe('stop') + + applyVoiceStopPhraseFromConfig(null) + expect($voiceStopPhrase.get()).toBe('stop') + }) + + it('uses the first configured phrase so a custom phrase renders correctly', () => { + applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: ['goodbye hermes', 'stop'] } }) + expect($voiceStopPhrase.get()).toBe('goodbye hermes') + }) + + it('coerces a bare string like the backend does', () => { + applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: 'halt' } }) + expect($voiceStopPhrase.get()).toBe('halt') + }) + + it('null phrase when stop phrases are disabled — no notice is shown', () => { + applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [] } }) + expect($voiceStopPhrase.get()).toBeNull() + }) + + it('malformed entries are skipped; all-blank list disables', () => { + applyVoiceStopPhraseFromConfig({ voice: { stop_phrases: [' ', ''] } }) + expect($voiceStopPhrase.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/voice-prefs.ts b/apps/desktop/src/store/voice-prefs.ts index f7e414e2556..b291b118304 100644 --- a/apps/desktop/src/store/voice-prefs.ts +++ b/apps/desktop/src/store/voice-prefs.ts @@ -12,6 +12,31 @@ export function applyAutoSpeakFromConfig(config: { voice?: { auto_tts?: unknown $autoSpeakReplies.set(Boolean(config?.voice?.auto_tts)) } +// First configured `voice.stop_phrases` entry — drives the "Say "stop" to end +// the voice chat" notice shown when a voice conversation starts. `null` means +// the user disabled stop phrases (`stop_phrases: []`), so no notice is shown. +// Defaults to "stop" (the backend default) before config loads. +export const $voiceStopPhrase = atom('stop') + +/** Seed the stop-phrase atom from a loaded config payload (mount / refresh). */ +export function applyVoiceStopPhraseFromConfig( + config: { voice?: { stop_phrases?: unknown } | null } | null | undefined +) { + const raw = config?.voice?.stop_phrases + + if (raw === undefined) { + // Key absent — backend default applies. + $voiceStopPhrase.set('stop') + + return + } + + const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [] + const first = list.map(entry => String(entry).trim()).find(entry => entry.length > 0) + + $voiceStopPhrase.set(first ?? null) +} + /** * Flip the preference and persist it. Optimistic — the atom updates instantly and * reverts if the config write fails. Read-modify-writes the whole record (the diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 7f4da0706a8..c2d01f272fb 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -331,6 +331,7 @@ export interface HermesConfig { voice?: { max_recording_seconds?: number auto_tts?: boolean + stop_phrases?: unknown } } diff --git a/cli.py b/cli.py index 5594ca32e68..519f6158f9e 100644 --- a/cli.py +++ b/cli.py @@ -12283,6 +12283,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _ptt_display = self._voice_record_key_label() _cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}") _cprint(f" {_DIM}{_ptt_display} to start/stop recording{_RST}") + # Spoken-stop hint sourced from voice.stop_phrases (first entry); the + # helper returns "" when stop phrases are disabled — show no hint then. + try: + from tools.voice_mode import voice_stop_hint + _stop_hint = voice_stop_hint() + except Exception: + _stop_hint = "" + if _stop_hint: + _cprint(f" {_DIM}{_stop_hint}{_RST}") _cprint(f" {_DIM}/voice tts to toggle speech output{_RST}") _cprint(f" {_DIM}/voice off to disable voice mode{_RST}") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a39fcc0d6f6..4c28623bf42 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1188,6 +1188,47 @@ def test_voice_toggle_returns_configured_record_key(monkeypatch): assert status_resp["result"]["record_key"] == "ctrl+o" +def test_voice_toggle_on_carries_stop_hint(monkeypatch): + """voice.toggle action=on returns the spoken-stop hint for clients to + render — sourced from voice.stop_phrases so a custom phrase shows + correctly, and empty when the feature is disabled (stop_phrases: []).""" + monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}}) + monkeypatch.setitem( + sys.modules, + "tools.voice_mode", + types.SimpleNamespace( + check_voice_requirements=lambda: {"available": True, "details": ""}, + voice_stop_hint=lambda: 'Say "halt" to end the voice chat.', + ), + ) + monkeypatch.setenv("HERMES_VOICE", "0") + + on_resp = server.dispatch( + {"id": "voice-on", "method": "voice.toggle", "params": {"action": "on"}} + ) + assert on_resp["result"]["stop_hint"] == 'Say "halt" to end the voice chat.' + + # Disabled stop phrases → empty hint, clients show nothing. + monkeypatch.setitem( + sys.modules, + "tools.voice_mode", + types.SimpleNamespace( + check_voice_requirements=lambda: {"available": True, "details": ""}, + voice_stop_hint=lambda: "", + ), + ) + on_resp = server.dispatch( + {"id": "voice-on2", "method": "voice.toggle", "params": {"action": "on"}} + ) + assert on_resp["result"]["stop_hint"] == "" + + # off carries no hint text (mode is ending). + off_resp = server.dispatch( + {"id": "voice-off", "method": "voice.toggle", "params": {"action": "off"}} + ) + assert off_resp["result"]["stop_hint"] == "" + + def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch): """Round-3 Copilot review regression on #19835. diff --git a/tests/tools/test_voice_stop_phrase.py b/tests/tools/test_voice_stop_phrase.py index a6f0f48805b..974530db8d7 100644 --- a/tests/tools/test_voice_stop_phrase.py +++ b/tests/tools/test_voice_stop_phrase.py @@ -17,9 +17,29 @@ from tools.voice_mode import ( DEFAULT_VOICE_STOP_PHRASES, _load_voice_stop_phrases, is_voice_stop_phrase, + voice_stop_hint, ) +class TestVoiceStopHint: + """The 'Say "stop" to end the voice chat.' hint shown on voice-mode start.""" + + def test_default_phrase(self): + with patch("tools.voice_mode._load_voice_stop_phrases", return_value=("stop",)): + assert voice_stop_hint() == 'Say "stop" to end the voice chat.' + + def test_custom_phrase_uses_first_entry(self): + with patch( + "tools.voice_mode._load_voice_stop_phrases", + return_value=("goodbye hermes", "stop"), + ): + assert voice_stop_hint() == 'Say "goodbye hermes" to end the voice chat.' + + def test_disabled_phrases_show_no_hint(self): + with patch("tools.voice_mode._load_voice_stop_phrases", return_value=()): + assert voice_stop_hint() == "" + + class TestIsVoiceStopPhrase: @pytest.mark.parametrize("utterance", [ "stop", "Stop", "STOP", "stop.", "Stop!", " stop ", '"Stop."', "stop?", diff --git a/tools/voice_mode.py b/tools/voice_mode.py index fd26a8c1b24..b12c0672301 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -1161,6 +1161,21 @@ def is_voice_stop_phrase(transcript: str, stop_phrases: Optional[tuple] = None) return cleaned in stop_phrases +def voice_stop_hint() -> str: + """One-line 'Say "stop" to end the voice chat.' hint for voice-mode start. + + Sources the phrase from ``voice.stop_phrases`` (first entry) so a custom + phrase renders correctly; returns "" when stop phrases are disabled + (``stop_phrases: []``) so surfaces show no hint at all. Every surface + that announces voice-mode start (CLI /voice on, TUI, desktop) uses this + one owner instead of hardcoding the wording. + """ + phrases = _load_voice_stop_phrases() + if not phrases: + return "" + return f'Say "{phrases[0]}" to end the voice chat.' + + # ============================================================================ # STT dispatch # ============================================================================ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0b49bab12a1..5984952d0f7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -18287,6 +18287,18 @@ def _(rid, params: dict) -> dict: # persisted stale toggle. os.environ["HERMES_VOICE"] = "1" if enabled else "0" + stop_hint = "" + if enabled: + # Spoken-stop hint for the client to render on voice-mode start. + # Sourced from voice.stop_phrases (custom phrases render + # correctly); empty when the feature is disabled. + try: + from tools.voice_mode import voice_stop_hint + + stop_hint = voice_stop_hint() + except Exception: + stop_hint = "" + if not enabled: # Disabling the mode must tear the continuous loop down; the # loop holds the microphone and would otherwise keep running. @@ -18310,6 +18322,7 @@ def _(rid, params: dict) -> dict: "enabled": enabled, "record_key": _voice_record_key(), "tts": _voice_tts_enabled(), + "stop_hint": stop_hint, }, ) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index e2a841c6fc2..3fc60e8564a 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -380,6 +380,14 @@ export const sessionCommands: SlashCommand[] = [ const tts = r.tts ? ' (TTS enabled)' : '' ctx.transcript.sys(`Voice mode enabled${tts}`) ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`) + + // Spoken-stop hint — backend-sourced from voice.stop_phrases so a + // custom phrase renders correctly; absent/empty means the feature + // is disabled (stop_phrases: []) and no hint is shown. + if (r.stop_hint) { + ctx.transcript.sys(` ${r.stop_hint}`) + } + ctx.transcript.sys(' /voice tts to toggle speech output') ctx.transcript.sys(' /voice off to disable voice mode') } else { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 607da742594..3066b83848f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -394,6 +394,7 @@ export interface VoiceToggleResponse { details?: string enabled?: boolean record_key?: string + stop_hint?: string stt_available?: boolean tts?: boolean }