From c6f42bfa182eb29fe6745d3ed8f2c34b4d372fd8 Mon Sep 17 00:00:00 2001 From: Adam Crocker <77861255+captcrock@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:04:22 +0000 Subject: [PATCH] TD-5224: a system-wide stop shortcut for computer use (engage only) Cmd+. / Ctrl+. only works while TST Desk is the front window, and during computer use the agent is usually in another app. The app now registers a global shortcut, default Ctrl+Alt+Super+Period (Ctrl+Opt+Cmd+. on macOS), that creates the same machine-wide stop-file as the title-bar Stop (TD-5223), so every computer-use client halts. It only engages. An agent can press key combos, so a toggle would let it switch itself back on; resume stays a click in TST Desk. It does not take Cmd+. system-wide, since many apps use that for Cancel. The combo is computer_use.stop_shortcut (empty turns it off), read at launch. The app writes the file itself, so the shortcut works while the daemon is busy; the daemon's check then updates every window. The global-shortcut plugin is now built once and each shortcut registers its own handler, so quick entry (macOS) and the stop shortcut share it. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 20 +++ core/tests/test_stop_shortcut_config.py | 40 +++++ core/tstd/config.py | 6 + core/tstd/config.yaml | 2 + core/tstd/config_help_features.py | 3 + docs/configuration.md | 10 ++ docs/tst-desk-backlog.md | 17 ++ shell/src/lib.rs | 6 + shell/src/quick_entry.rs | 21 ++- shell/src/stop_hotkey.rs | 205 ++++++++++++++++++++++++ 10 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 core/tests/test_stop_shortcut_config.py create mode 100644 shell/src/stop_hotkey.rs diff --git a/DECISIONS.md b/DECISIONS.md index f9b25ed8..bbbcf3d3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -19589,3 +19589,23 @@ it exists. Capture, permissions and `ui_snapshot` keep working. - `TST_CU_MCP_STOP` set in the daemon's own environment keeps the switch engaged on Resume, matching tst-cu-mcp; the reply says so. +## 2026-11-02 — TD-5224: a system-wide stop shortcut, engage only (Class B) + +**Decision:** The app registers a global shortcut, default +`Ctrl+Alt+Super+Period` (⌃⌥⌘. on macOS), set by `computer_use.stop_shortcut` +and read at launch. Pressing it creates the TD-5223 stop-file in the app +process. It never removes the file. + +**Rationale:** +- Engage only: an agent can press key combos (`press_keys`), so a toggle + would let it switch itself back on. Resume stays a click in TST Desk. +- Not ⌘.: many apps use it for Cancel, and a global grab would take it + from all of them. Four modifiers plus `.` is a combo nothing else + claims; the in-window ⌘. stays as it was. +- The app writes the file itself instead of asking the daemon, so the + shortcut still stops input while the daemon is busy or restarting. The + daemon's once-a-second check (TD-5223) then updates every window. +- The global-shortcut plugin is built once, and each shortcut registers its + own handler (`on_shortcut`). Quick entry used to build the plugin with a + single handler, which would have left no room for a second shortcut. + diff --git a/core/tests/test_stop_shortcut_config.py b/core/tests/test_stop_shortcut_config.py new file mode 100644 index 00000000..73c7ec76 --- /dev/null +++ b/core/tests/test_stop_shortcut_config.py @@ -0,0 +1,40 @@ +"""The system-wide stop shortcut setting (TD-5224). + +The app shell reads ``computer_use.stop_shortcut`` from ``config.yaml`` at +launch and registers it; these pin the config side so the shipped file, the +model and the docs agree. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from tstd.config import ComputerUseConfig, ModelConfig + +SHIPPED = Path(__file__).resolve().parents[1] / "tstd" / "config.yaml" +DOCS = Path(__file__).resolve().parents[2] / "docs" / "configuration.md" +DEFAULT = "Ctrl+Alt+Super+Period" + + +def test_default_is_not_bare_cmd_period() -> None: + assert ComputerUseConfig().stop_shortcut == DEFAULT + assert ComputerUseConfig().stop_shortcut.lower() not in {"cmd+period", "super+period"} + + +def test_shipped_config_carries_the_default() -> None: + shipped = yaml.safe_load(SHIPPED.read_text(encoding="utf-8")) + assert shipped["computer_use"]["stop_shortcut"] == DEFAULT + + +def test_empty_turns_it_off_and_a_combo_round_trips() -> None: + off = ModelConfig.model_validate({"computer_use": {"stop_shortcut": ""}}) + assert off.computer_use.stop_shortcut == "" + custom = ModelConfig.model_validate({"computer_use": {"stop_shortcut": "Ctrl+Shift+F12"}}) + assert custom.computer_use.stop_shortcut == "Ctrl+Shift+F12" + + +def test_documented() -> None: + text = DOCS.read_text(encoding="utf-8") + assert f"| `stop_shortcut` | string | `{DEFAULT}` |" in text diff --git a/core/tstd/config.py b/core/tstd/config.py index 4f1760fe..b6f17548 100644 --- a/core/tstd/config.py +++ b/core/tstd/config.py @@ -1333,12 +1333,18 @@ class ComputerUseConfig(BaseModel): Brain stays on the active preset. ``accessibility`` turns on the element-level desktop tools (TD-5220). + + ``stop_shortcut`` is the system-wide combo that stops computer use on + the whole machine, even with TST Desk in the background (TD-5224). It + only engages; resume is a click in TST Desk. Empty turns it off. The + app reads it at launch. """ command: str | list[str] = "" browser: Literal["mock", "playwright"] = "mock" grounding: GroundingConfig = Field(default_factory=GroundingConfig) accessibility: AccessibilityConfig = Field(default_factory=AccessibilityConfig) + stop_shortcut: str = "Ctrl+Alt+Super+Period" local_worker_preset: str = "" # Hosted vLLM forge is --limit-mm-per-prompt image:1 (TD-1730). max_prompt_images: int = Field(default=1, ge=0) diff --git a/core/tstd/config.yaml b/core/tstd/config.yaml index 24fef779..a5ee5cf8 100644 --- a/core/tstd/config.yaml +++ b/core/tstd/config.yaml @@ -354,6 +354,8 @@ computer_use: sha256: "" timeout_seconds: 20 max_snapshot_chars: 12000 + # System-wide stop (TD-5224): engage only; empty turns it off. + stop_shortcut: Ctrl+Alt+Super+Period local_worker_preset: "" max_prompt_images: 1 diff --git a/core/tstd/config_help_features.py b/core/tstd/config_help_features.py index f2fbe6da..69e9710c 100644 --- a/core/tstd/config_help_features.py +++ b/core/tstd/config_help_features.py @@ -114,6 +114,9 @@ "no real pointer.", "ComputerUseConfig.browser": "Browser driver: mock never launches a browser.", "ComputerUseConfig.grounding": "Local vision model for click targeting.", + "ComputerUseConfig.stop_shortcut": "System-wide key combo that stops computer use on the " + "whole machine, even with TST Desk in the background. It only stops; resume in TST Desk. " + "Empty turns it off. Takes effect at the next launch.", "ComputerUseConfig.local_worker_preset": "Model whose worker tier serves a session after " "it uses a desktop or browser tool. Empty never switches.", "ComputerUseConfig.max_prompt_images": "How many images a request may keep; the oldest " diff --git a/docs/configuration.md b/docs/configuration.md index acccd8e6..4428a334 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -282,6 +282,15 @@ onboarding is the same kind of honesty as Windows (no grant dialog). A Wayland session is unsupported (TD-2002); `health` reports `supported: false` and `session_type: "wayland"`. +**Stop** (title bar, ⌘. / Ctrl+. in the window, the palette, and the +system-wide `stop_shortcut`) creates the tst-cu-mcp stop-file +(`killswitch.stop_file` in that config, else `$TST_CU_MCP_STOP_FILE`, else +`~/.tst-cu-mcp/STOP`). Every computer-use client refuses input while it +exists, including ones TST Desk did not start, and the host's +`cu-agent.sock` refuses too. Capture keeps working. Resume removes it. A +stop-file created by another tool shows in TST Desk as stopped within a +second (TD-5223). + Settings → Computer use (TD-4830) writes `~/.tst-cu-mcp/config.yaml` (or `$TST_CU_MCP_CONFIG`): `actuation.enabled`, `mode` (`background` or `full_control`), `scoping.denied_apps`, `scoping.unhide_on_finish`. The @@ -297,6 +306,7 @@ under the user data dir. Playwright missing always falls back to mock. | `browser` | `mock` or `playwright` | `mock` | Browser driver. `mock` never launches Chrome. `playwright` uses a persistent profile under the user data dir when Playwright is installed; otherwise the mock. | | `grounding` | mapping | see below | Local vision model for click targeting (TD-3902). | | `accessibility` | mapping | see below | Element-level desktop tools on the accessibility tree (TD-5220). Off by default. | +| `stop_shortcut` | string | `Ctrl+Alt+Super+Period` | System-wide shortcut that stops computer use on the whole machine, even while TST Desk is in the background (TD-5224): ⌃⌥⌘. on macOS, Ctrl+Alt+Super+. elsewhere. It creates the same stop-file as the title-bar Stop, so every computer-use client halts. It only engages: resume is a click in TST Desk, because an agent can press key combos. Modifiers `Ctrl`, `Alt`/`Option`, `Super`/`Cmd`, `Shift` joined with `+`, then one key (`Period`, `F12`, `KeyS`, …). Empty turns it off. Read at launch. Not available in a Wayland session. | | `local_worker_preset` | string | *empty* | After this session has used a `desktop_` or `browser_` tool, the worker *client* uses that named preset's worker tier (loopback URL + optional slug). Brain stays on the active preset. Empty never remaps. The name is a preset key, not a model slug. Lead-turns, `set_tier`, and escalation are unchanged. | | `max_prompt_images` | int ≥ 0 | `1` | How many `image_url` parts a completion may keep. Oldest images (screenshots, and images `fs_read` returned) are dropped first. `1` matches gateway-model (`--limit-mm-per-prompt image:1`). It also caps the page images one PDF read returns (at most 5), and `0` makes `fs_read` treat every tier as text-only (TD-5130). | diff --git a/docs/tst-desk-backlog.md b/docs/tst-desk-backlog.md index 9d6f824e..2ae75058 100644 --- a/docs/tst-desk-backlog.md +++ b/docs/tst-desk-backlog.md @@ -13041,6 +13041,23 @@ step 1; elsewhere the binary comes from `binary` or `PATH`. --- +### TD-5224 — A system-wide stop shortcut for computer use +**Size:** 3 · **Depends on:** TD-5223 +**Status:** In review 2026-11-02. + +⌘. / Ctrl+. only works while TST Desk is the front window, and during +computer use the agent is usually working in another app. A shortcut that +works from anywhere stops it without switching windows first. + +**Acceptance criteria:** +- [x] Works while TST Desk is in the background (a global shortcut registered by the app) +- [x] Creates the same stop-file as the title-bar Stop (TD-5223), so every computer-use client halts +- [x] Engage only: it never resumes, so an agent pressing the combo cannot switch actuation back on +- [x] Does not take ⌘. system-wide; default `Ctrl+Alt+Super+Period` (⌃⌥⌘. on macOS), configurable as `computer_use.stop_shortcut`, empty turns it off +- [x] Quick entry (macOS) and the stop shortcut share one global-shortcut plugin + +--- + ### TD-5223 — Stop halts every computer-use agent on the machine **Size:** 5 · **Depends on:** TD-3404, TD-4830 **Status:** In review 2026-11-02. The live macOS check (an outside client acting through `cu-agent.sock`, then Stop) runs separately against the PR build. diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 0af762f5..3f3ec6ca 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -11,6 +11,7 @@ mod received_files; mod sent_files; mod session_window; mod single_instance; +mod stop_hotkey; mod tray; mod updater; @@ -367,6 +368,11 @@ pub fn run() { let embeddings = daemon::embeddings::start(data_dir); app.manage(handle); app.manage(embeddings); + // One global-shortcut plugin; each shortcut registers its own + // handler (quick entry on macOS, the stop shortcut everywhere). + app.handle() + .plugin(tauri_plugin_global_shortcut::Builder::new().build())?; + stop_hotkey::setup(app.handle(), &daemon::data_dir()); quick_entry::setup(app)?; updater::start(app.handle()); Ok(()) diff --git a/shell/src/quick_entry.rs b/shell/src/quick_entry.rs index 705c7b44..08473bcb 100644 --- a/shell/src/quick_entry.rs +++ b/shell/src/quick_entry.rs @@ -163,19 +163,18 @@ pub fn setup(app: &tauri::App) -> Result<(), Box> { Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState, }; + // The plugin is built once in lib.rs (TD-5224 shares it for the + // computer-use stop shortcut); each shortcut brings its own handler. let shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::Period); let handle = app.handle().clone(); - app.handle().plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler(move |app, sc, event| { - if sc == &shortcut && event.state() == ShortcutState::Pressed { - toggle_overlay(app); - } - }) - .build(), - )?; - - if let Err(err) = handle.global_shortcut().register(shortcut) { + let registered = handle + .global_shortcut() + .on_shortcut(shortcut, |app, _sc, event| { + if event.state() == ShortcutState::Pressed { + toggle_overlay(app); + } + }); + if let Err(err) = registered { log::warn!("quick entry shortcut registration failed: {err}"); emit_permission_notice(&handle); } diff --git a/shell/src/stop_hotkey.rs b/shell/src/stop_hotkey.rs new file mode 100644 index 00000000..e613278d --- /dev/null +++ b/shell/src/stop_hotkey.rs @@ -0,0 +1,205 @@ +//! System-wide computer-use stop shortcut (TD-5224). +//! +//! A global shortcut that works while TST Desk is in the background and +//! creates the machine-wide stop-file (TD-5223), the same file the title-bar +//! Stop writes. Every computer-use client refuses input while it exists, and +//! the daemon shows it as stopped within a second. +//! +//! Engage only. The shortcut never resumes: an agent can press key combos, +//! so a toggle would let it switch itself back on. Resume stays a click in +//! TST Desk. +//! +//! The combo is `computer_use.stop_shortcut` in `config.yaml` (default +//! `Ctrl+Alt+Super+Period`, ⌃⌥⌘. on macOS). Empty turns it off. It is +//! read at launch. + +use std::path::{Path, PathBuf}; + +use crate::cu_stop::{self, Sources}; + +/// ⌃⌥⌘. on macOS; Ctrl+Alt+Super+. elsewhere. Unlike ⌘. (Cancel in many +/// apps) nothing else claims it. +pub const DEFAULT_SHORTCUT: &str = "Ctrl+Alt+Super+Period"; + +/// What `config.yaml` asks for. +#[derive(Debug, PartialEq, Eq)] +pub enum Setting { + /// Register this combo. + Combo(String), + /// `stop_shortcut: ""`: no system-wide shortcut. + Off, +} + +/// `computer_use.stop_shortcut` from the config text; the default when unset. +pub fn setting_from_config(config_text: Option<&str>) -> Setting { + let value = config_text + .and_then(|text| serde_yml::from_str::(text).ok()) + .and_then(|doc| { + doc.get("computer_use")? + .get("stop_shortcut") + .map(|v| v.as_str().map(str::to_owned)) + }); + match value { + // Present but not a string (null, a number): fall back to the default. + None | Some(None) => Setting::Combo(DEFAULT_SHORTCUT.into()), + Some(Some(text)) if text.trim().is_empty() => Setting::Off, + Some(Some(text)) => Setting::Combo(text.trim().to_string()), + } +} + +pub fn setting(data_dir: &Path) -> Setting { + let text = std::fs::read_to_string(data_dir.join("config.yaml")).ok(); + setting_from_config(text.as_deref()) +} + +/// Create the stop-file for these sources. Never removes it. +pub fn engage(sources: &Sources) -> std::io::Result { + let path = cu_stop::resolve(sources).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory") + })?; + cu_stop::engage_at(&path)?; + Ok(path) +} + +/// Register the shortcut. The global-shortcut plugin must already be built. +pub fn setup(app: &tauri::AppHandle, data_dir: &Path) { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + + let combo = match setting(data_dir) { + Setting::Off => { + log::info!("computer-use stop shortcut is off (computer_use.stop_shortcut)"); + return; + } + Setting::Combo(combo) => combo, + }; + let shortcut: Shortcut = match combo.parse() { + Ok(shortcut) => shortcut, + Err(err) => { + log::warn!("computer-use stop shortcut {combo:?} is not a valid combo: {err}"); + return; + } + }; + let registered = app + .global_shortcut() + .on_shortcut(shortcut, |_app, _shortcut, event| { + if event.state() != ShortcutState::Pressed { + return; + } + match engage(&Sources::live()) { + Ok(path) => log::info!("computer use stopped by shortcut: {}", path.display()), + Err(err) => log::error!("computer-use stop shortcut could not stop: {err}"), + } + }); + match registered { + Ok(()) => log::info!("computer-use stop shortcut registered: {combo}"), + Err(err) => log::warn!("computer-use stop shortcut {combo:?} not registered: {err}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut}; + + static N: AtomicU32 = AtomicU32::new(0); + + fn temp_home() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "tst-stop-key-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn sources(home: &Path) -> Sources { + Sources { + home: Some(home.to_path_buf()), + ..Sources::default() + } + } + + #[test] + fn default_when_unset() { + assert_eq!( + setting_from_config(None), + Setting::Combo(DEFAULT_SHORTCUT.into()) + ); + assert_eq!( + setting_from_config(Some("computer_use:\n command: ''\n")), + Setting::Combo(DEFAULT_SHORTCUT.into()) + ); + assert_eq!( + setting_from_config(Some("computer_use:\n stop_shortcut:\n")), + Setting::Combo(DEFAULT_SHORTCUT.into()) + ); + } + + #[test] + fn configured_combo_and_off() { + assert_eq!( + setting_from_config(Some("computer_use:\n stop_shortcut: Ctrl+Shift+F12\n")), + Setting::Combo("Ctrl+Shift+F12".into()) + ); + assert_eq!( + setting_from_config(Some("computer_use:\n stop_shortcut: ''\n")), + Setting::Off + ); + } + + #[test] + fn default_is_ctrl_alt_super_period_not_bare_cmd_period() { + let parsed: Shortcut = DEFAULT_SHORTCUT.parse().unwrap(); + assert_eq!( + parsed, + Shortcut::new( + Some(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER), + Code::Period + ) + ); + let cmd_period = Shortcut::new(Some(Modifiers::SUPER), Code::Period); + assert_ne!(parsed, cmd_period); + } + + #[test] + fn engage_creates_the_stop_file() { + let home = temp_home(); + let s = sources(&home); + assert!(!cu_stop::engaged_for(&s)); + let path = engage(&s).unwrap(); + assert_eq!(path, home.join(".tst-cu-mcp").join("STOP")); + assert!(cu_stop::engaged_for(&s)); + } + + #[test] + fn engage_never_resumes() { + // Pressing it again while stopped (an agent mashing the combo) keeps + // it stopped: there is no toggle. + let home = temp_home(); + let s = sources(&home); + engage(&s).unwrap(); + engage(&s).unwrap(); + assert!(cu_stop::engaged_for(&s)); + } + + #[test] + fn engage_follows_the_configured_stop_file() { + let home = temp_home(); + let config = home.join("cu.yaml"); + let target = home.join("elsewhere").join("STOP"); + std::fs::write( + &config, + format!("killswitch:\n stop_file: {}\n", target.display()), + ) + .unwrap(); + let s = Sources { + config_env: Some(config.display().to_string()), + ..sources(&home) + }; + assert_eq!(engage(&s).unwrap(), target); + assert!(target.exists()); + } +}