Skip to content

ianmurrays/hammerspoon

Repository files navigation

Hammerspoon Config

Personal Hammerspoon configuration for macOS automation — window management, Slack status, encrypted scratchpad, GIF search, and more.

Modules

Module Purpose Hotkey
window_manager Rectangle-style window tiling with fraction cycling (1/2, 1/3, 2/3) Ctrl+Alt+Cmd + arrows/F/Home/End
scratchpad Encrypted markdown editor synced via iCloud Ctrl+Alt+S
gif_finder GIF search via Klipy API with favorites and recents, copies URL to clipboard Ctrl+Alt+G
slack_status Auto-updates Slack status based on WiFi network; manual overrides and custom status via menu
hyperduck Monitors iCloud file for URLs sent from iPhone, opens them on Mac
battery_indicator Shows remaining battery time in menu bar
screen_blur Full-screen blur overlay for privacy (downsample trick via sips) Ctrl+Alt+B
stt Local speech-to-text via parakeet-mlx daemon with optional LLM post-processing, audio tones, media pause/resume, and transcription history viewer fn+Space (toggle) / fn+Shift (hold) / Ctrl+Alt+H (history)
clipboard_history Clipboard history with search, auto-skips password manager entries, 30-day retention Ctrl+Alt+V
mouse_grid Keyboard-driven mouse (Mouseless-style): full-screen hint grid for click, right/double click, drag & drop, and scrolling; element hints mode (Shortcat-style, via the Accessibility API); free mode for smooth relative cursor movement Tap left Cmd (grid) / Double-tap left Cmd (hints) / Tap left Alt (free)
unified_menu Combines Slack Status, Hyperduck, Scratchpad, Screen Blur, and Clipboard History into a single menubar item

Hotkeys

Shortcut Action
Ctrl+Alt+Cmd+Left Tile window left (cycles 1/2 → 1/3 → 2/3)
Ctrl+Alt+Cmd+Right Tile window right (cycles 1/2 → 1/3 → 2/3)
Ctrl+Alt+Cmd+Up Tile window top (cycles 1/2 → 1/3 → 2/3)
Ctrl+Alt+Cmd+Down Tile window bottom (cycles 1/2 → 1/3 → 2/3)
Ctrl+Alt+Cmd+F Maximize window
Ctrl+Alt+Cmd+Home Move window to previous display
Ctrl+Alt+Cmd+End Move window to next display
Ctrl+Alt+S Toggle scratchpad
Ctrl+Alt+G Toggle GIF finder
Ctrl+Alt+B Toggle screen blur overlay (also dismisses on click or any keypress)
fn+Space Toggle speech-to-text recording (press to start, press again to stop and paste)
fn+Shift Hold-to-talk speech-to-text (hold both to record, release to stop and paste)
Ctrl+Alt+H Toggle STT transcription history viewer
Ctrl+Alt+V Toggle clipboard history viewer
Tap left Cmd Toggle mouse grid overlay (quick press+release of left Cmd alone)
Double-tap left Cmd Element hints mode (Shortcat-style: labels on clickable UI elements)
Tap left Alt Toggle free mouse mode (relative cursor movement, no overlay)

Note: Home = Fn+Left and End = Fn+Right on Mac keyboards.

Mouse Grid (while overlay is up)

Type a cell's two characters (first char = row, a–z top-to-bottom; second char = column, keyboard rows qwert/asdfg/zxcvb left-to-right). Cells are wide horizontal rectangles (26 rows × 15 columns). Then pick a precision point in the subgrid shown inside the cell (qwert / asdfg / zxcvb, laid out spatially) or press Space for the cell center. A hint toast at the bottom of the screen shows the available keys at every step. Modifiers held on the final key choose the action:

Key Action
subgrid key / Space Left click
Shift + final key Right click
Ctrl + final key Double click
Alt + final key Move cursor only (no click)
Cmd + final key Arm drag (mouse down; overlay stays up — next selection drops)
Hold final key Nudge: cursor jumps to the point and arrows/h/j/k/l move it in small steps (Shift = bigger); releasing the key performs the action (modifiers apply at release)
Backspace Undo one selection level (or cancel a nudge)
Tab Move overlay to next screen
, Scroll mode: h/j/k/l or arrows scroll, Shift = faster, Esc exits
Esc Dismiss overlay (cancels an armed drag)

Hints Mode (double-tap left Cmd)

Shortcat-style element hints: the focused window's accessibility tree is scanned for actionable elements (buttons, links, text fields, checkboxes, menu items…) and each one gets a short yellow label. Typing a label's characters filters the hints live (non-matching ones dim out); completing a label performs the action at that element's center. No screenshots involved — element positions come straight from the macOS Accessibility API.

Key Action
label chars Filter hints / act when a label is completed
Space Search mode: type the element's text (title/label/value) to find it
Shift + final char Right click
Ctrl + final char Double click
Alt + final char Move cursor only (no click)
Cmd + final char Arm drag (mouse down; hints reappear — next label drops)
Backspace Un-type one label character
Esc Exit hints mode (cancels an armed drag)

While searching, matching elements are outlined instead of labelled and typing goes to the query:

Key Action
any text Filter elements by their accessibility text (case-insensitive substring)
Tab / Shift+Tab Select next / previous match (red outline)
Enter Act on the selected match — same modifiers as above (Shift right, Ctrl double, Alt move, Cmd drag)
Backspace Delete a query character (on an empty query: back to labels)
Esc Back to label mode (Esc again exits hints)

Notes:

  • Scans the focused window of the frontmost app. A "scanning…" toast shows while the (asynchronous) traversal runs; very large windows are capped at 400 hints.
  • Chromium browsers (Chrome, Arc, Brave, Edge, Vivaldi) and Electron apps (Slack, Discord, VS Code, Notion) hide web/page content from the accessibility tree by default. The module temporarily enables the relevant accessibility attribute (AXEnhancedUserInterface / AXManualAccessibility) while hints are up and restores it on exit, since leaving it on can make window snapping glitchy. The first scan in these apps may take one extra ~200ms rescan while the tree populates. The app lists are configurable (enhancedUIApps / electronApps).
  • Single-tap left Cmd while hints are up switches to the grid; tap left Alt to switch to free mode.

Free Mode (tap left Alt)

Moves the real cursor with the keyboard — no grid overlay, just a hint toast and a soft glow around the screen edges so it's obvious the mode is active (the glow follows the cursor across monitors). Exits on Esc or after 10s of inactivity. Tapping left Cmd switches to the grid; tapping left Alt while the grid is up switches to free mode.

Key Action
h / j / k / l Move cursor left/down/up/right (hold; diagonals work)
Shift (held) Move faster (4×)
Ctrl (held) Move slower (0.25×, precision)
Space Left click (mode stays active)
Shift+Space Right click
Ctrl+Space Double click
Cmd+Space Drag toggle — press to grab, move, press Space again to drop
The 4 keys right of N Scroll left/up/down/right (Shift = faster) — matched by physical position, so m , . / on US, m , . - on Spanish ISO; the toast shows the keys for your layout
Esc Exit free mode (releases a held drag)

File Structure

Webview modules (gif_finder, slack_status, scratchpad, stt, clipboard_history) store their HTML, CSS, and JS in separate files under html/:

html/
  gif_finder/    — GIF search UI
  slack_status/  — Custom status form
  scratchpad/    — CodeMirror markdown editor
  stt_history/        — Transcription history viewer
  clipboard_history/  — Clipboard history viewer

Each directory contains index.html, style.css, and script.js. At runtime, html_loader.lua reads these files and inlines the CSS/JS into the HTML before passing it to hs.webview:html().

Setup

Prerequisites

  1. Install Hammerspoon
  2. Grant Accessibility permissions when prompted (System Settings → Privacy & Security → Accessibility)

Keychain Secrets

Store secrets in the macOS Keychain — they are never saved in code.

# Slack API token (xoxp-...)
security add-generic-password -a "$USER" -s "slack-status-token" -w "YOUR_TOKEN"

# Klipy GIF search API key (https://partner.klipy.com)
security add-generic-password -a "$USER" -s "klipy-api-key" -w "YOUR_API_KEY"

# Mistral API key for STT post-processing (optional — omit to disable)
security add-generic-password -a "$USER" -s "mistral-api-key" -w "YOUR_API_KEY"

STT Daemon

The speech-to-text module requires a local Python daemon running parakeet-mlx:

cd ~/.hammerspoon/stt-daemon
uv sync
uv run stt_daemon.py

To run as a background service via launchd:

cp ~/.hammerspoon/stt-daemon/com.local.stt-daemon.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.local.stt-daemon.plist

Logs are written to ~/Library/Logs/stt-daemon.log.

LLM Post-Processing (Optional)

When a Mistral API key is present in the keychain, transcribed text is sent through the Mistral API to remove filler words, fix punctuation/capitalization, and apply light grammar corrections. The pill overlay shows a purple "Polishing..." spinner during this step. If the API call fails or times out (10s), the raw transcription is pasted instead.

Configuration options in init.lua:

stt.init({
    llm_api_key = mistralApiKey,
    -- llm_model = "mistral-small-latest",           -- model to use
    -- llm_system_prompt = "...",                     -- custom prompt
    -- llm_api_url = "https://api.mistral.ai/v1/chat/completions",  -- API endpoint
    -- llm_timeout = 10,                             -- seconds before fallback
})

The API uses the OpenAI-compatible chat completions format, so other providers (OpenRouter, Groq, Together, etc.) work by changing llm_api_url, llm_model, and llm_api_key.

Transcription History & Audio Backup

Each transcription is appended to an iCloud-synced history file at ~/Library/Mobile Documents/com~apple~CloudDocs/STT/history.txt. Entries include a UTC timestamp, the raw transcription, and the LLM-polished version (if different). The history file grows indefinitely — clean up manually via Finder if needed.

The daemon also saves each recording to a temporary WAV file in /tmp/ before transcription. On success, the WAV is automatically deleted. On failure (transcription error, daemon crash), the WAV is preserved for debugging or manual recovery.

Audio Tones & Media Control

By default, the STT module plays subtle macOS system sounds at key moments:

  • Tink — recording starts
  • Pop — recording stops
  • Glass — transcription/polishing complete

It also pauses any currently playing media (Spotify, Music, YouTube, etc.) when recording starts and resumes it when recording stops. Media state detection uses media-control, which must be installed via Homebrew:

brew tap ungive/media-control && brew install media-control

Both features can be disabled in init.lua:

stt.init({
    play_tones = false,   -- disable notification sounds
    pause_media = false,  -- disable media pause/resume
})

The scratchpad encryption key is generated automatically on first use. To copy it to another Mac:

# Export from source Mac
security find-generic-password -a "hammerspoon" -s "scratchpad-encryption-key" -w

# Import on target Mac
security add-generic-password -a "hammerspoon" -s "scratchpad-encryption-key" -w "PASTE_KEY_HERE"

iCloud Sync

The scratchpad, Hyperduck, and GIF Finder modules store files in iCloud Drive:

  • Scratchpad: ~/Library/Mobile Documents/com~apple~CloudDocs/Scratchpad/scratchpad.txt
  • Hyperduck: ~/Library/Mobile Documents/com~apple~CloudDocs/Hyperduck/inbox.txt
  • GIF Finder: ~/Library/Mobile Documents/com~apple~CloudDocs/GifFinder/favorites.json and recents.json
  • STT: ~/Library/Mobile Documents/com~apple~CloudDocs/STT/history.txt — append-only transcription history
  • Clipboard History: ~/Library/Mobile Documents/com~apple~CloudDocs/ClipboardHistory/history.json — clipboard entries (30-day retention)

Hyperduck requires an iPhone Shortcut that appends timestamped URLs (timestamp|url format) to the inbox file. URLs older than 7 days are automatically purged.

Reloading

After making changes, reload the config:

  • Menu bar: Click the Hammerspoon icon → Reload Config
  • Console: Open Hammerspoon console and press Cmd+Shift+R

About

My hammerspoon config

Topics

Resources

Stars

Watchers

Forks

Contributors