session-tools/hammerspoon/rvoice.lua
Natalie dbcaf999f9 feat(@scripts): add disk reclaim, host probe, power-cycle tools
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-17 19:38:40 -07:00

225 lines
9.3 KiB
Lua

-- rvoice.lua — Right-Option push-to-talk for the rvoice helper.
--
-- Install:
-- 1. Hammerspoon → Preferences → enable "Launch Hammerspoon at login"
-- 2. Add this line to ~/.hammerspoon/init.lua:
-- require("rvoice")
-- 3. Symlink this file so init.lua can find it:
-- ln -sfn ~/Code/@scripts/session-tools/hammerspoon/rvoice.lua \
-- ~/.hammerspoon/rvoice.lua
-- 4. Reload Hammerspoon config (menu bar → Reload Config)
-- 5. Grant Accessibility + Microphone permissions when prompted.
--
-- Behavior: hold Right ⌥ (Right Option) to talk — but only when the
-- focused iTerm2 tab is attached to an rclaude session (i.e. its title
-- matches `<host> · claude-…`, the format set by session-tools/tmux.conf).
-- Outside that context Right ⌥ passes through unmodified, so the key still
-- types its usual special characters in other apps.
-- Release to transcribe + inject into the active rclaude tmux session.
-- Taps shorter than 200ms are ignored (configurable via RVOICE_MIN_MS).
--
-- 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 = {}
-- Resolve `rvoice` once at load. Hammerspoon's task PATH is barebones, so
-- prefer an explicit symlink in ~/.local/bin or fall back to the repo path.
local function resolveRvoice()
local candidates = {
os.getenv("HOME") .. "/.local/bin/rvoice",
os.getenv("HOME") .. "/Code/@scripts/session-tools/bin/rvoice",
}
for _, p in ipairs(candidates) do
local f = io.open(p, "r")
if f then f:close(); return p end
end
return "rvoice"
end
local RVOICE = resolveRvoice()
local holding = false
-- rvoice can be toggled off via `rclaude voice off`, which drops a sentinel
-- file. If present at load time, skip starting the eventtap entirely so the
-- Right-⌥ key behaves normally. `rclaude voice on` calls reloadConfig which
-- re-runs this module.
local DISABLE_FLAG = (os.getenv("XDG_STATE_HOME") or (os.getenv("HOME") .. "/.local/state"))
.. "/rclaude/voice-disabled"
local function isDisabled()
local f = io.open(DISABLE_FLAG, "r")
if f then f:close(); return true end
return false
end
-- ──────────────────────────────────────────────────────────────────────────
-- 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
-- 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 <cmd> 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, stderr or "")
end
end, {"-c", RVOICE .. " " .. cmd})
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
-- Context gate: PTT only fires when the focused iTerm2 tab is showing an
-- rclaude session. The canonical tmux config sets the title to
-- "<host> · <session>"; for rclaude the session name always starts with
-- "claude-". Anything else (browser, finder, local iTerm2 shell tab,
-- a non-rclaude tmux) returns false so the key behaves normally.
local function inRclaudeSession()
local front = hs.application.frontmostApplication()
if not front then return false end
local name = front:name()
if name ~= "iTerm2" and name ~= "iTerm" then return false end
-- Pull the title of the active session via AppleScript. Cheap (~5ms);
-- we only run this on a Right ⌥ keyDown, not on every event.
local ok, title = hs.osascript.applescript(
'tell application "iTerm2" to tell current session of current window to return name')
if not ok or type(title) ~= "string" then return false end
-- Canonical tmux title set by session-tools/tmux.conf:
-- "#H · #S" → "apricot · claude-natalie-..."
-- We're permissive on whitespace around the separator but require the
-- session name to start with "claude-" (rclaude's invariant).
return title:match("·%s*claude%-") ~= nil
end
-- Right-Option push-to-talk. Hammerspoon delivers modifier transitions via
-- flagsChanged; we gate on keycode 61 (Right Option) and read the alt flag
-- to determine press vs release.
M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e)
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
-- Only arm PTT when the focused tab is an rclaude session. If we
-- didn't arm on keyDown, the release branch below will also skip
-- because `holding` stays false.
if not inRclaudeSession() then return false end
holding = true
doStart()
elseif (not pressed) and holding then
holding = false
doStop()
end
return false -- don't swallow; other apps may want the modifier
end)
M.tap:start()
hs.alert.show("rvoice: hold Right ⌥ to talk", 1.5)
return M