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
20 changes: 20 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

40 changes: 40 additions & 0 deletions core/tests/test_stop_shortcut_config.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions core/tstd/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions core/tstd/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions core/tstd/config_help_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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). |

Expand Down
17 changes: 17 additions & 0 deletions docs/tst-desk-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions shell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod received_files;
mod sent_files;
mod session_window;
mod single_instance;
mod stop_hotkey;
mod tray;
mod updater;

Expand Down Expand Up @@ -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(())
Expand Down
21 changes: 10 additions & 11 deletions shell/src/quick_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,18 @@ pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
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);
}
Expand Down
Loading
Loading