From 602cc1143109e641ed509265cdf4bbb911fc311b Mon Sep 17 00:00:00 2001 From: Susan Xueqing Liu Date: Sat, 4 Jul 2026 12:27:13 -0400 Subject: [PATCH 1/3] docs(hotkeys): sync README + web UI + dashboard with actual keybindings Drop Context is Ctrl+Shift+C (not Ctrl+C), and Drop Video Clip (Ctrl+Shift+R) was undocumented. Three places hardcode the shortcut list (README, web-client.ts status bar, dashboard.py) and none read ~/.config/sutando/hotkeys.json, so they drifted from main.swift's registration. Corrected all three; noted the source of truth in the README so the next change has one place to check. --- README.md | 11 +++++++---- src/dashboard.py | 3 ++- src/web-client.ts | 4 +++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a851832ad..536a5f786 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` (`hotkeyDefaults`); 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/dashboard.py b/src/dashboard.py index 03bc86158..7f05c8800 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -362,8 +362,9 @@ def render_dashboard() -> str:

Keyboard Shortcuts

{shortcut_status}
-
⌃C Context drop (text/image/file)
+
⌃⇧C Context drop (text/image/file)
⌃S Drop screenshot
+
⌃⇧R Drop video clip
⌃V Toggle voice
⌃M Toggle mute
""") diff --git a/src/web-client.ts b/src/web-client.ts index c73cdc68e..c07297bc9 100644 --- a/src/web-client.ts +++ b/src/web-client.ts @@ -742,10 +742,12 @@ fetch('http://localhost:7844/stand-identity').then(r=>r.json()).then(s=>{
- ⌃C drop context + ⌃⇧C drop context | ⌃S drop screenshot | + ⌃⇧R drop video + | ⌃V voice | ⌃M mute From 73b67f58704f5d7353835c88e11d5b397618f5d2 Mon Sep 17 00:00:00 2001 From: Susan Xueqing Liu Date: Sat, 4 Jul 2026 12:39:01 -0400 Subject: [PATCH 2/3] =?UTF-8?q?Fix=20README=20source-of-truth=20pointer:?= =?UTF-8?q?=20defaultHotkeys=20(not=20hotkeyDefaults)=20=E2=80=94=20per=20?= =?UTF-8?q?Pro's=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 536a5f786..4f04a5452 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ The Sutando menu bar app (`src/Sutando/`) provides global keyboard shortcuts. It | ⌃⇧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` (`hotkeyDefaults`); override per-machine via `~/.config/sutando/hotkeys.json`. +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). From 9390159a4e4e0e649fd26cf458858a8c0ec14982 Mon Sep 17 00:00:00 2001 From: Susan Xueqing Liu Date: Sat, 4 Jul 2026 13:19:04 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(hotkeys):=20single=20source=20?= =?UTF-8?q?=E2=80=94=20app=20publishes=20state/hotkeys.json,=20UIs=20read?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix (Chi's ask on #1920): the hotkey list was hardcoded in three places (README, web-client.ts, dashboard.py) and none read the app's config, so they drifted from main.swift's registration (Drop Context showed ⌃C when it's ⌃⇧C; Drop Video Clip ⌃⇧R was undocumented). - main.swift: publishHotkeys() writes the resolved effective bindings to /state/hotkeys.json (reusing the existing atomic tmp+replace; it already computes the ⌃⇧C display labels). Called from registerHotKey(). - web-client.ts: new /hotkeys endpoint + client renderHotkeyHints() populates the status bar from it. No hardcoded keys; descriptions stay local UI copy keyed by the stable action name. - dashboard.py: reads the same file to build its shortcut rows. - README stays a static table (humans read markdown) with the defaultHotkeys source pointer. Change a binding once (defaults or ~/.config/sutando/hotkeys.json) and all surfaces follow. Verified: swiftc -typecheck, tsc --noEmit, py ast + render. --- src/Sutando/main.swift | 25 +++++++++++++++++++++++++ src/dashboard.py | 24 +++++++++++++++++++----- src/web-client.ts | 41 +++++++++++++++++++++++++++++++---------- 3 files changed, 75 insertions(+), 15 deletions(-) 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 7f05c8800..ac977c4eb 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -358,15 +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
-
⌃⇧R Drop video clip
-
⌃V Toggle voice
-
⌃M Toggle mute
+{_hk_rows}
""") # Quick links diff --git a/src/web-client.ts b/src/web-client.ts index c07297bc9..5c5e6417d 100644 --- a/src/web-client.ts +++ b/src/web-client.ts @@ -742,16 +742,9 @@ fetch('http://localhost:7844/stand-identity').then(r=>r.json()).then(s=>{
- ⌃⇧C drop context - | - ⌃S drop screenshot - | - ⌃⇧R drop video - | - ⌃V voice - | - ⌃M mute - | + + 🎤 PRESENTER MODE @@ -809,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 {} @@ -3597,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-