From 1c575ad2639ca4ffdb0f7061d1d71099af068883 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 17 May 2026 18:42:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20remote=20ho?= =?UTF-8?q?st=20resolution=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- bin/rclaude | 40 ++++++++-- hammerspoon/rvoice.lua | 138 ++++++++++++++++++++++++++++++---- tests/test_rclaude_helpers.sh | 17 +++++ 3 files changed, 172 insertions(+), 23 deletions(-) diff --git a/bin/rclaude b/bin/rclaude index 309afa0..5ae3471 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -1006,6 +1006,30 @@ cmd_resume() { # Dispatch # --------------------------------------------------------------------------- +# Resolve the hostname THIS machine is reachable at from the remote. Used +# to tell the remote claude session where to forward audio/state back to. +# Override with RCLAUDE_BACK_HOST in config (e.g. if the local hostname +# isn't directly reachable from the remote — pick a wg1 mesh IP / .lan name). +caller_hostname() { + if [ -n "${RCLAUDE_BACK_HOST:-}" ]; then + printf %s "$RCLAUDE_BACK_HOST" + return + fi + _hn=$(hostname -s 2>/dev/null || hostname) + case $_hn in + *.*) printf %s "$_hn" ;; + *) printf '%s.lan' "$_hn" ;; + esac +} + +# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch +# so callers can invoke individual helpers without launching anything. +# MUST be placed after every helper definition so all functions are +# available to the sourcing test runner. +if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + cmd_version() { _self=$(resolve_self) _repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd) @@ -1020,12 +1044,6 @@ cmd_version() { fi } -# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch -# so callers can invoke individual helpers without launching anything. -if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then - return 0 2>/dev/null || exit 0 -fi - cmd_voice() { # `rclaude voice` — toggle / inspect the rvoice push-to-talk binding. # rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon. @@ -1167,8 +1185,16 @@ build_inner() { elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then _resume_flag="--continue" fi + # When launching on a remote host, tell its MCPs where to forward + # audio back to (so apricot's TTS plays on the local Mac, etc.). When + # local, leave the env alone — local MCPs play locally. + _back_env="" + if ! is_local "$host"; then + _back=$(caller_hostname) + _back_env="export SPEECH_PLAYBACK_HOST=${_back}; " + fi printf '%s' \ - "cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \ + "${_back_env}cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \ "rc_e=\$(date +%s); rc_d=\$((rc_e - rc_t)); " \ "if [ \$rc_ec -ne 0 ] || [ \$rc_d -lt 2 ]; then " \ "printf '\\n[rclaude] claude exited in %ds with code %d\\n' \$rc_d \$rc_ec; " \ diff --git a/hammerspoon/rvoice.lua b/hammerspoon/rvoice.lua index 1d74e63..6f183b3 100644 --- a/hammerspoon/rvoice.lua +++ b/hammerspoon/rvoice.lua @@ -13,6 +13,15 @@ -- Behavior: hold Right-Option to talk. Release to transcribe + inject into -- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are -- ignored (configurable via RVOICE_MIN_MS env in rvoice config). +-- +-- Visual feedback (in order, from least to most intrusive): +-- 1. Menu-bar dot — gray idle, red filled while recording, yellow during +-- transcription (a few hundred ms). Always present so you know rvoice +-- is loaded. +-- 2. Floating "● REC" overlay — top-center of the focused screen, only +-- visible while the key is held. Goes away on release. +-- 3. Transcript toast — short-lived alert with the recognized text after +-- a successful injection, or an error message on failure. local M = {} @@ -44,45 +53,142 @@ local function isDisabled() if f then f:close(); return true end return false end -if isDisabled() then - hs.alert.show("rvoice: disabled (rclaude voice on to re-enable)") - return {} + +-- ────────────────────────────────────────────────────────────────────────── +-- Visual feedback +-- ────────────────────────────────────────────────────────────────────────── + +-- Menu-bar status dot. Always visible while rvoice is loaded. +M.menubar = hs.menubar.new() +local function setMenu(state) + -- state: "idle" | "recording" | "transcribing" | "disabled" + local glyph, tooltip + if state == "recording" then glyph = "🔴"; tooltip = "rvoice: recording" + elseif state == "transcribing" then glyph = "🟡"; tooltip = "rvoice: transcribing" + elseif state == "disabled" then glyph = "⚪"; tooltip = "rvoice: disabled" + else glyph = "🎙️"; tooltip = "rvoice: idle (hold Right ⌥ to talk)" + end + if M.menubar then + M.menubar:setTitle(glyph) + M.menubar:setTooltip(tooltip) + end end --- Run rvoice in the background; capture stderr to the system log so --- failures are visible via Hammerspoon's console. -local function run(cmd) - local t = hs.task.new("/bin/sh", function(exit, _, err) +-- Click the menubar to open the action log (handy for quick debugging). +if M.menubar then + M.menubar:setClickCallback(function() + hs.task.new("/bin/sh", nil, {"-c", RVOICE .. " log | tail -20 | pbcopy"}):start() + hs.alert.show("rvoice log copied to clipboard") + end) +end + +if isDisabled() then + setMenu("disabled") + hs.alert.show("rvoice: disabled (rclaude voice on to re-enable)") + return M +end + +setMenu("idle") + +-- Floating "● REC" overlay shown while the key is held. hs.alert is built +-- for short pop-ups, but a long duration + an explicit closeAll on release +-- gives us the persistent-while-holding behavior we want with no per-frame +-- repaint cost. +local recAlertUUID = nil +local function showRecording() + recAlertUUID = hs.alert.show( + "🔴 REC", + { textSize = 36, radius = 12, fillColor = { red = 0.85, alpha = 0.85 }, + strokeColor = { white = 1, alpha = 0.6 }, strokeWidth = 2 }, + hs.screen.mainScreen(), + 9999 -- effectively indefinite; closed on key-up + ) +end +local function hideRecording() + if recAlertUUID then hs.alert.closeSpecific(recAlertUUID); recAlertUUID = nil + else hs.alert.closeAll(0) end +end + +-- Toast helpers for the post-transcription result. +local function toastOk(text) + local snippet = text + if #snippet > 120 then snippet = snippet:sub(1, 117) .. "…" end + hs.alert.show( + "✓ " .. snippet, + { textSize = 16, radius = 8, fillColor = { green = 0.6, alpha = 0.85 } }, + hs.screen.mainScreen(), + 2.5 + ) +end +local function toastErr(text) + hs.alert.show( + "✗ " .. text, + { textSize = 16, radius = 8, fillColor = { red = 0.7, alpha = 0.85 } }, + hs.screen.mainScreen(), + 3.5 + ) +end + +-- ────────────────────────────────────────────────────────────────────────── +-- Action dispatch +-- ────────────────────────────────────────────────────────────────────────── + +-- Run rvoice asynchronously. The callback receives stdout so we can +-- surface the transcript in a toast on success. rvoice writes the recognized +-- text to stdout when invoked as `rvoice stop --print-text`; for `start` +-- we just fire and forget. +local function runAsync(cmd, onDone) + local t = hs.task.new("/bin/sh", function(exit, stdout, stderr) + if onDone then onDone(exit, stdout or "", stderr or "") end if exit ~= 0 then - hs.printf("[rvoice] %s exited %d: %s", cmd, exit, err or "") + hs.printf("[rvoice] %s exited %d: %s", cmd, exit, stderr or "") end end, {"-c", RVOICE .. " " .. cmd}) - -- Inherit user shell env so PATH for ffmpeg/jq is set and rvoice can - -- source ~/.config/rvoice/config to pick up any user overrides. - t:setEnvironment(hs.execute("env", true):gsub("\n$", "") and nil or nil) t:start() end +local function doStart() + setMenu("recording") + showRecording() + runAsync("start") +end + +local function doStop() + hideRecording() + setMenu("transcribing") + runAsync("stop --print-text", function(exit, stdout, stderr) + setMenu("idle") + local text = (stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") + if exit == 0 and text ~= "" then + toastOk(text) + elseif exit == 0 then + -- silent success (e.g. taps below min duration) — no toast + else + local err = (stderr or ""):gsub("^%s+", ""):gsub("%s+$", "") + if err == "" then err = "transcription failed" end + toastErr(err) + end + end) +end + -- Right-Option keyDown/keyUp. Hammerspoon delivers modifier changes through -- eventtap.flagsChanged; we watch for the rightAlt flag transitioning. M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e) - -- macOS exposes the side via a per-key mask. Right-Option is 0x40 in the - -- raw `keyCode` event of type flagsChanged (code 61). local code = e:getKeyCode() if code ~= 61 then return false end -- 61 = Right Option local flags = e:getFlags() local pressed = flags.alt or false if pressed and not holding then holding = true - run("start") + doStart() elseif (not pressed) and holding then holding = false - run("stop") + doStop() end return false -- don't swallow the modifier; other apps may use it end) M.tap:start() -hs.alert.show("rvoice: Right ⌥ to talk") +hs.alert.show("rvoice: Right ⌥ to talk", 1.5) return M diff --git a/tests/test_rclaude_helpers.sh b/tests/test_rclaude_helpers.sh index 151bb36..0614e81 100644 --- a/tests/test_rclaude_helpers.sh +++ b/tests/test_rclaude_helpers.sh @@ -78,3 +78,20 @@ test_get_home_unknown_returns_zero() { test_get_home_local_returns_HOME() { assert_eq "$HOME" "$(get_home local)" } + +# --------------------------------------------------------------------------- +# caller_hostname — used by build_inner to tell remote MCPs where to send +# things back (audio playback, etc.) +# --------------------------------------------------------------------------- + +test_caller_hostname_env_override() { + assert_eq "wg1.10.9.0.3" "$(RCLAUDE_BACK_HOST=wg1.10.9.0.3 caller_hostname)" +} + +test_caller_hostname_default_adds_lan() { + # Default behavior should produce a fqdn-ish form (either already + # has a dot, or .lan got appended). We don't assert the exact host, + # just that the result is non-empty and dotted. + _out=$(unset RCLAUDE_BACK_HOST; caller_hostname) + assert_contains "$_out" "." "caller_hostname output should be dotted" +}