Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23f54d15f | ||
|
|
ffb3257cb5 |
@@ -1,4 +1,4 @@
|
||||
from agent.telemetry_logger import log_token_usage\n"""Shared auxiliary client router for side tasks.
|
||||
"""Shared auxiliary client router for side tasks.
|
||||
|
||||
Provides a single resolution chain so every consumer (context compression,
|
||||
session search, web extraction, vision analysis, browser vision) picks up
|
||||
@@ -34,6 +34,8 @@ Payment / credit exhaustion fallback:
|
||||
their OpenRouter balance but has Codex OAuth or another provider available.
|
||||
"""
|
||||
|
||||
from agent.telemetry_logger import log_token_usage
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -396,7 +398,8 @@ class _CodexCompletionsAdapter:
|
||||
prompt_tokens=getattr(resp_usage, "input_tokens", 0),
|
||||
completion_tokens=getattr(resp_usage, "output_tokens", 0),
|
||||
total_tokens=getattr(resp_usage, "total_tokens", 0),
|
||||
)\n log_token_usage(usage.prompt_tokens, usage.completion_tokens, model)
|
||||
)
|
||||
log_token_usage(usage.prompt_tokens, usage.completion_tokens, model)
|
||||
except Exception as exc:
|
||||
logger.debug("Codex auxiliary Responses API call failed: %s", exc)
|
||||
raise
|
||||
@@ -529,7 +532,8 @@ class _AnthropicCompletionsAdapter:
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)\n log_token_usage(usage.prompt_tokens, usage.completion_tokens, model)
|
||||
)
|
||||
log_token_usage(usage.prompt_tokens, usage.completion_tokens, model)
|
||||
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
|
||||
273
cli.py
273
cli.py
@@ -6852,12 +6852,11 @@ class HermesCLI:
|
||||
self._voice_stop_and_transcribe()
|
||||
|
||||
# Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._voice_recorder.start(on_silence_stop=_on_silence)
|
||||
@@ -6905,12 +6904,11 @@ class HermesCLI:
|
||||
wav_path = self._voice_recorder.stop()
|
||||
|
||||
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if wav_path is None:
|
||||
_cprint(f"{_DIM}No speech detected.{_RST}")
|
||||
@@ -7061,17 +7059,6 @@ class HermesCLI:
|
||||
_cprint(f"Unknown voice subcommand: {subcommand}")
|
||||
_cprint("Usage: /voice [on|off|tts|status]")
|
||||
|
||||
def _voice_beeps_enabled(self) -> bool:
|
||||
"""Return whether CLI voice mode should play record start/stop beeps."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
voice_cfg = load_config().get("voice", {})
|
||||
if isinstance(voice_cfg, dict):
|
||||
return bool(voice_cfg.get("beep_enabled", True))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _enable_voice_mode(self):
|
||||
"""Enable voice mode after checking requirements."""
|
||||
if self._voice_mode:
|
||||
@@ -7267,6 +7254,40 @@ class HermesCLI:
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
|
||||
def _handle_clarify_selection(self) -> None:
|
||||
"""Process the currently selected clarify choice."""
|
||||
state = self._clarify_state
|
||||
if not state or self._clarify_freetext:
|
||||
return
|
||||
|
||||
selected = state.get("selected", 0)
|
||||
choices = state.get("choices") or []
|
||||
if selected < len(choices):
|
||||
state["response_queue"].put(choices[selected])
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
self._invalidate()
|
||||
return
|
||||
|
||||
if selected == len(choices):
|
||||
self._clarify_freetext = True
|
||||
self._invalidate()
|
||||
|
||||
def _handle_clarify_number_shortcut(self, number: int) -> bool:
|
||||
"""Select a clarify option by number key."""
|
||||
state = self._clarify_state
|
||||
if not state or self._clarify_freetext:
|
||||
return False
|
||||
|
||||
choices = state.get("choices") or []
|
||||
max_option = len(choices) + 1
|
||||
if number < 1 or number > max_option:
|
||||
return False
|
||||
|
||||
state["selected"] = number - 1
|
||||
self._handle_clarify_selection()
|
||||
return True
|
||||
|
||||
def _sudo_password_callback(self) -> str:
|
||||
"""
|
||||
Prompt for sudo password through the prompt_toolkit UI.
|
||||
@@ -7375,6 +7396,20 @@ class HermesCLI:
|
||||
choices.append("view")
|
||||
return choices
|
||||
|
||||
def _handle_approval_number_shortcut(self, number: int) -> bool:
|
||||
"""Select an approval option by number key."""
|
||||
state = self._approval_state
|
||||
if not state:
|
||||
return False
|
||||
|
||||
choices = state.get("choices") or []
|
||||
if number < 1 or number > len(choices):
|
||||
return False
|
||||
|
||||
state["selected"] = number - 1
|
||||
self._handle_approval_selection()
|
||||
return True
|
||||
|
||||
def _handle_approval_selection(self) -> None:
|
||||
"""Process the currently selected dangerous-command approval choice."""
|
||||
state = self._approval_state
|
||||
@@ -7450,8 +7485,9 @@ class HermesCLI:
|
||||
preview_lines.extend(_wrap_panel_text(cmd_display, 60))
|
||||
for i, choice in enumerate(choices):
|
||||
prefix = '❯ ' if i == selected else ' '
|
||||
label = f"{i + 1}. {choice_labels.get(choice, choice)}"
|
||||
preview_lines.extend(_wrap_panel_text(
|
||||
f"{prefix}{choice_labels.get(choice, choice)}",
|
||||
f"{prefix}{label}",
|
||||
60,
|
||||
subsequent_indent=" ",
|
||||
))
|
||||
@@ -7469,7 +7505,7 @@ class HermesCLI:
|
||||
_append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:approval-border', box_width)
|
||||
for i, choice in enumerate(choices):
|
||||
label = choice_labels.get(choice, choice)
|
||||
label = f"{i + 1}. {choice_labels.get(choice, choice)}"
|
||||
style = 'class:approval-selected' if i == selected else 'class:approval-choice'
|
||||
prefix = '❯ ' if i == selected else ' '
|
||||
for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "):
|
||||
@@ -7478,6 +7514,97 @@ class HermesCLI:
|
||||
lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||
return lines
|
||||
|
||||
def _get_clarify_display_fragments(self):
|
||||
"""Render the clarify panel for the prompt_toolkit UI."""
|
||||
state = self._clarify_state
|
||||
if not state:
|
||||
return []
|
||||
|
||||
def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46, max_width: int = 76) -> int:
|
||||
term_cols = shutil.get_terminal_size((100, 20)).columns
|
||||
longest = max([len(title)] + [len(line) for line in content_lines] + [min_width - 4])
|
||||
inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
|
||||
return inner + 2
|
||||
|
||||
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
|
||||
wrapped = textwrap.wrap(
|
||||
text,
|
||||
width=max(8, width),
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False,
|
||||
subsequent_indent=subsequent_indent,
|
||||
)
|
||||
return wrapped or [""]
|
||||
|
||||
def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
|
||||
inner_width = max(0, box_width - 2)
|
||||
lines.append((border_style, "│ "))
|
||||
lines.append((content_style, text.ljust(inner_width)))
|
||||
lines.append((border_style, " │\n"))
|
||||
|
||||
def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
|
||||
lines.append((border_style, "│" + (" " * box_width) + "│\n"))
|
||||
|
||||
question = state["question"]
|
||||
choices = state.get("choices") or []
|
||||
selected = state.get("selected", 0)
|
||||
preview_lines = _wrap_panel_text(question, 60)
|
||||
for i, choice in enumerate(choices):
|
||||
prefix = "❯ " if i == selected and not self._clarify_freetext else " "
|
||||
label = f"{i + 1}. {choice}"
|
||||
preview_lines.extend(_wrap_panel_text(f"{prefix}{label}", 60, subsequent_indent=" "))
|
||||
other_number = len(choices) + 1
|
||||
other_label = (
|
||||
f"❯ {other_number}. Other (type below)" if self._clarify_freetext
|
||||
else f"❯ {other_number}. Other (type your answer)" if selected == len(choices)
|
||||
else f" {other_number}. Other (type your answer)"
|
||||
)
|
||||
preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" "))
|
||||
box_width = _panel_box_width("Hermes needs your input", preview_lines)
|
||||
inner_text_width = max(8, box_width - 2)
|
||||
|
||||
lines = []
|
||||
lines.append(('class:clarify-border', '╭─ '))
|
||||
lines.append(('class:clarify-title', 'Hermes needs your input'))
|
||||
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len("Hermes needs your input") - 3)) + '╮\n'))
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
for wrapped in _wrap_panel_text(question, inner_text_width):
|
||||
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
if self._clarify_freetext and not choices:
|
||||
guidance = "Type your answer in the prompt below, then press Enter."
|
||||
for wrapped in _wrap_panel_text(guidance, inner_text_width):
|
||||
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
if choices:
|
||||
for i, choice in enumerate(choices):
|
||||
style = 'class:clarify-selected' if i == selected and not self._clarify_freetext else 'class:clarify-choice'
|
||||
prefix = '❯ ' if i == selected and not self._clarify_freetext else ' '
|
||||
label = f"{i + 1}. {choice}"
|
||||
wrapped_lines = _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" ")
|
||||
for wrapped in wrapped_lines:
|
||||
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
|
||||
|
||||
other_idx = len(choices)
|
||||
if selected == other_idx and not self._clarify_freetext:
|
||||
other_style = 'class:clarify-selected'
|
||||
other_label = f'❯ {other_number}. Other (type your answer)'
|
||||
elif self._clarify_freetext:
|
||||
other_style = 'class:clarify-active-other'
|
||||
other_label = f'❯ {other_number}. Other (type below)'
|
||||
else:
|
||||
other_style = 'class:clarify-choice'
|
||||
other_label = f' {other_number}. Other (type your answer)'
|
||||
for wrapped in _wrap_panel_text(other_label, inner_text_width, subsequent_indent=" "):
|
||||
_append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width)
|
||||
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||
return lines
|
||||
|
||||
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
return prompt_for_secret(self, var_name, prompt, metadata)
|
||||
|
||||
@@ -8384,17 +8511,8 @@ class HermesCLI:
|
||||
|
||||
# --- Clarify choice mode: confirm the highlighted selection ---
|
||||
if self._clarify_state and not self._clarify_freetext:
|
||||
state = self._clarify_state
|
||||
selected = state["selected"]
|
||||
choices = state.get("choices") or []
|
||||
if selected < len(choices):
|
||||
state["response_queue"].put(choices[selected])
|
||||
self._clarify_state = None
|
||||
event.app.invalidate()
|
||||
else:
|
||||
# "Other" selected → switch to freetext
|
||||
self._clarify_freetext = True
|
||||
event.app.invalidate()
|
||||
self._handle_clarify_selection()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# --- Normal input routing ---
|
||||
@@ -8514,6 +8632,19 @@ class HermesCLI:
|
||||
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
|
||||
event.app.invalidate()
|
||||
|
||||
# --- Numbered shortcuts for clarify / approval modal prompts ---
|
||||
for _digit in '123456789':
|
||||
@kb.add(_digit, filter=Condition(lambda: bool(self._approval_state) or (bool(self._clarify_state) and not self._clarify_freetext)))
|
||||
def _handle_modal_number(event, digit=_digit):
|
||||
number = int(digit)
|
||||
handled = False
|
||||
if self._approval_state:
|
||||
handled = self._handle_approval_number_shortcut(number)
|
||||
elif self._clarify_state and not self._clarify_freetext:
|
||||
handled = self._handle_clarify_number_shortcut(number)
|
||||
if handled:
|
||||
event.app.invalidate()
|
||||
|
||||
# --- /model picker: arrow-key navigation ---
|
||||
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
|
||||
def model_picker_up(event):
|
||||
@@ -9008,7 +9139,7 @@ class HermesCLI:
|
||||
if cli_ref._approval_state:
|
||||
remaining = max(0, int(cli_ref._approval_deadline - _time.monotonic()))
|
||||
return [
|
||||
('class:hint', ' ↑/↓ to select, Enter to confirm'),
|
||||
('class:hint', ' 1-9 or ↑/↓ to select, Enter to confirm'),
|
||||
('class:clarify-countdown', f' ({remaining}s)'),
|
||||
]
|
||||
|
||||
@@ -9021,7 +9152,7 @@ class HermesCLI:
|
||||
('class:clarify-countdown', countdown),
|
||||
]
|
||||
return [
|
||||
('class:hint', ' ↑/↓ to select, Enter to confirm'),
|
||||
('class:hint', ' 1-9 or ↑/↓ to select, Enter to confirm'),
|
||||
('class:clarify-countdown', countdown),
|
||||
]
|
||||
|
||||
@@ -9099,71 +9230,7 @@ class HermesCLI:
|
||||
lines.append((border_style, "│" + (" " * box_width) + "│\n"))
|
||||
|
||||
def _get_clarify_display():
|
||||
"""Build styled text for the clarify question/choices panel."""
|
||||
state = cli_ref._clarify_state
|
||||
if not state:
|
||||
return []
|
||||
|
||||
question = state["question"]
|
||||
choices = state.get("choices") or []
|
||||
selected = state.get("selected", 0)
|
||||
preview_lines = _wrap_panel_text(question, 60)
|
||||
for i, choice in enumerate(choices):
|
||||
prefix = "❯ " if i == selected and not cli_ref._clarify_freetext else " "
|
||||
preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" "))
|
||||
other_label = (
|
||||
"❯ Other (type below)" if cli_ref._clarify_freetext
|
||||
else "❯ Other (type your answer)" if selected == len(choices)
|
||||
else " Other (type your answer)"
|
||||
)
|
||||
preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" "))
|
||||
box_width = _panel_box_width("Hermes needs your input", preview_lines)
|
||||
inner_text_width = max(8, box_width - 2)
|
||||
|
||||
lines = []
|
||||
# Box top border
|
||||
lines.append(('class:clarify-border', '╭─ '))
|
||||
lines.append(('class:clarify-title', 'Hermes needs your input'))
|
||||
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len("Hermes needs your input") - 3)) + '╮\n'))
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
# Question text
|
||||
for wrapped in _wrap_panel_text(question, inner_text_width):
|
||||
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
if cli_ref._clarify_freetext and not choices:
|
||||
guidance = "Type your answer in the prompt below, then press Enter."
|
||||
for wrapped in _wrap_panel_text(guidance, inner_text_width):
|
||||
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width)
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
|
||||
if choices:
|
||||
# Multiple-choice mode: show selectable options
|
||||
for i, choice in enumerate(choices):
|
||||
style = 'class:clarify-selected' if i == selected and not cli_ref._clarify_freetext else 'class:clarify-choice'
|
||||
prefix = '❯ ' if i == selected and not cli_ref._clarify_freetext else ' '
|
||||
wrapped_lines = _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" ")
|
||||
for wrapped in wrapped_lines:
|
||||
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
|
||||
|
||||
# "Other" option (5th line, only shown when choices exist)
|
||||
other_idx = len(choices)
|
||||
if selected == other_idx and not cli_ref._clarify_freetext:
|
||||
other_style = 'class:clarify-selected'
|
||||
other_label = '❯ Other (type your answer)'
|
||||
elif cli_ref._clarify_freetext:
|
||||
other_style = 'class:clarify-active-other'
|
||||
other_label = '❯ Other (type below)'
|
||||
else:
|
||||
other_style = 'class:clarify-choice'
|
||||
other_label = ' Other (type your answer)'
|
||||
for wrapped in _wrap_panel_text(other_label, inner_text_width, subsequent_indent=" "):
|
||||
_append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width)
|
||||
|
||||
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||
return lines
|
||||
return cli_ref._get_clarify_display_fragments()
|
||||
|
||||
clarify_widget = ConditionalContainer(
|
||||
Window(
|
||||
|
||||
172
tests/cli/test_cli_numbered_prompt_shortcuts.py
Normal file
172
tests/cli/test_cli_numbered_prompt_shortcuts.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import queue
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text="", cursor_position=None):
|
||||
self.text = text
|
||||
self.cursor_position = len(text) if cursor_position is None else cursor_position
|
||||
|
||||
def reset(self, append_to_history=False):
|
||||
self.text = ""
|
||||
self.cursor_position = 0
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
cli._approval_lock = threading.Lock()
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._clarify_deadline = 0
|
||||
cli._sudo_state = None
|
||||
cli._sudo_deadline = 0
|
||||
cli._secret_state = None
|
||||
cli._secret_deadline = 0
|
||||
cli._modal_input_snapshot = None
|
||||
cli._invalidate = MagicMock()
|
||||
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
|
||||
return cli
|
||||
|
||||
|
||||
def test_approval_display_numbers_choices():
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "sudo rm -rf /tmp/example",
|
||||
"description": "dangerous command",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
rendered = "".join(text for _style, text in cli._get_approval_display_fragments())
|
||||
|
||||
assert "❯ 1. Allow once" in rendered
|
||||
assert "2. Allow for this session" in rendered
|
||||
assert "3. Add to permanent allowlist" in rendered
|
||||
assert "4. Deny" in rendered
|
||||
|
||||
|
||||
def test_approval_number_shortcut_submits_choice():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._approval_state = {
|
||||
"command": "sudo rm -rf /tmp/example",
|
||||
"description": "dangerous command",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
assert cli._handle_approval_number_shortcut(2) is True
|
||||
assert response_queue.get_nowait() == "session"
|
||||
assert cli._approval_state is None
|
||||
|
||||
|
||||
def test_approval_selection_still_submits_selected_choice():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._approval_state = {
|
||||
"command": "sudo rm -rf /tmp/example",
|
||||
"description": "dangerous command",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 1,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
cli._handle_approval_selection()
|
||||
|
||||
assert response_queue.get_nowait() == "session"
|
||||
assert cli._approval_state is None
|
||||
|
||||
|
||||
def test_approval_number_shortcut_handles_view_in_place():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._approval_state = {
|
||||
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
|
||||
"description": "disk copy",
|
||||
"choices": ["once", "session", "always", "deny", "view"],
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
assert cli._handle_approval_number_shortcut(5) is True
|
||||
assert cli._approval_state is not None
|
||||
assert cli._approval_state["show_full"] is True
|
||||
assert "view" not in cli._approval_state["choices"]
|
||||
assert cli._approval_state["selected"] == 3
|
||||
assert response_queue.empty()
|
||||
|
||||
|
||||
def test_clarify_display_numbers_choices_and_other():
|
||||
cli = _make_cli_stub()
|
||||
cli._clarify_state = {
|
||||
"question": "Pick the best option",
|
||||
"choices": ["Alpha", "Beta", "Gamma", "Delta"],
|
||||
"selected": 1,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
rendered = "".join(text for _style, text in cli._get_clarify_display_fragments())
|
||||
|
||||
assert "1. Alpha" in rendered
|
||||
assert "❯ 2. Beta" in rendered
|
||||
assert "3. Gamma" in rendered
|
||||
assert "4. Delta" in rendered
|
||||
assert "5. Other (type your answer)" in rendered
|
||||
|
||||
|
||||
def test_clarify_number_shortcut_submits_choice():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._clarify_state = {
|
||||
"question": "Pick the best option",
|
||||
"choices": ["Alpha", "Beta", "Gamma"],
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
assert cli._handle_clarify_number_shortcut(3) is True
|
||||
assert response_queue.get_nowait() == "Gamma"
|
||||
assert cli._clarify_state is None
|
||||
assert cli._clarify_freetext is False
|
||||
|
||||
|
||||
def test_clarify_selection_still_submits_selected_choice():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._clarify_state = {
|
||||
"question": "Pick the best option",
|
||||
"choices": ["Alpha", "Beta", "Gamma"],
|
||||
"selected": 1,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
cli._handle_clarify_selection()
|
||||
|
||||
assert response_queue.get_nowait() == "Beta"
|
||||
assert cli._clarify_state is None
|
||||
assert cli._clarify_freetext is False
|
||||
|
||||
|
||||
def test_clarify_number_shortcut_activates_other_freetext():
|
||||
cli = _make_cli_stub()
|
||||
response_queue = queue.Queue()
|
||||
cli._clarify_state = {
|
||||
"question": "Pick the best option",
|
||||
"choices": ["Alpha", "Beta", "Gamma"],
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
|
||||
assert cli._handle_clarify_number_shortcut(4) is True
|
||||
assert cli._clarify_state is not None
|
||||
assert cli._clarify_state["selected"] == 3
|
||||
assert cli._clarify_freetext is True
|
||||
assert response_queue.empty()
|
||||
@@ -4,31 +4,13 @@ state management, streaming TTS activation, voice message prefix, _vprint."""
|
||||
import ast
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_cli_import_shims():
|
||||
sys.modules.setdefault(
|
||||
"agent.auxiliary_client",
|
||||
types.SimpleNamespace(
|
||||
call_llm=lambda *args, **kwargs: "",
|
||||
async_call_llm=lambda *args, **kwargs: "",
|
||||
extract_content_or_reasoning=lambda *args, **kwargs: "",
|
||||
resolve_provider_client=lambda *args, **kwargs: (None, None, None, None),
|
||||
get_async_text_auxiliary_client=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_ensure_cli_import_shims()
|
||||
|
||||
|
||||
def _make_voice_cli(**overrides):
|
||||
"""Create a minimal HermesCLI with only voice-related attrs initialized.
|
||||
|
||||
@@ -36,7 +18,6 @@ def _make_voice_cli(**overrides):
|
||||
needed. Only the voice state attributes (from __init__ lines 3749-3758)
|
||||
are populated.
|
||||
"""
|
||||
_ensure_cli_import_shims()
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
@@ -952,58 +933,6 @@ class TestEnableVoiceModeReal:
|
||||
assert cli._voice_mode is True
|
||||
|
||||
|
||||
class TestVoiceBeepConfigReal:
|
||||
"""Tests the CLI voice beep toggle."""
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
|
||||
def test_beeps_enabled_by_default(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is True
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
def test_beeps_can_be_disabled(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is False
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.threading.Thread")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
@patch("tools.voice_mode.create_audio_recorder")
|
||||
@patch(
|
||||
"tools.voice_mode.check_voice_requirements",
|
||||
return_value={
|
||||
"available": True,
|
||||
"audio_available": True,
|
||||
"stt_available": True,
|
||||
"details": "OK",
|
||||
"missing_packages": [],
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"voice": {
|
||||
"beep_enabled": False,
|
||||
"silence_threshold": 200,
|
||||
"silence_duration": 3.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
def test_start_recording_skips_beep_when_disabled(
|
||||
self, _cfg, _req, mock_create, mock_beep, mock_thread, _cp
|
||||
):
|
||||
recorder = MagicMock()
|
||||
recorder.supports_silence_autostop = True
|
||||
mock_create.return_value = recorder
|
||||
mock_thread.return_value = MagicMock(start=MagicMock())
|
||||
|
||||
cli = _make_voice_cli()
|
||||
cli._voice_start_recording()
|
||||
|
||||
recorder.start.assert_called_once()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
|
||||
class TestDisableVoiceModeReal:
|
||||
"""Tests _disable_voice_mode with real CLI instance."""
|
||||
|
||||
@@ -1158,16 +1087,6 @@ class TestVoiceStopAndTranscribeReal:
|
||||
cli._voice_stop_and_transcribe()
|
||||
assert cli._pending_input.empty()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_no_speech_detected_skips_beep_when_disabled(self, mock_beep, _cfg, _cp):
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
||||
cli._voice_stop_and_transcribe()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.os.unlink")
|
||||
@patch("cli.os.path.isfile", return_value=True)
|
||||
@@ -1237,18 +1156,12 @@ class TestVoiceStopAndTranscribeReal:
|
||||
@patch("cli._cprint")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
|
||||
import time
|
||||
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
|
||||
_voice_continuous=True)
|
||||
cli._voice_start_recording = MagicMock()
|
||||
cli._voice_stop_and_transcribe()
|
||||
for _ in range(50):
|
||||
if cli._voice_start_recording.call_count:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
cli._voice_start_recording.assert_called_once()
|
||||
|
||||
@patch("cli._cprint")
|
||||
|
||||
@@ -149,7 +149,7 @@ Two-stage algorithm detects when you've finished speaking:
|
||||
|
||||
If no speech is detected at all for 15 seconds, recording stops automatically.
|
||||
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`. You can also disable the record start/stop beeps with `voice.beep_enabled: false`.
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.
|
||||
|
||||
### Streaming TTS
|
||||
|
||||
@@ -383,7 +383,6 @@ voice:
|
||||
record_key: "ctrl+b" # Key to start/stop recording
|
||||
max_recording_seconds: 120 # Maximum recording length
|
||||
auto_tts: false # Auto-enable TTS when voice mode starts
|
||||
beep_enabled: true # Play record start/stop beeps
|
||||
silence_threshold: 200 # RMS level (0-32767) below which counts as silence
|
||||
silence_duration: 3.0 # Seconds of silence before auto-stop
|
||||
|
||||
|
||||
Reference in New Issue
Block a user