fix(termux): tighten voice setup and mobile chat UX

This commit is contained in:
adybag14-cyber 2026-04-09 14:41:30 +02:00 committed by Teknium
parent 769ec1ee1a
commit c3141429b7
6 changed files with 136 additions and 9 deletions

34
cli.py
View file

@ -1899,7 +1899,26 @@ class HermesCLI:
return 0 return 0
return 0 if self._use_minimal_tui_chrome(width=width) else 1 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: def _build_status_bar_text(self, width: Optional[int] = None) -> str:
"""Return a compact one-line session status string for the TUI footer."""
try: try:
snapshot = self._get_status_bar_snapshot() snapshot = self._get_status_bar_snapshot()
if width is None: if width is None:
@ -5979,6 +5998,13 @@ class HermesCLI:
reqs = check_voice_requirements() reqs = check_voice_requirements()
if not reqs["audio_available"]: if not reqs["audio_available"]:
if _is_termux_environment(): 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( raise RuntimeError(
"Voice mode requires either Termux:API microphone access or Python audio libraries.\n" "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" "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) # Persistent voice mode status bar (visible only when voice mode is on)
def _get_voice_status(): def _get_voice_status():
if cli_ref._voice_recording: return cli_ref._get_voice_status_fragments()
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 ')]
voice_status_bar = ConditionalContainer( voice_status_bar = ConditionalContainer(
Window( Window(

View file

@ -71,6 +71,17 @@ def _system_package_install_cmd(pkg: str) -> str:
return f"sudo apt install {pkg}" 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: def _has_provider_env_config(content: str) -> bool:
"""Return True when ~/.hermes/.env contains provider auth/base URL settings.""" """Return True when ~/.hermes/.env contains provider auth/base URL settings."""
return any(key in content for key in _PROVIDER_ENV_HINTS) return any(key in content for key in _PROVIDER_ENV_HINTS)
@ -597,12 +608,18 @@ def run_doctor(args):
if _is_termux(): if _is_termux():
check_info("agent-browser is not installed (expected in the tested Termux path)") 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("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: else:
check_warn("agent-browser not installed", "(run: npm install)") check_warn("agent-browser not installed", "(run: npm install)")
else: else:
if _is_termux(): if _is_termux():
check_info("Node.js not found (browser tools are optional in the tested Termux path)") 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("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: else:
check_warn("Node.js not found", "(optional, needed for browser tools)") check_warn("Node.js not found", "(optional, needed for browser tools)")

View file

@ -237,6 +237,28 @@ class TestCLIStatusBar:
cli_obj._spinner_text = "" cli_obj._spinner_text = ""
assert cli_obj._spinner_widget_height(width=90) == 0 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: class TestCLIUsageReport:
def test_show_usage_includes_estimated_cost(self, capsys): def test_show_usage_includes_estimated_cost(self, capsys):

View file

@ -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 "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 "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 "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 assert "docker not found (optional)" not in out

View file

@ -190,6 +190,7 @@ class TestDetectAudioEnvironment:
monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", 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._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 from tools.voice_mode import detect_audio_environment
result = 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("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"]) 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): def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
@ -206,6 +223,7 @@ class TestDetectAudioEnvironment:
monkeypatch.delenv("SSH_TTY", raising=False) monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", 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.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"))) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
from tools.voice_mode import detect_audio_environment 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): 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._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_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.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") 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("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 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_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 from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
recorder = create_audio_recorder() recorder = create_audio_recorder()
@ -293,6 +313,17 @@ class TestCreateAudioRecorder:
assert isinstance(recorder, TermuxAudioRecorder) assert isinstance(recorder, TermuxAudioRecorder)
assert recorder.supports_silence_autostop is False 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: class TestTermuxAudioRecorder:
def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir): 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("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 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_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.time.strftime", lambda fmt: "20260409_120000")
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
@ -332,6 +364,7 @@ class TestTermuxAudioRecorder:
monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") 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_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.time.strftime", lambda fmt: "20260409_120000")
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run) monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)

View file

@ -71,8 +71,24 @@ def _termux_media_player_command() -> Optional[str]:
return shutil.which("termux-media-player") 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: 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: def detect_audio_environment() -> dict:
@ -84,7 +100,9 @@ def detect_audio_environment() -> dict:
""" """
warnings = [] # hard-fail: these block voice mode warnings = [] # hard-fail: these block voice mode
notices = [] # informational: logged but don't block 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 # SSH detection
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): 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: except ImportError:
if termux_capture: if termux_capture:
notices.append("Termux:API microphone recording available (sounddevice not required)") 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: else:
warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})") warnings.append(f"Audio libraries not installed ({_voice_capture_install_hint()})")
except OSError: except OSError:
if termux_capture: if termux_capture:
notices.append("Termux:API microphone recording available (PortAudio not required)") 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(): elif _is_termux_environment():
warnings.append( warnings.append(
"PortAudio system library not found -- install it first:\n" "PortAudio system library not found -- install it first:\n"
@ -257,6 +283,11 @@ class TermuxAudioRecorder:
"Install with: pkg install termux-api\n" "Install with: pkg install termux-api\n"
"Then install/update the Termux:API Android app." "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: with self._lock:
if self._recording: if self._recording: