diff --git a/README.md b/README.md index a851832ad..4f04a5452 100644 --- a/README.md +++ b/README.md @@ -289,10 +289,13 @@ The Sutando menu bar app (`src/Sutando/`) provides global keyboard shortcuts. It | Shortcut | Action | |----------|--------| -| ⌃C | **Context drop** — sends selected text, clipboard image, or Finder file to Sutando | -| ⌃S | **Screenshot drop** — sends a screenshot of the active window/screen to Sutando | -| ⌃V | **Voice toggle** — connects/disconnects voice in the browser | -| ⌃M | **Mute toggle** — mutes/unmutes microphone during voice | +| ⌃V | **Toggle Voice** — connects/disconnects voice in the browser | +| ⌃⇧C | **Drop Context** — sends selected text, clipboard image, or Finder file to Sutando | +| ⌃M | **Toggle Mute** — mutes/unmutes microphone during voice | +| ⌃⇧R | **Drop Video Clip** — sends a screen recording of the active window/screen to Sutando | +| ⌃S | **Drop Screenshot** — sends a screenshot of the active window/screen to Sutando | + +Defaults live in `src/Sutando/main.swift` (`defaultHotkeys`); override per-machine via `~/.config/sutando/hotkeys.json`. The menu bar also has **Open Core** (brings up the Claude Code terminal) and **Open Dashboard** (opens the status dashboard at localhost:7844). diff --git a/src/Sutando/main.swift b/src/Sutando/main.swift index 0261ec122..99deee915 100644 --- a/src/Sutando/main.swift +++ b/src/Sutando/main.swift @@ -1174,8 +1174,33 @@ class AppDelegate: NSObject, NSApplicationDelegate { return "\(modSymbols)\(key)" } + /// Publish the resolved hotkeys to `/state/hotkeys.json` so the + /// web UI + dashboard render the real bindings instead of hardcoding their + /// own copies (which drifted — this is the single source they read). Same + /// atomic tmp+replace as the status-file writers above. + private func publishHotkeys(_ hotkeys: [(action: String, key: String, modifiers: [String])]) { + let entries = hotkeys.map { hk in + ["action": hk.action, + "label": displayLabel(key: hk.key, modifiers: hk.modifiers), + "key": hk.key, + "modifiers": hk.modifiers] as [String: Any] + } + guard let json = try? JSONSerialization.data(withJSONObject: entries, options: [.prettyPrinted]) else { return } + let stateDir = workspace + "/state" + try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true) + let dst = URL(fileURLWithPath: stateDir + "/hotkeys.json") + let tmp = URL(fileURLWithPath: stateDir + "/hotkeys.json.tmp") + do { + try json.write(to: tmp, options: [.atomic]) + _ = try FileManager.default.replaceItemAt(dst, withItemAt: tmp) + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } + func registerHotKey() { let hotkeys = loadHotkeyConfig() + publishHotkeys(hotkeys) var statuses: [String] = [] for (idx, hk) in hotkeys.enumerated() { guard let keyCode = AppDelegate.keyNameToCode[hk.key] else { diff --git a/src/dashboard.py b/src/dashboard.py index 03bc86158..ac977c4eb 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -358,14 +358,29 @@ def render_dashboard() -> str: # distributed .app (`/Applications/Sutando.app/Contents/MacOS/Sutando`). sutando_running = subprocess.run(["/usr/bin/pgrep", "-f", "(Sutando|MacOS)/Sutando"], capture_output=True).returncode == 0 shortcut_status = ' Sutando app running' if sutando_running else ' Sutando app not running' + # Shortcuts come from /state/hotkeys.json (published by the + # Sutando app from its resolved config — single source of truth). Only the + # human descriptions are local UI copy, keyed by the stable action name. + _hk_desc = { + "drop_context": "Context drop (text/image/file)", + "drop_screenshot": "Drop screenshot", + "drop_video_clip": "Drop video clip", + "toggle_voice": "Toggle voice", + "toggle_mute": "Toggle mute", + } + try: + _hk = json.loads((WORKSPACE_DIR / "state" / "hotkeys.json").read_text()) + except (OSError, ValueError): + _hk = [] # app hasn't published yet — show the header only + _hk_rows = "".join( + f'
{e.get("label","")} {_hk_desc.get(e.get("action"), e.get("action",""))}
' + for e in _hk + ) cards.append(f"""

Keyboard Shortcuts

{shortcut_status}
-
⌃C Context drop (text/image/file)
-
⌃S Drop screenshot
-
⌃V Toggle voice
-
⌃M Toggle mute
+{_hk_rows}
""") # Quick links diff --git a/src/web-client.ts b/src/web-client.ts index c73cdc68e..5c5e6417d 100644 --- a/src/web-client.ts +++ b/src/web-client.ts @@ -742,14 +742,9 @@ fetch('http://localhost:7844/stand-identity').then(r=>r.json()).then(s=>{
- ⌃C drop context - | - ⌃S drop screenshot - | - ⌃V voice - | - ⌃M mute - | + + 🎤 PRESENTER MODE @@ -807,11 +802,29 @@ function getDefaultWsUrl() { } // Set default WebSocket URL on page load + init Chrome STT +// Descriptions are local UI copy keyed by the stable action name; the KEY +// bindings come from /hotkeys (the app's published config) so they never drift. +function renderHotkeyHints() { + var desc = { drop_context: 'drop context', drop_screenshot: 'drop screenshot', + drop_video_clip: 'drop video', toggle_voice: 'voice', toggle_mute: 'mute' }; + var el = document.getElementById('hotkey-hints'); + if (!el) return; + fetch('/hotkeys').then(function (r) { return r.json(); }).then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var kbd = 'background:#1a1a2e;padding:3px 8px;border-radius:4px;border:1px solid #333;font-family:monospace;color:#8af;font-size:14px'; + var sep = '|'; + el.innerHTML = list.map(function (e) { + return '' + esc(e.label || '') + ' ' + (desc[e.action] || esc(e.action || '')); + }).join(sep) + sep; + }).catch(function () {}); +} + window.addEventListener('DOMContentLoaded', () => { const wsUrlInput = $('wsUrl'); if (wsUrlInput && !wsUrlInput.value) { wsUrlInput.value = getDefaultWsUrl(); } + renderHotkeyHints(); initChromeStt(); // Auto-reconnect voice if it was connected before refresh try { if (sessionStorage.getItem('sutando-voice')) { setTimeout(() => toggle(), 500); } } catch {} @@ -3595,6 +3608,16 @@ const server = createServer((req, res) => { return; } + // Hotkeys published by the Sutando app (state/hotkeys.json) — the single + // source the status-bar hints render from. Empty array if not yet written. + if (url.pathname === '/hotkeys') { + let hotkeys: unknown = []; + try { hotkeys = JSON.parse(readFileSync(join(STATE_DIR, 'hotkeys.json'), 'utf-8')); } catch {} + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(hotkeys)); + return; + } + // Presenter-mode sentinel (state/presenter-mode.sentinel written by // scripts/presenter-mode.sh). Replaces the old localhost:7877 poll that // required the iclr-highlight skill server. Uses the same malformed-