mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 11:47:09 +08:00
Compare commits
4 Commits
dependabot
...
fix/ty-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4062f964 | ||
|
|
1dba69095d | ||
|
|
c96eb06dc4 | ||
|
|
f6cfff4ac6 |
145
.github/workflows/docker-publish.yml
vendored
145
.github/workflows/docker-publish.yml
vendored
@@ -16,13 +16,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Top-level concurrency: do NOT cancel in-flight builds when a new push lands.
|
||||
# Every commit deserves its own SHA-tagged image in the registry, and we guard
|
||||
# the :latest tag in a separate job below (with its own concurrency group) so
|
||||
# a slow run can't clobber :latest with older bits.
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -30,18 +26,11 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
# Fetch enough history to run `git merge-base --is-ancestor` in the
|
||||
# move-latest job. That job reuses this checkout via its own
|
||||
# actions/checkout call, but commits reachable from main up to ~1000
|
||||
# back are plenty for any realistic race window.
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
@@ -85,12 +74,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Always push a per-commit SHA tag on main. This is race-free because
|
||||
# every commit has a unique SHA — concurrent runs can't clobber each
|
||||
# other here. We also embed the git SHA as an OCI label so the
|
||||
# move-latest job (below) can read it back off the registry's `:latest`.
|
||||
- name: Push multi-arch image with SHA tag (main branch)
|
||||
id: push_sha
|
||||
- name: Push multi-arch image (main branch)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
@@ -98,17 +82,10 @@ jobs:
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: nousresearch/hermes-agent:sha-${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
tags: nousresearch/hermes-agent:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Mark SHA tag pushed
|
||||
id: mark_pushed
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push multi-arch image (release)
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
@@ -120,119 +97,3 @@ jobs:
|
||||
tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Second job: moves `:latest` to point at the SHA tag the first job pushed.
|
||||
#
|
||||
# Has its own concurrency group with `cancel-in-progress: true`, which
|
||||
# gives us the serialization we need: if a newer push arrives while an
|
||||
# older run is mid-way through this job, the older run is cancelled
|
||||
# before it can clobber `:latest`. Combined with the ancestor check
|
||||
# below, this means `:latest` only ever moves forward in git history.
|
||||
move-latest:
|
||||
if: |
|
||||
github.repository == 'NousResearch/hermes-agent'
|
||||
&& github.event_name == 'push'
|
||||
&& github.ref == 'refs/heads/main'
|
||||
&& needs.build-and-push.outputs.pushed_sha_tag == 'true'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: docker-move-latest-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Read the git revision label off the current `:latest` manifest, then
|
||||
# use `git merge-base --is-ancestor` to check whether our commit is a
|
||||
# descendant of it. If `:latest` doesn't exist yet, or its label is
|
||||
# missing, we treat that as "safe to publish". If another run already
|
||||
# advanced `:latest` past us (or diverged), we skip and leave it alone.
|
||||
- name: Decide whether to move :latest
|
||||
id: latest_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
# Pull the JSON for the linux/amd64 sub-manifest's config and extract
|
||||
# the OCI revision label with jq — Go template field access can't
|
||||
# handle dots in map keys, so using json+jq is the robust route.
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:latest" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :latest (or inspect failed) — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :latest has no revision label — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :latest is at ${current_sha}"
|
||||
echo "This run is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":latest already points at our SHA — nothing to do."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Make sure we have the :latest commit locally for merge-base.
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Our SHA must be a descendant of the current :latest to be safe.
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our commit is a descendant of :latest — safe to advance."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Another run advanced :latest past us (or diverged) — leaving it alone."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Retag the already-pushed SHA manifest as :latest. This is a registry-
|
||||
# side operation — no rebuild, no layer re-push — so it's quick and
|
||||
# atomic per-tag. The ancestor check above plus the cancel-in-progress
|
||||
# concurrency on this job together guarantee we only ever move :latest
|
||||
# forward in git history.
|
||||
- name: Move :latest to this SHA
|
||||
if: steps.latest_check.outputs.push_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
docker buildx imagetools create \
|
||||
--tag "${image}:latest" \
|
||||
"${image}:sha-${GITHUB_SHA}"
|
||||
|
||||
13
.github/workflows/lint.yml
vendored
13
.github/workflows/lint.yml
vendored
@@ -48,12 +48,16 @@ jobs:
|
||||
|
||||
- name: Determine base ref
|
||||
id: base
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
# For PRs, diff against the merge base with the target branch.
|
||||
# For PRs, diff against the PR's pinned parent commit
|
||||
# (github.event.pull_request.base.sha — snapshot at PR open time,
|
||||
# so later pushes to main don't leak into the diff).
|
||||
# For pushes to main, diff against the previous commit on main.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
BASE_SHA="${PR_BASE_SHA}"
|
||||
BASE_REF="PR base (${BASE_SHA:0:7})"
|
||||
else
|
||||
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
|
||||
BASE_REF="HEAD~1"
|
||||
@@ -117,6 +121,9 @@ jobs:
|
||||
name: lint-reports
|
||||
path: .lint-reports/
|
||||
retention-days: 14
|
||||
# .lint-reports/ is a dotfile-prefixed directory, and upload-artifact@v4
|
||||
# skips hidden files by default (breaking change from v3). Opt back in.
|
||||
include-hidden-files: true
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
@@ -4216,7 +4216,7 @@ def _prompt_model_selection(
|
||||
clear_screen=False,
|
||||
title=effective_title,
|
||||
)
|
||||
idx = menu.show()
|
||||
idx: int | None = menu.show() # ty:ignore[invalid-assignment] - TerminalMenu.show() is always `int | None` when multi_select is False / not provided.
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
|
||||
@@ -967,27 +967,6 @@ class UserSystemdUnavailableError(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
class SystemScopeRequiresRootError(RuntimeError):
|
||||
"""Raised when a system-scope gateway operation is attempted as non-root.
|
||||
|
||||
System-scope units live in ``/etc/systemd/system/`` and require root for
|
||||
install / uninstall / start / stop / restart via ``systemctl``. The
|
||||
previous behavior was ``sys.exit(1)`` which blew past the wizard's
|
||||
``except Exception`` guards and dumped the user at a bare shell prompt
|
||||
with no guidance. Raising a typed exception lets callers that can
|
||||
recover (the setup wizard) print actionable remediation instead, while
|
||||
``gateway_command`` still exits 1 with the same message for the direct
|
||||
CLI path.
|
||||
|
||||
``args[0]`` carries the user-facing message, ``args[1]`` the action name.
|
||||
``str(e)`` returns only the message (not the tuple repr) so format
|
||||
strings like ``f"Failed: {e}"`` render cleanly.
|
||||
"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.args[0] if self.args else ""
|
||||
|
||||
|
||||
def _user_dbus_socket_path() -> Path:
|
||||
"""Return the expected per-user D-Bus socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
@@ -1403,10 +1382,8 @@ def print_systemd_scope_conflict_warning() -> None:
|
||||
|
||||
def _require_root_for_system_service(action: str) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise SystemScopeRequiresRootError(
|
||||
f"System gateway {action} requires root. Re-run with sudo.",
|
||||
action,
|
||||
)
|
||||
print(f"System gateway {action} requires root. Re-run with sudo.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, str]:
|
||||
@@ -1953,47 +1930,6 @@ def _select_systemd_scope(system: bool = False) -> bool:
|
||||
return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()
|
||||
|
||||
|
||||
def _system_scope_wizard_would_need_root(system: bool = False) -> bool:
|
||||
"""True when the setup wizard is about to trigger a system-scope operation
|
||||
as a non-root user.
|
||||
|
||||
Replicates the decision ``_select_systemd_scope`` makes inside
|
||||
``systemd_start`` / ``systemd_restart`` / ``systemd_stop`` so the wizard
|
||||
can detect the dead-end BEFORE prompting, rather than letting
|
||||
``SystemScopeRequiresRootError`` propagate out and leave the user
|
||||
staring at a bare shell.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
return False
|
||||
return _select_systemd_scope(system=system)
|
||||
|
||||
|
||||
def _print_system_scope_remediation(action: str) -> None:
|
||||
"""Print actionable remediation when the wizard skips a system-scope
|
||||
prompt because the user isn't root. Keeps the wizard flowing instead of
|
||||
aborting.
|
||||
"""
|
||||
svc = get_service_name()
|
||||
print_warning(
|
||||
f"Gateway is installed as a system-wide service — "
|
||||
f"{action} requires root."
|
||||
)
|
||||
print_info(" Options:")
|
||||
print_info(f" 1. {action.capitalize()} it this time:")
|
||||
if action == "start":
|
||||
print_info(f" sudo systemctl start {svc}")
|
||||
elif action == "stop":
|
||||
print_info(f" sudo systemctl stop {svc}")
|
||||
elif action == "restart":
|
||||
print_info(f" sudo systemctl restart {svc}")
|
||||
else:
|
||||
print_info(f" sudo systemctl {action} {svc}")
|
||||
print_info(" 2. Switch to a per-user service (recommended for personal use):")
|
||||
print_info(" sudo hermes gateway uninstall --system")
|
||||
print_info(" hermes gateway install")
|
||||
print_info(" hermes gateway start")
|
||||
|
||||
|
||||
def _get_restart_drain_timeout() -> float:
|
||||
"""Return the configured gateway restart drain timeout in seconds."""
|
||||
raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
|
||||
@@ -4179,9 +4115,7 @@ def gateway_setup():
|
||||
print_success("Gateway service is installed and running.")
|
||||
elif service_installed:
|
||||
print_warning("Gateway service is installed but not running.")
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start it now?", True):
|
||||
if prompt_yes_no(" Start it now?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4191,12 +4125,6 @@ def gateway_setup():
|
||||
print_error(" Failed to start — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have caught
|
||||
# this, but handle the race/edge case gracefully instead of
|
||||
# letting the exception escape the wizard.
|
||||
print_error(f" Failed to start: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Failed to start: {e}")
|
||||
else:
|
||||
@@ -4246,9 +4174,7 @@ def gateway_setup():
|
||||
service_running = _is_service_running()
|
||||
|
||||
if service_running:
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_restart()
|
||||
@@ -4261,15 +4187,10 @@ def gateway_setup():
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4279,9 +4200,6 @@ def gateway_setup():
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
@@ -4355,14 +4273,6 @@ def gateway_command(args):
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# The direct ``hermes gateway install|uninstall|start|stop|restart``
|
||||
# path lands here when the user typed a system-scope action without
|
||||
# sudo. Same exit code as before — just gives the wizard a way to
|
||||
# intercept the same condition with friendlier guidance before the
|
||||
# error is raised.
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
|
||||
@@ -3534,7 +3534,7 @@ def _remove_custom_provider(config):
|
||||
clear_screen=False,
|
||||
title="Select provider to remove:",
|
||||
)
|
||||
idx = menu.show()
|
||||
idx: int | None = menu.show() # ty:ignore[invalid-assignment] - TerminalMenu.show() is always `int | None` when multi_select is False / not provided.
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
@@ -3620,7 +3620,7 @@ def _model_flow_named_custom(config, provider_info):
|
||||
clear_screen=False,
|
||||
title=f"Select model from {name}:",
|
||||
)
|
||||
idx = menu.show()
|
||||
idx: int | None = menu.show() # ty:ignore[invalid-assignment] - TerminalMenu.show() is always `int | None` when multi_select is False / not provided.
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
@@ -3796,7 +3796,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
clear_screen=False,
|
||||
title="Select reasoning effort:",
|
||||
)
|
||||
idx = menu.show()
|
||||
idx: int | None = menu.show() # ty:ignore[invalid-assignment] - TerminalMenu.show() is always `int | None` when multi_select is False / not provided.
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
@@ -7582,7 +7582,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# systemd units without SIGUSR1 wiring this wait just times out
|
||||
# and we fall back to ``systemctl restart`` (the old behaviour).
|
||||
try:
|
||||
from hermes_constants import (
|
||||
from gateway.restart import (
|
||||
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT as _DEFAULT_DRAIN,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -2462,9 +2462,6 @@ def setup_gateway(config: dict):
|
||||
launchd_start,
|
||||
launchd_restart,
|
||||
UserSystemdUnavailableError,
|
||||
SystemScopeRequiresRootError,
|
||||
_system_scope_wizard_would_need_root,
|
||||
_print_system_scope_remediation,
|
||||
)
|
||||
|
||||
service_installed = _is_service_installed()
|
||||
@@ -2482,9 +2479,7 @@ def setup_gateway(config: dict):
|
||||
print()
|
||||
|
||||
if service_running:
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_restart()
|
||||
@@ -2494,19 +2489,10 @@ def setup_gateway(config: dict):
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have
|
||||
# caught this, but a race (unit file appearing mid-run)
|
||||
# could still land here. Previously this exited the
|
||||
# whole wizard via sys.exit(1).
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except Exception as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_start()
|
||||
@@ -2516,9 +2502,6 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
elif supports_service_manager:
|
||||
@@ -2546,9 +2529,6 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -281,8 +281,6 @@ _recorder_lock = threading.Lock()
|
||||
# ── Continuous (VAD) state ───────────────────────────────────────────
|
||||
_continuous_lock = threading.Lock()
|
||||
_continuous_active = False
|
||||
_continuous_stopping = False
|
||||
_continuous_auto_restart: bool = True
|
||||
_continuous_recorder: Any = None
|
||||
|
||||
# ── TTS-vs-STT feedback guard ────────────────────────────────────────
|
||||
@@ -372,43 +370,32 @@ def start_continuous(
|
||||
on_silent_limit: Optional[Callable[[], None]] = None,
|
||||
silence_threshold: int = 200,
|
||||
silence_duration: float = 3.0,
|
||||
auto_restart: bool = True,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Start a VAD-driven continuous recording loop.
|
||||
|
||||
The loop calls ``on_transcript(text)`` each time speech is detected and
|
||||
transcribed successfully. If ``auto_restart`` is True, it auto-restarts
|
||||
for the next turn and resets the no-speech counter for that loop. If
|
||||
``auto_restart`` is False, the first silence-triggered transcription ends
|
||||
the loop and reports ``"idle"``; no-speech counts are retained across
|
||||
starts so a push-to-talk caller can still enforce the three-strikes guard.
|
||||
After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit`` so the
|
||||
UI can reflect "voice off". Returns False if a previous stop is still
|
||||
transcribing/cleaning up; otherwise returns True. Idempotent — calling while
|
||||
already active is a successful no-op.
|
||||
transcribed successfully, then auto-restarts. After
|
||||
``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit``
|
||||
so the UI can reflect "voice off". Idempotent — calling while already
|
||||
active is a no-op.
|
||||
|
||||
``on_status`` is called with ``"listening"`` / ``"transcribing"`` /
|
||||
``"idle"`` so the UI can show a live indicator.
|
||||
"""
|
||||
global _continuous_active, _continuous_recorder, _continuous_auto_restart
|
||||
global _continuous_active, _continuous_recorder
|
||||
global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_no_speech_count
|
||||
|
||||
with _continuous_lock:
|
||||
if _continuous_active:
|
||||
_debug("start_continuous: already active — no-op")
|
||||
return True
|
||||
if _continuous_stopping:
|
||||
_debug("start_continuous: stop/transcribe in progress — busy")
|
||||
return False
|
||||
return
|
||||
_continuous_active = True
|
||||
_continuous_auto_restart = auto_restart
|
||||
_continuous_on_transcript = on_transcript
|
||||
_continuous_on_status = on_status
|
||||
_continuous_on_silent_limit = on_silent_limit
|
||||
if auto_restart:
|
||||
_continuous_no_speech_count = 0
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if _continuous_recorder is None:
|
||||
_continuous_recorder = create_audio_recorder()
|
||||
@@ -441,18 +428,15 @@ def start_continuous(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stop_continuous(force_transcribe: bool = False) -> None:
|
||||
def stop_continuous() -> None:
|
||||
"""Stop the active continuous loop and release the microphone.
|
||||
|
||||
Idempotent — calling while not active is a no-op. If ``force_transcribe`` is
|
||||
True, the recorder stops synchronously, then transcription/cleanup runs on a
|
||||
background thread before reporting ``"idle"``. Otherwise the buffer is
|
||||
discarded.
|
||||
Idempotent — calling while not active is a no-op. Any in-flight
|
||||
transcription completes but its result is discarded (the callback
|
||||
checks ``_continuous_active`` before firing).
|
||||
"""
|
||||
global _continuous_active, _continuous_on_transcript, _continuous_stopping
|
||||
global _continuous_active, _continuous_on_transcript
|
||||
global _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_recorder, _continuous_no_speech_count
|
||||
|
||||
@@ -462,98 +446,18 @@ def stop_continuous(force_transcribe: bool = False) -> None:
|
||||
_continuous_active = False
|
||||
rec = _continuous_recorder
|
||||
on_status = _continuous_on_status
|
||||
on_transcript = _continuous_on_transcript
|
||||
on_silent_limit = _continuous_on_silent_limit
|
||||
auto_restart = _continuous_auto_restart
|
||||
track_no_speech = force_transcribe and not auto_restart
|
||||
_continuous_stopping = rec is not None
|
||||
_continuous_on_transcript = None
|
||||
_continuous_on_status = None
|
||||
_continuous_on_silent_limit = None
|
||||
if not track_no_speech:
|
||||
_continuous_no_speech_count = 0
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if rec is not None:
|
||||
if force_transcribe and on_transcript:
|
||||
if on_status:
|
||||
try:
|
||||
on_status("transcribing")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
wav_path = rec.stop()
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop recorder: %s", e)
|
||||
try:
|
||||
rec.cancel()
|
||||
except Exception as cancel_error:
|
||||
logger.warning("failed to cancel recorder: %s", cancel_error)
|
||||
wav_path = None
|
||||
|
||||
def _transcribe_and_cleanup():
|
||||
global _continuous_no_speech_count, _continuous_stopping
|
||||
transcript: Optional[str] = None
|
||||
should_halt = False
|
||||
|
||||
try:
|
||||
if wav_path:
|
||||
try:
|
||||
result = transcribe_recording(wav_path)
|
||||
if result.get("success"):
|
||||
text = (result.get("transcript") or "").strip()
|
||||
if text and not is_whisper_hallucination(text):
|
||||
transcript = text
|
||||
finally:
|
||||
if os.path.isfile(wav_path):
|
||||
os.unlink(wav_path)
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop/transcribe recorder: %s", e)
|
||||
finally:
|
||||
if transcript:
|
||||
try:
|
||||
on_transcript(transcript)
|
||||
except Exception as e:
|
||||
logger.warning("on_transcript callback raised: %s", e)
|
||||
|
||||
if track_no_speech:
|
||||
with _continuous_lock:
|
||||
if transcript:
|
||||
_continuous_no_speech_count = 0
|
||||
else:
|
||||
_continuous_no_speech_count += 1
|
||||
should_halt = (
|
||||
_continuous_no_speech_count
|
||||
>= _CONTINUOUS_NO_SPEECH_LIMIT
|
||||
)
|
||||
if should_halt:
|
||||
_continuous_no_speech_count = 0
|
||||
if should_halt and on_silent_limit:
|
||||
try:
|
||||
on_silent_limit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_play_beep(frequency=660, count=2)
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_transcribe_and_cleanup, daemon=True).start()
|
||||
return
|
||||
else:
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
|
||||
# Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the
|
||||
# silence-auto-stop path plays).
|
||||
@@ -699,39 +603,23 @@ def _continuous_on_silence() -> None:
|
||||
_debug("_continuous_on_silence: stopped while waiting for TTS")
|
||||
return
|
||||
|
||||
if _continuous_auto_restart:
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Do not auto-restart. Clean up state and notify idle.
|
||||
_debug("_continuous_on_silence: auto_restart=False, stopping loop")
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── TTS API ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2177,171 +2177,3 @@ class TestSystemdInstallOffersLegacyRemoval:
|
||||
|
||||
assert prompt_called["count"] == 0
|
||||
assert remove_called["invoked"] is False
|
||||
|
||||
|
||||
class TestSystemScopeRequiresRootError:
|
||||
"""Tests for the SystemScopeRequiresRootError replacement of sys.exit(1).
|
||||
|
||||
Before this change, ``_require_root_for_system_service`` called
|
||||
``sys.exit(1)`` when non-root code tried a system-scope systemd
|
||||
operation. The wizard's ``except Exception`` guards don't catch
|
||||
``SystemExit`` (it's a ``BaseException`` subclass), so the user was
|
||||
dumped at a bare shell prompt mid-setup. The fix raises a typed
|
||||
exception instead, which the wizard intercepts and handles with
|
||||
actionable remediation.
|
||||
"""
|
||||
|
||||
def test_require_root_raises_when_non_root(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
|
||||
with pytest.raises(gateway_cli.SystemScopeRequiresRootError) as excinfo:
|
||||
gateway_cli._require_root_for_system_service("start")
|
||||
|
||||
assert excinfo.value.args[0] == "System gateway start requires root. Re-run with sudo."
|
||||
assert excinfo.value.args[1] == "start"
|
||||
# str(e) renders only the message, not the tuple repr, so that
|
||||
# wizard format strings like f"Failed: {e}" print cleanly.
|
||||
assert str(excinfo.value) == "System gateway start requires root. Re-run with sudo."
|
||||
assert f"Failed: {excinfo.value}" == "Failed: System gateway start requires root. Re-run with sudo."
|
||||
|
||||
def test_require_root_noop_when_root(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0)
|
||||
|
||||
# Should not raise, should not exit
|
||||
gateway_cli._require_root_for_system_service("start")
|
||||
|
||||
def test_error_is_runtime_error_subclass(self):
|
||||
"""Wizards use ``except Exception`` guards — the error must be a
|
||||
``RuntimeError`` (catchable by ``Exception``), NOT a ``SystemExit``
|
||||
(``BaseException``), so the wizard can recover from it.
|
||||
"""
|
||||
err = gateway_cli.SystemScopeRequiresRootError("msg", "start")
|
||||
assert isinstance(err, RuntimeError)
|
||||
assert isinstance(err, Exception)
|
||||
assert not isinstance(err, SystemExit)
|
||||
|
||||
|
||||
class TestSystemScopeWizardPreCheck:
|
||||
"""Tests for _system_scope_wizard_would_need_root — the guard the
|
||||
wizard uses to detect the dead-end BEFORE prompting the user to start
|
||||
a service that will fail without sudo.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _setup_units(tmp_path, monkeypatch, system_present: bool, user_present: bool):
|
||||
sys_dir = tmp_path / "sys"
|
||||
usr_dir = tmp_path / "usr"
|
||||
sys_dir.mkdir()
|
||||
usr_dir.mkdir()
|
||||
if system_present:
|
||||
(sys_dir / "hermes-gateway.service").write_text("[Unit]\n")
|
||||
if user_present:
|
||||
(usr_dir / "hermes-gateway.service").write_text("[Unit]\n")
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_systemd_unit_path",
|
||||
lambda system=False: (sys_dir if system else usr_dir) / "hermes-gateway.service",
|
||||
)
|
||||
|
||||
def test_non_root_with_only_system_unit_returns_true(self, tmp_path, monkeypatch):
|
||||
self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=False)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
|
||||
assert gateway_cli._system_scope_wizard_would_need_root() is True
|
||||
|
||||
def test_root_never_needs_root(self, tmp_path, monkeypatch):
|
||||
self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=False)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0)
|
||||
|
||||
assert gateway_cli._system_scope_wizard_would_need_root() is False
|
||||
|
||||
def test_non_root_with_user_unit_present_returns_false(self, tmp_path, monkeypatch):
|
||||
# User-scope unit present — user can start it themselves, no sudo needed.
|
||||
self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=True)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
|
||||
assert gateway_cli._system_scope_wizard_would_need_root() is False
|
||||
|
||||
def test_non_root_with_no_units_returns_false(self, tmp_path, monkeypatch):
|
||||
self._setup_units(tmp_path, monkeypatch, system_present=False, user_present=False)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
|
||||
assert gateway_cli._system_scope_wizard_would_need_root() is False
|
||||
|
||||
def test_non_root_with_explicit_system_arg_returns_true(self, tmp_path, monkeypatch):
|
||||
# Caller passed system=True explicitly (e.g. ``hermes gateway start --system``).
|
||||
self._setup_units(tmp_path, monkeypatch, system_present=False, user_present=False)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
|
||||
assert gateway_cli._system_scope_wizard_would_need_root(system=True) is True
|
||||
|
||||
|
||||
class TestSystemScopeRemediationOutput:
|
||||
"""Tests for _print_system_scope_remediation — the actionable guidance
|
||||
shown when the wizard detects a system-scope-only setup as non-root.
|
||||
"""
|
||||
|
||||
def test_start_remediation_mentions_sudo_systemctl_and_uninstall(self, capsys, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway")
|
||||
|
||||
gateway_cli._print_system_scope_remediation("start")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "system-wide service" in out
|
||||
assert "start requires root" in out
|
||||
assert "sudo systemctl start hermes-gateway" in out
|
||||
assert "sudo hermes gateway uninstall --system" in out
|
||||
assert "hermes gateway install" in out
|
||||
|
||||
def test_restart_remediation_uses_systemctl_restart(self, capsys, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway")
|
||||
|
||||
gateway_cli._print_system_scope_remediation("restart")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "restart requires root" in out
|
||||
assert "sudo systemctl restart hermes-gateway" in out
|
||||
|
||||
def test_stop_remediation_uses_systemctl_stop(self, capsys, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway")
|
||||
|
||||
gateway_cli._print_system_scope_remediation("stop")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "stop requires root" in out
|
||||
assert "sudo systemctl stop hermes-gateway" in out
|
||||
|
||||
|
||||
class TestGatewayCommandCatchesSystemScopeError:
|
||||
"""The direct CLI path (``hermes gateway start --system`` etc.) must
|
||||
still exit 1 with a clean message when non-root. The top-level
|
||||
``gateway_command`` catches ``SystemScopeRequiresRootError`` and
|
||||
converts it back to ``sys.exit(1)``, preserving existing CLI behavior.
|
||||
"""
|
||||
|
||||
def test_non_root_system_start_exits_one_with_clean_message(self, tmp_path, monkeypatch, capsys):
|
||||
sys_dir = tmp_path / "sys"
|
||||
usr_dir = tmp_path / "usr"
|
||||
sys_dir.mkdir()
|
||||
usr_dir.mkdir()
|
||||
(sys_dir / "hermes-gateway.service").write_text("[Unit]\n")
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"get_systemd_unit_path",
|
||||
lambda system=False: (sys_dir if system else usr_dir) / "hermes-gateway.service",
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda **kw: 0)
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=True, all=False)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
gateway_cli.gateway_command(args)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
|
||||
assert "System gateway start requires root. Re-run with sudo." in out
|
||||
assert "('" not in out # no tuple repr leaking through
|
||||
|
||||
@@ -309,7 +309,6 @@ class TestContinuousAPI:
|
||||
|
||||
# Isolate from any state left behind by other tests in the session.
|
||||
monkeypatch.setattr(voice, "_continuous_active", False)
|
||||
monkeypatch.setattr(voice, "_continuous_stopping", False, raising=False)
|
||||
monkeypatch.setattr(voice, "_continuous_recorder", None)
|
||||
|
||||
assert voice.is_continuous_active() is False
|
||||
@@ -344,20 +343,11 @@ class TestContinuousAPI:
|
||||
|
||||
monkeypatch.setattr(voice, "_continuous_recorder", FakeRecorder())
|
||||
|
||||
started = voice.start_continuous(on_transcript=lambda _t: None)
|
||||
voice.start_continuous(on_transcript=lambda _t: None)
|
||||
|
||||
# The guard inside start_continuous short-circuits before rec.start()
|
||||
assert started is True
|
||||
assert called["n"] == 0
|
||||
|
||||
def test_start_returns_false_while_stopping(self, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
monkeypatch.setattr(voice, "_continuous_active", False)
|
||||
monkeypatch.setattr(voice, "_continuous_stopping", True, raising=False)
|
||||
|
||||
assert voice.start_continuous(on_transcript=lambda _t: None) is False
|
||||
|
||||
|
||||
class TestContinuousLoopSimulation:
|
||||
"""End-to-end simulation of the VAD loop with a fake recorder.
|
||||
@@ -378,8 +368,6 @@ class TestContinuousLoopSimulation:
|
||||
monkeypatch.setattr(voice, "_continuous_on_transcript", None)
|
||||
monkeypatch.setattr(voice, "_continuous_on_status", None)
|
||||
monkeypatch.setattr(voice, "_continuous_on_silent_limit", None)
|
||||
monkeypatch.setattr(voice, "_continuous_auto_restart", True, raising=False)
|
||||
monkeypatch.setattr(voice, "_play_beep", lambda *_, **__: None)
|
||||
|
||||
class FakeRecorder:
|
||||
_silence_threshold = 200
|
||||
@@ -393,20 +381,13 @@ class TestContinuousLoopSimulation:
|
||||
self.cancelled = 0
|
||||
# Preset WAV path returned by stop()
|
||||
self.next_stop_wav = "/tmp/fake.wav"
|
||||
self.fail_stop = False
|
||||
self.fail_next_start = False
|
||||
|
||||
def start(self, on_silence_stop=None):
|
||||
if self.fail_next_start:
|
||||
self.fail_next_start = False
|
||||
raise RuntimeError("boom")
|
||||
self.start_calls += 1
|
||||
self.last_callback = on_silence_stop
|
||||
self.is_recording = True
|
||||
|
||||
def stop(self):
|
||||
if self.fail_stop:
|
||||
raise RuntimeError("stop failed")
|
||||
self.stopped += 1
|
||||
self.is_recording = False
|
||||
return self.next_stop_wav
|
||||
@@ -452,204 +433,6 @@ class TestContinuousLoopSimulation:
|
||||
|
||||
voice.stop_continuous()
|
||||
|
||||
def test_auto_restart_false_stops_after_first_transcript(self, fake_recorder, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": "single shot"},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
transcripts = []
|
||||
statuses = []
|
||||
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda t: transcripts.append(t),
|
||||
on_status=lambda s: statuses.append(s),
|
||||
auto_restart=False,
|
||||
)
|
||||
fake_recorder.last_callback()
|
||||
|
||||
assert transcripts == ["single shot"]
|
||||
assert fake_recorder.start_calls == 1
|
||||
assert statuses == ["listening", "transcribing", "idle"]
|
||||
assert voice.is_continuous_active() is False
|
||||
|
||||
def test_auto_restart_false_retains_silent_strikes_across_starts(
|
||||
self, fake_recorder, monkeypatch
|
||||
):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": ""},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
silent_limit_fired = []
|
||||
|
||||
for _ in range(3):
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda _t: None,
|
||||
on_silent_limit=lambda: silent_limit_fired.append(True),
|
||||
auto_restart=False,
|
||||
)
|
||||
fake_recorder.last_callback()
|
||||
|
||||
assert silent_limit_fired == [True]
|
||||
assert voice.is_continuous_active() is False
|
||||
assert fake_recorder.start_calls == 3
|
||||
|
||||
def test_force_transcribe_stop_delivers_current_buffer(self, fake_recorder, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon=False):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": "manual stop"},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
transcripts = []
|
||||
statuses = []
|
||||
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda t: transcripts.append(t),
|
||||
on_status=lambda s: statuses.append(s),
|
||||
)
|
||||
voice.stop_continuous(force_transcribe=True)
|
||||
|
||||
assert fake_recorder.stopped == 1
|
||||
assert transcripts == ["manual stop"]
|
||||
assert statuses == ["listening", "transcribing", "idle"]
|
||||
assert voice.is_continuous_active() is False
|
||||
|
||||
def test_force_transcribe_empty_single_shots_hit_silent_limit(
|
||||
self, fake_recorder, monkeypatch
|
||||
):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon=False):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": ""},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
silent_limit_fired = []
|
||||
|
||||
for _ in range(3):
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda _t: None,
|
||||
on_silent_limit=lambda: silent_limit_fired.append(True),
|
||||
auto_restart=False,
|
||||
)
|
||||
voice.stop_continuous(force_transcribe=True)
|
||||
|
||||
assert silent_limit_fired == [True]
|
||||
assert fake_recorder.stopped == 3
|
||||
assert voice._continuous_no_speech_count == 0
|
||||
|
||||
def test_force_transcribe_valid_single_shot_resets_silent_strikes(
|
||||
self, fake_recorder, monkeypatch
|
||||
):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon=False):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
|
||||
monkeypatch.setattr(voice, "_continuous_no_speech_count", 2)
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": "manual stop"},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
transcripts = []
|
||||
silent_limit_fired = []
|
||||
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda t: transcripts.append(t),
|
||||
on_silent_limit=lambda: silent_limit_fired.append(True),
|
||||
auto_restart=False,
|
||||
)
|
||||
voice.stop_continuous(force_transcribe=True)
|
||||
|
||||
assert transcripts == ["manual stop"]
|
||||
assert silent_limit_fired == []
|
||||
assert voice._continuous_no_speech_count == 0
|
||||
|
||||
def test_force_transcribe_stop_failure_cancels_and_clears_stopping(
|
||||
self, fake_recorder, monkeypatch
|
||||
):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, daemon=False):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
monkeypatch.setattr(voice.threading, "Thread", ImmediateThread)
|
||||
fake_recorder.fail_stop = True
|
||||
|
||||
statuses = []
|
||||
voice.start_continuous(
|
||||
on_transcript=lambda _t: None,
|
||||
on_status=lambda s: statuses.append(s),
|
||||
)
|
||||
voice.stop_continuous(force_transcribe=True)
|
||||
|
||||
assert fake_recorder.cancelled == 1
|
||||
assert statuses == ["listening", "transcribing", "idle"]
|
||||
assert voice.is_continuous_active() is False
|
||||
assert voice._continuous_stopping is False
|
||||
|
||||
def test_restart_failure_reports_idle(self, fake_recorder, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
monkeypatch.setattr(
|
||||
voice,
|
||||
"transcribe_recording",
|
||||
lambda _p: {"success": True, "transcript": "hello world"},
|
||||
)
|
||||
monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False)
|
||||
|
||||
statuses = []
|
||||
voice.start_continuous(on_transcript=lambda _t: None, on_status=statuses.append)
|
||||
|
||||
fake_recorder.fail_next_start = True
|
||||
fake_recorder.last_callback()
|
||||
|
||||
assert statuses == ["listening", "transcribing", "idle"]
|
||||
assert voice.is_continuous_active() is False
|
||||
|
||||
def test_silent_limit_halts_loop_after_three_strikes(self, fake_recorder, monkeypatch):
|
||||
import hermes_cli.voice as voice
|
||||
|
||||
|
||||
@@ -204,7 +204,6 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
||||
assert resp["result"]["status"] == "recording"
|
||||
assert captured["silence_threshold"] == 200
|
||||
assert captured["silence_duration"] == 3.0
|
||||
assert captured["auto_restart"] is False
|
||||
|
||||
# Round-12 Copilot review regression on #19835: ``bool`` is a subclass
|
||||
# of ``int``, so the naive ``isinstance(threshold, (int, float))``
|
||||
@@ -233,80 +232,6 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
||||
assert (
|
||||
captured["silence_duration"] == 3.0
|
||||
), f"bool silence_duration leaked through for {bad_bool_cfg!r}"
|
||||
assert captured["auto_restart"] is False
|
||||
|
||||
|
||||
def test_voice_record_stop_forces_transcription(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_stop_continuous(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.voice",
|
||||
types.SimpleNamespace(
|
||||
start_continuous=lambda **_kwargs: None,
|
||||
stop_continuous=fake_stop_continuous,
|
||||
),
|
||||
)
|
||||
|
||||
resp = server.dispatch(
|
||||
{
|
||||
"id": "voice-record-stop",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "stop"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["status"] == "stopped"
|
||||
assert captured["force_transcribe"] is True
|
||||
|
||||
|
||||
def test_voice_record_stop_updates_event_session_id(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.voice",
|
||||
types.SimpleNamespace(
|
||||
start_continuous=lambda **_kwargs: True,
|
||||
stop_continuous=lambda **_kwargs: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(server, "_voice_event_sid", "old-session")
|
||||
|
||||
resp = server.dispatch(
|
||||
{
|
||||
"id": "voice-record-stop-session",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "stop", "session_id": "new-session"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["status"] == "stopped"
|
||||
assert server._voice_event_sid == "new-session"
|
||||
|
||||
|
||||
def test_voice_record_start_reports_busy_when_stop_is_in_progress(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.voice",
|
||||
types.SimpleNamespace(
|
||||
start_continuous=lambda **_kwargs: False,
|
||||
stop_continuous=lambda **_kwargs: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_VOICE", "1")
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}})
|
||||
|
||||
resp = server.dispatch(
|
||||
{
|
||||
"id": "voice-record-busy",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "start"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["status"] == "busy"
|
||||
|
||||
|
||||
def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
|
||||
|
||||
@@ -5619,13 +5619,14 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
@method("voice.record")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""VAD-bounded push-to-talk capture, CLI-parity.
|
||||
"""VAD-driven continuous record loop, CLI-parity.
|
||||
|
||||
``start`` begins one VAD-bounded capture and emits ``voice.transcript``
|
||||
after silence stops the recorder. ``stop`` forces transcription of the
|
||||
active buffer, matching classic CLI push-to-talk. The voice wrapper retains
|
||||
no-speech counts across single-shot starts, so three consecutive silent
|
||||
captures emit ``voice.transcript`` with ``no_speech_limit=True``.
|
||||
``start`` turns on a VAD loop that emits ``voice.transcript`` events
|
||||
for each detected utterance and auto-restarts for the next turn.
|
||||
``stop`` halts the loop (manual stop; matches cli.py's Ctrl+B-while-
|
||||
recording branch clearing ``_voice_continuous``). Three consecutive
|
||||
silent cycles stop the loop automatically and emit a
|
||||
``voice.transcript`` with ``no_speech_limit=True``.
|
||||
"""
|
||||
action = params.get("action", "start")
|
||||
|
||||
@@ -5664,7 +5665,7 @@ def _(rid, params: dict) -> dict:
|
||||
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
|
||||
else 3.0
|
||||
)
|
||||
started = start_continuous(
|
||||
start_continuous(
|
||||
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
|
||||
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
|
||||
on_silent_limit=lambda: _voice_emit(
|
||||
@@ -5672,19 +5673,13 @@ def _(rid, params: dict) -> dict:
|
||||
),
|
||||
silence_threshold=safe_threshold,
|
||||
silence_duration=safe_duration,
|
||||
auto_restart=False,
|
||||
)
|
||||
if started is False:
|
||||
return _ok(rid, {"status": "busy"})
|
||||
return _ok(rid, {"status": "recording"})
|
||||
|
||||
# action == "stop"
|
||||
with _voice_sid_lock:
|
||||
_voice_event_sid = params.get("session_id") or _voice_event_sid
|
||||
|
||||
from hermes_cli.voice import stop_continuous
|
||||
|
||||
stop_continuous(force_transcribe=True)
|
||||
stop_continuous()
|
||||
return _ok(rid, {"status": "stopped"})
|
||||
except ImportError:
|
||||
return _err(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
|
||||
|
||||
describe('precisionWheel', () => {
|
||||
it('passes the first modifier-held wheel event', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 })
|
||||
})
|
||||
|
||||
it('coalesces same-frame events without throttling line-by-line scroll', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0)
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps queued momentum in precision mode briefly after modifier release', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 })
|
||||
})
|
||||
|
||||
it('leaves precision mode once modifier-free momentum goes idle', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 })
|
||||
})
|
||||
|
||||
it('does not coalesce immediate reversals', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
|
||||
|
||||
describe('applyVoiceRecordResponse', () => {
|
||||
it('reverts optimistic REC state when the gateway reports voice busy', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
const sys = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse({ status: 'busy' }, true, { setProcessing, setRecording }, sys)
|
||||
|
||||
expect(setRecording).toHaveBeenCalledWith(false)
|
||||
expect(setProcessing).toHaveBeenCalledWith(true)
|
||||
expect(sys).toHaveBeenCalledWith('voice: still transcribing; try again shortly')
|
||||
})
|
||||
|
||||
it('keeps optimistic REC state for successful recording starts', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse({ status: 'recording' }, true, { setProcessing, setRecording }, vi.fn())
|
||||
|
||||
expect(setRecording).not.toHaveBeenCalled()
|
||||
expect(setProcessing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverts optimistic REC state when the gateway returns null', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse(null, true, { setProcessing, setRecording }, vi.fn())
|
||||
|
||||
expect(setRecording).toHaveBeenCalledWith(false)
|
||||
expect(setProcessing).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
|
||||
import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
|
||||
|
||||
describe('viewportStore', () => {
|
||||
it('normalizes absent scroll handles', () => {
|
||||
@@ -51,35 +51,4 @@ describe('viewportStore', () => {
|
||||
expect(snap.atBottom).toBe(true)
|
||||
expect(snap.scrollHeight).toBe(20)
|
||||
})
|
||||
|
||||
it('keeps scrollbar position tied to committed scrollTop, not pending target', () => {
|
||||
const handle = {
|
||||
getPendingDelta: () => 24,
|
||||
getScrollHeight: () => 100,
|
||||
getScrollTop: () => 10,
|
||||
getViewportHeight: () => 20,
|
||||
isSticky: () => false
|
||||
}
|
||||
|
||||
const viewport = getViewportSnapshot(handle as any)
|
||||
const scrollbar = getScrollbarSnapshot(handle as any)
|
||||
|
||||
expect(viewport.top).toBe(34)
|
||||
expect(scrollbar).toEqual({
|
||||
scrollHeight: 100,
|
||||
top: 10,
|
||||
viewportHeight: 20
|
||||
})
|
||||
expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100')
|
||||
})
|
||||
|
||||
it('clamps scrollbar position to committed scroll bounds', () => {
|
||||
const handle = {
|
||||
getScrollHeight: () => 30,
|
||||
getScrollTop: () => 50,
|
||||
getViewportHeight: () => 20
|
||||
}
|
||||
|
||||
expect(getScrollbarSnapshot(handle as any).top).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
|
||||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
@@ -22,26 +21,8 @@ import { patchTurnState } from './turnStore.js'
|
||||
import { getUiState } from './uiStore.js'
|
||||
|
||||
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
||||
|
||||
export function applyVoiceRecordResponse(
|
||||
response: null | VoiceRecordResponse,
|
||||
starting: boolean,
|
||||
voice: Pick<InputHandlerContext['voice'], 'setProcessing' | 'setRecording'>,
|
||||
sys: (text: string) => void
|
||||
) {
|
||||
if (!starting || response?.status === 'recording') {
|
||||
return
|
||||
}
|
||||
|
||||
voice.setRecording(false)
|
||||
|
||||
if (response?.status === 'busy') {
|
||||
voice.setProcessing(true)
|
||||
sys('voice: still transcribing; try again shortly')
|
||||
} else {
|
||||
voice.setProcessing(false)
|
||||
}
|
||||
}
|
||||
const PRECISION_WHEEL_MIN_GAP_MS = 80
|
||||
const PRECISION_WHEEL_STICKY_MS = 80
|
||||
|
||||
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
||||
@@ -57,7 +38,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
// rows = wheelStep × accelMult. State mutates in place across renders.
|
||||
const wheelAccelRef = useRef(initWheelAccelForHost())
|
||||
|
||||
const precisionWheelRef = useRef(initPrecisionWheel())
|
||||
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
|
||||
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
|
||||
)
|
||||
|
||||
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
|
||||
|
||||
@@ -177,12 +160,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
}
|
||||
}
|
||||
|
||||
// CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture
|
||||
// CLI parity: Ctrl+B toggles the VAD-driven continuous recording loop
|
||||
// (NOT the voice-mode umbrella bit). The mode is enabled via /voice on;
|
||||
// Ctrl+B while the mode is off sys-nudges the user. While the mode is
|
||||
// on, the first press starts a single VAD-bounded capture
|
||||
// (gateway -> start_continuous(auto_restart=false), VAD auto-stop ->
|
||||
// transcribe -> idle), a subsequent press stops and transcribes it.
|
||||
// on, the first press starts a continuous loop (gateway → start_continuous,
|
||||
// VAD auto-stop → transcribe → auto-restart), a subsequent press stops it.
|
||||
// The gateway publishes voice.status + voice.transcript events that
|
||||
// createGatewayEventHandler turns into UI badges and composer injection.
|
||||
const voiceRecordToggle = () => {
|
||||
@@ -203,17 +185,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
voice.setProcessing(false)
|
||||
}
|
||||
|
||||
gateway
|
||||
.rpc<VoiceRecordResponse>('voice.record', { action, session_id: getUiState().sid })
|
||||
.then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys))
|
||||
.catch((e: Error) => {
|
||||
// Revert optimistic UI on failure.
|
||||
if (starting) {
|
||||
voice.setRecording(false)
|
||||
}
|
||||
gateway.rpc<VoiceRecordResponse>('voice.record', { action }).catch((e: Error) => {
|
||||
// Revert optimistic UI on failure.
|
||||
if (starting) {
|
||||
voice.setRecording(false)
|
||||
}
|
||||
|
||||
actions.sys(`voice error: ${e.message}`)
|
||||
})
|
||||
actions.sys(`voice error: ${e.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
@@ -312,26 +291,40 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
if (key.wheelUp || key.wheelDown) {
|
||||
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
||||
const now = Date.now()
|
||||
// Modifier-held wheel = precision mode: one row per frame, no accel.
|
||||
// Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
|
||||
// without the old 80ms throttle that made opt-scroll feel stepped.
|
||||
// Modifier-held wheel = precision mode: at most one wheelStep per short
|
||||
// interval. Smooth mice / trackpads emit many raw wheel events for one
|
||||
// intended line step, so raw 1:1 still moves too far.
|
||||
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
|
||||
// macOS is intercepted by the terminal, so we honor Option (meta) on
|
||||
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
|
||||
// is reserved for selection extension.
|
||||
const hasModifier = key.meta || key.ctrl
|
||||
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
|
||||
const precision = precisionWheelRef.current
|
||||
// Keep precision active through the current wheel burst after the
|
||||
// modifier is released. Otherwise a stream of queued/momentum wheel
|
||||
// events can hand off mid-burst into the accelerated path and jump.
|
||||
const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
|
||||
|
||||
if (precision.active) {
|
||||
// Entering precision mode must discard any accelerated wheel state;
|
||||
// otherwise the next normal wheel event inherits stale momentum.
|
||||
if (precision.entered) {
|
||||
if (hasModifier || precisionSticky) {
|
||||
if (!precision.active) {
|
||||
precision.active = true
|
||||
wheelAccelRef.current = initWheelAccelForHost()
|
||||
}
|
||||
|
||||
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
|
||||
precision.lastEventAtMs = now
|
||||
|
||||
if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
precision.lastScrollAtMs = now
|
||||
precision.dir = dir
|
||||
|
||||
return scrollTranscript(dir * wheelStep)
|
||||
}
|
||||
|
||||
precision.active = false
|
||||
|
||||
// 0 = direction-flip bounce deferred; skip the no-op scroll.
|
||||
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
|
||||
import unicodeSpinners from 'unicode-animations'
|
||||
|
||||
import { $delegationState } from '../app/delegationStore.js'
|
||||
@@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js'
|
||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
||||
import { fmtK } from '../lib/text.js'
|
||||
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
|
||||
import { useViewportSnapshot } from '../lib/viewportStore.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg, Usage } from '../types.js'
|
||||
|
||||
@@ -377,8 +377,7 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
|
||||
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [grab, setGrab] = useState<number | null>(null)
|
||||
const grabRef = useRef<number | null>(null)
|
||||
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
|
||||
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
|
||||
|
||||
if (!vp) {
|
||||
return <Box width={1} />
|
||||
@@ -406,20 +405,15 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
||||
onMouseDown={(e: { localRow?: number }) => {
|
||||
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
|
||||
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
|
||||
|
||||
grabRef.current = off
|
||||
setGrab(off)
|
||||
jump(row, off)
|
||||
}}
|
||||
onMouseDrag={(e: { localRow?: number }) =>
|
||||
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
|
||||
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
|
||||
}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
grabRef.current = null
|
||||
setGrab(null)
|
||||
}}
|
||||
onMouseUp={() => setGrab(null)}
|
||||
width={1}
|
||||
>
|
||||
{!scrollable ? (
|
||||
|
||||
@@ -295,7 +295,7 @@ export interface VoiceToggleResponse {
|
||||
}
|
||||
|
||||
export interface VoiceRecordResponse {
|
||||
status?: 'busy' | 'recording' | 'stopped'
|
||||
status?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const PRECISION_WHEEL_FRAME_MS = 16
|
||||
const PRECISION_WHEEL_STICKY_MS = 80
|
||||
|
||||
export type PrecisionWheelState = {
|
||||
active: boolean
|
||||
dir: 0 | -1 | 1
|
||||
lastEventAtMs: number
|
||||
lastScrollAtMs: number
|
||||
}
|
||||
|
||||
export type PrecisionWheelStep = {
|
||||
active: boolean
|
||||
entered: boolean
|
||||
rows: 0 | 1
|
||||
}
|
||||
|
||||
export function initPrecisionWheel(): PrecisionWheelState {
|
||||
return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
|
||||
}
|
||||
|
||||
export function computePrecisionWheelStep(
|
||||
state: PrecisionWheelState,
|
||||
dir: -1 | 1,
|
||||
hasModifier: boolean,
|
||||
now: number
|
||||
): PrecisionWheelStep {
|
||||
const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
|
||||
|
||||
if (!active) {
|
||||
state.active = false
|
||||
|
||||
return { active: false, entered: false, rows: 0 }
|
||||
}
|
||||
|
||||
const entered = !state.active
|
||||
|
||||
state.active = true
|
||||
state.lastEventAtMs = now
|
||||
|
||||
if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) {
|
||||
return { active: true, entered, rows: 0 }
|
||||
}
|
||||
|
||||
state.dir = dir
|
||||
state.lastScrollAtMs = now
|
||||
|
||||
return { active: true, entered, rows: 1 }
|
||||
}
|
||||
@@ -11,12 +11,6 @@ export interface ViewportSnapshot {
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
export interface ScrollbarSnapshot {
|
||||
scrollHeight: number
|
||||
top: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
const EMPTY: ViewportSnapshot = {
|
||||
atBottom: true,
|
||||
bottom: 0,
|
||||
@@ -26,12 +20,6 @@ const EMPTY: ViewportSnapshot = {
|
||||
viewportHeight: 0
|
||||
}
|
||||
|
||||
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
|
||||
scrollHeight: 0,
|
||||
top: 0,
|
||||
viewportHeight: 0
|
||||
}
|
||||
|
||||
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
|
||||
if (!s) {
|
||||
return EMPTY
|
||||
@@ -64,26 +52,6 @@ export function viewportSnapshotKey(v: ViewportSnapshot) {
|
||||
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
|
||||
}
|
||||
|
||||
export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
|
||||
if (!s) {
|
||||
return EMPTY_SCROLLBAR
|
||||
}
|
||||
|
||||
const viewportHeight = Math.max(0, s.getViewportHeight())
|
||||
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
|
||||
const maxTop = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
return {
|
||||
scrollHeight,
|
||||
top: Math.max(0, Math.min(maxTop, s.getScrollTop())),
|
||||
viewportHeight
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
|
||||
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
|
||||
}
|
||||
|
||||
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
|
||||
const key = useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
@@ -104,21 +72,3 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
}
|
||||
}, [key])
|
||||
}
|
||||
|
||||
export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
|
||||
const key = useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
|
||||
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')
|
||||
|
||||
return {
|
||||
scrollHeight: Number(scrollHeight),
|
||||
top: Number(top),
|
||||
viewportHeight: Number(viewportHeight)
|
||||
}
|
||||
}, [key])
|
||||
}
|
||||
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -1762,14 +1762,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.49"
|
||||
version = "3.1.46"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/63/210aaa302d6a0a78daa67c5c15bbac2cad361722841278b0209b6da20855/gitpython-3.1.49.tar.gz", hash = "sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1", size = 219367, upload-time = "2026-04-29T00:31:20.478Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/6f/b842bfa6f21d6f87c57f9abf7194225e55279d96d869775e19e9f7236fc5/gitpython-3.1.49-py3-none-any.whl", hash = "sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c", size = 212190, upload-time = "2026-04-29T00:31:18.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user