diff --git a/cli.py b/cli.py index 3848a24d92..cc9ca214b4 100644 --- a/cli.py +++ b/cli.py @@ -1899,7 +1899,26 @@ class HermesCLI: return 0 return 0 if self._use_minimal_tui_chrome(width=width) else 1 + def _get_voice_status_fragments(self, width: Optional[int] = None): + """Return the voice status bar fragments for the interactive TUI.""" + width = width or self._get_tui_terminal_width() + compact = self._use_minimal_tui_chrome(width=width) + if self._voice_recording: + if compact: + return [("class:voice-status-recording", " ● REC ")] + return [("class:voice-status-recording", " ● REC Ctrl+B to stop ")] + if self._voice_processing: + if compact: + return [("class:voice-status", " ◉ STT ")] + return [("class:voice-status", " ◉ Transcribing... ")] + if compact: + return [("class:voice-status", " 🎤 Ctrl+B ")] + tts = " | TTS on" if self._voice_tts else "" + cont = " | Continuous" if self._voice_continuous else "" + return [("class:voice-status", f" 🎤 Voice mode{tts}{cont} — Ctrl+B to record ")] + def _build_status_bar_text(self, width: Optional[int] = None) -> str: + """Return a compact one-line session status string for the TUI footer.""" try: snapshot = self._get_status_bar_snapshot() if width is None: @@ -5979,6 +5998,13 @@ class HermesCLI: reqs = check_voice_requirements() if not reqs["audio_available"]: if _is_termux_environment(): + details = reqs.get("details", "") + if "Termux:API Android app is not installed" in details: + raise RuntimeError( + "Termux:API command package detected, but the Android app is missing.\n" + "Install/update the Termux:API Android app, then retry /voice on.\n" + "Fallback: pkg install python-numpy portaudio && python -m pip install sounddevice" + ) raise RuntimeError( "Voice mode requires either Termux:API microphone access or Python audio libraries.\n" "Option 1: pkg install termux-api and install the Termux:API Android app\n" @@ -8338,13 +8364,7 @@ class HermesCLI: # Persistent voice mode status bar (visible only when voice mode is on) def _get_voice_status(): - if cli_ref._voice_recording: - return [('class:voice-status-recording', ' ● REC Ctrl+B to stop ')] - if cli_ref._voice_processing: - return [('class:voice-status', ' ◉ Transcribing... ')] - tts = " | TTS on" if cli_ref._voice_tts else "" - cont = " | Continuous" if cli_ref._voice_continuous else "" - return [('class:voice-status', f' 🎤 Voice mode{tts}{cont} — Ctrl+B to record ')] + return cli_ref._get_voice_status_fragments() voice_status_bar = ConditionalContainer( Window( diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 5ef7acb3fe..e90631a98e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -71,6 +71,17 @@ def _system_package_install_cmd(pkg: str) -> str: return f"sudo apt install {pkg}" +def _termux_browser_setup_steps(node_installed: bool) -> list[str]: + steps: list[str] = [] + step = 1 + if not node_installed: + steps.append(f"{step}) pkg install nodejs") + step += 1 + steps.append(f"{step}) npm install -g agent-browser") + steps.append(f"{step + 1}) agent-browser install") + return steps + + def _has_provider_env_config(content: str) -> bool: """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" return any(key in content for key in _PROVIDER_ENV_HINTS) @@ -597,12 +608,18 @@ def run_doctor(args): if _is_termux(): check_info("agent-browser is not installed (expected in the tested Termux path)") check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=True): + check_info(step) else: check_warn("agent-browser not installed", "(run: npm install)") else: if _is_termux(): check_info("Node.js not found (browser tools are optional in the tested Termux path)") check_info("Install Node.js on Termux with: pkg install nodejs") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=False): + check_info(step) else: check_warn("Node.js not found", "(optional, needed for browser tools)") diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index cb794465bd..eabcd0f962 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -237,6 +237,28 @@ class TestCLIStatusBar: cli_obj._spinner_text = "" assert cli_obj._spinner_widget_height(width=90) == 0 + def test_voice_status_bar_compacts_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._voice_mode = True + cli_obj._voice_recording = False + cli_obj._voice_processing = False + cli_obj._voice_tts = True + cli_obj._voice_continuous = True + + fragments = cli_obj._get_voice_status_fragments(width=50) + + assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")] + + def test_voice_recording_status_bar_compacts_on_narrow_terminals(self): + cli_obj = _make_cli() + cli_obj._voice_mode = True + cli_obj._voice_recording = True + cli_obj._voice_processing = False + + fragments = cli_obj._get_voice_status_fragments(width=50) + + assert fragments == [("class:voice-status-recording", " ● REC ")] + class TestCLIUsageReport: def test_show_usage_includes_estimated_cost(self, capsys): diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 1c1246e4b4..faaa7a8a2d 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -244,6 +244,10 @@ def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkey assert "Docker backend is not available inside Termux" in out assert "Node.js not found (browser tools are optional in the tested Termux path)" in out assert "Install Node.js on Termux with: pkg install nodejs" in out + assert "Termux browser setup:" in out + assert "1) pkg install nodejs" in out + assert "2) npm install -g agent-browser" in out + assert "3) agent-browser install" in out assert "docker not found (optional)" not in out diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 6ff64702a9..1d35c48625 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -190,6 +190,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_CONNECTION", raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: None) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -198,6 +199,22 @@ class TestDetectAudioEnvironment: assert any("pkg install python-numpy portaudio" in w for w in result["warnings"]) assert any("python -m pip install sounddevice" in w for w in result["warnings"]) + def test_termux_api_package_without_android_app_blocks_voice(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False) + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any("Termux:API Android app is not installed" in w for w in result["warnings"]) + def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch): monkeypatch.setenv("TERMUX_VERSION", "0.118.3") @@ -206,6 +223,7 @@ class TestDetectAudioEnvironment: monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_CONNECTION", raising=False) monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None) + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) from tools.voice_mode import detect_audio_environment @@ -224,6 +242,7 @@ class TestCheckVoiceRequirements: def test_termux_api_capture_counts_as_audio_available(self, monkeypatch): monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False) monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]}) monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai") @@ -286,6 +305,7 @@ class TestCreateAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder recorder = create_audio_recorder() @@ -293,6 +313,17 @@ class TestCreateAudioRecorder: assert isinstance(recorder, TermuxAudioRecorder) assert recorder.supports_silence_autostop is False + def test_termux_without_android_app_falls_back_to_audio_recorder(self, monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.118.3") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False) + + from tools.voice_mode import create_audio_recorder, AudioRecorder + recorder = create_audio_recorder() + + assert isinstance(recorder, AudioRecorder) + class TestTermuxAudioRecorder: def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir): @@ -308,6 +339,7 @@ class TestTermuxAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) @@ -332,6 +364,7 @@ class TestTermuxAudioRecorder: monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") + monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000") monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index a3128eb41e..d8ddfd2387 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -71,8 +71,24 @@ def _termux_media_player_command() -> Optional[str]: return shutil.which("termux-media-player") +def _termux_api_app_installed() -> bool: + if not _is_termux_environment(): + return False + try: + result = subprocess.run( + ["pm", "list", "packages", "com.termux.api"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + return "package:com.termux.api" in (result.stdout or "") + except Exception: + return False + + def _termux_voice_capture_available() -> bool: - return _termux_microphone_command() is not None + return _termux_microphone_command() is not None and _termux_api_app_installed() def detect_audio_environment() -> dict: @@ -84,7 +100,9 @@ def detect_audio_environment() -> dict: """ warnings = [] # hard-fail: these block voice mode notices = [] # informational: logged but don't block - termux_capture = _termux_voice_capture_available() + termux_mic_cmd = _termux_microphone_command() + termux_app_installed = _termux_api_app_installed() + termux_capture = bool(termux_mic_cmd and termux_app_installed) # SSH detection if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): @@ -133,11 +151,19 @@ def detect_audio_environment() -> dict: except ImportError: if termux_capture: notices.append("Termux:API microphone recording available (sounddevice not required)") + elif termux_mic_cmd and not termux_app_installed: + warnings.append( + "Termux:API Android app is not installed. Install/update the Termux:API app to use termux-microphone-record." + ) else: warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") except OSError: if termux_capture: notices.append("Termux:API microphone recording available (PortAudio not required)") + elif termux_mic_cmd and not termux_app_installed: + warnings.append( + "Termux:API Android app is not installed. Install/update the Termux:API app to use termux-microphone-record." + ) elif _is_termux_environment(): warnings.append( "PortAudio system library not found -- install it first:\n" @@ -257,6 +283,11 @@ class TermuxAudioRecorder: "Install with: pkg install termux-api\n" "Then install/update the Termux:API Android app." ) + if not _termux_api_app_installed(): + raise RuntimeError( + "Termux voice capture requires the Termux:API Android app.\n" + "Install/update the Termux:API app, then retry /voice on." + ) with self._lock: if self._recording: