Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
25 changes: 25 additions & 0 deletions src/Sutando/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1174,8 +1174,33 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return "\(modSymbols)\(key)"
}

/// Publish the resolved hotkeys to `<workspace>/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 {
Expand Down
23 changes: 19 additions & 4 deletions src/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<span class="ok">✓</span> Sutando app running' if sutando_running else '<span class="bad">✗</span> Sutando app not running'
# Shortcuts come from <workspace>/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'<div style="margin:4px 0"><kbd style="background:#222;color:#aaa;padding:2px 6px;border-radius:3px;font-family:monospace">{e.get("label","")}</kbd> {_hk_desc.get(e.get("action"), e.get("action",""))}</div>'
for e in _hk
)
cards.append(f"""<div class="card">
<h2>Keyboard Shortcuts</h2>
<div class="check">{shortcut_status}</div>
<div style="margin-top:8px;font-size:12px;color:#555">
<div style="margin:4px 0"><kbd style="background:#222;color:#aaa;padding:2px 6px;border-radius:3px;font-family:monospace">⌃C</kbd> Context drop (text/image/file)</div>
<div style="margin:4px 0"><kbd style="background:#222;color:#aaa;padding:2px 6px;border-radius:3px;font-family:monospace">⌃S</kbd> Drop screenshot</div>
<div style="margin:4px 0"><kbd style="background:#222;color:#aaa;padding:2px 6px;border-radius:3px;font-family:monospace">⌃V</kbd> Toggle voice</div>
<div style="margin:4px 0"><kbd style="background:#222;color:#aaa;padding:2px 6px;border-radius:3px;font-family:monospace">⌃M</kbd> Toggle mute</div>
{_hk_rows}
</div></div>""")

# Quick links
Expand Down
39 changes: 31 additions & 8 deletions src/web-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,14 +742,9 @@ fetch('http://localhost:7844/stand-identity').then(r=>r.json()).then(s=>{
</div>

<div id="status-bar" style="text-align:center;font-size:16px;color:#888;letter-spacing:0.3px;padding:12px 16px">
<kbd style="background:#1a1a2e;padding:3px 8px;border-radius:4px;border:1px solid #333;font-family:monospace;color:#8af;font-size:14px">⌃C</kbd> drop context
<span style="margin:0 8px;color:#444">|</span>
<kbd style="background:#1a1a2e;padding:3px 8px;border-radius:4px;border:1px solid #333;font-family:monospace;color:#8af;font-size:14px">⌃S</kbd> drop screenshot
<span style="margin:0 8px;color:#444">|</span>
<kbd style="background:#1a1a2e;padding:3px 8px;border-radius:4px;border:1px solid #333;font-family:monospace;color:#8af;font-size:14px">⌃V</kbd> voice
<span style="margin:0 8px;color:#444">|</span>
<kbd style="background:#1a1a2e;padding:3px 8px;border-radius:4px;border:1px solid #333;font-family:monospace;color:#8af;font-size:14px">⌃M</kbd> mute
<span style="margin:0 8px;color:#444">|</span>
<!-- Hotkey hints render from /hotkeys (published by the Sutando app from its
resolved config — single source of truth, no hardcoded keys here). -->
<span id="hotkey-hints"></span>
<span id="core-status-bar" style="display:inline"></span>
<span id="presenter-badge">🎤 PRESENTER MODE</span>
<span id="mode-badge"></span>
Expand Down Expand Up @@ -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 = '<span style="margin:0 8px;color:#444">|</span>';
el.innerHTML = list.map(function (e) {
return '<kbd style="' + kbd + '">' + esc(e.label || '') + '</kbd> ' + (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 {}
Expand Down Expand Up @@ -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-
Expand Down
Loading