Skip to content

Add support for Wayland by polling /dev/input - #12

Open
FichteFoll wants to merge 21 commits into
mainfrom
feature/wayland-keystate-helper
Open

FichteFoll wants to merge 21 commits into
mainfrom
feature/wayland-keystate-helper

Conversation

@FichteFoll

@FichteFoll FichteFoll commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Add a separate self-compilable helper that polls /dev/input for keyboard events on Linux. Using this helper tool with elevated permissions allows to read the input events directly, granting us a side channel to poll for the ctrl release event. It also works on X11 and is preferred if configured.

A "setup wizard" guides the user through the process, previews the C file we are compiling and also verifies that the setup works.

Some tooling cleanups and action updates are also included.

Closes #10.

FichteFoll and others added 16 commits October 15, 2026 00:17
Native Wayland clients cannot query the global keyboard state, so
Ctrl-release detection needs another source. `tabstack-keystate` reads
the Ctrl state from `/dev/input/event*` with `EVIOCGKEY` and reports
changes on stdout using protocol version 1. It is meant to be installed
with `cap_dac_read_search=ep`, sets `PR_SET_NO_NEW_PRIVS` and drops all
capabilities right after opening the devices.

The new `keystate-helper` CI job builds it with `-Werror` and runs
`tests/keystate_smoke.py` against a virtual `uinput` keyboard, with and
without the capability. `tests/` is excluded from package exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add `HelperCtrlReleasePoller`, which spawns the helper once per session
and reads protocol version 1 from its stdout. A wrong handshake, an
`error` line, EOF or a `0` line fire the release exactly once, and
never after `stop()`. Ctrl counts as held until the helper reports its
first state, so a slow helper start does not drop the session.

`probe_helper()` maps the helper's output to a `HelperStatus`.
`plugin_loaded` runs it on the async thread via
`refresh_availability()`, because it can take up to 2 s. Until the
result arrives, Linux behaves as if the helper were missing.
The probe records the helper path it ran with, sessions run exactly
that path, and a change of `keystate_helper_path` triggers a new probe.

On Linux, `create_poller` prefers the helper whenever its probe is
`OK` and falls back to X11 otherwise. X11 is no longer considered
available on native Wayland, where XWayland reports Ctrl as released
while Sublime has focus.

When Ctrl turns out to be released by the time the panel would open,
the pending session is now committed like a quick press instead of
being dropped.

The helper path is configurable via `keystate_helper_path` in the new
`TabStack.sublime-settings`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the `tab_stack_setup_keystate_helper` command, available in the
Command Palette as `TabStack: Set Up Ctrl Detection for Wayland`. It
re-probes the helper in the background and reports success, or
extracts the helper source to the cache directory, opens it for review,
and opens a read-only view with the build, install, `setcap` and
uninstall commands and the security implications. The plugin never
runs any of these commands.

On native Wayland without a working helper, the first `ctrl+tab` per
plugin load offers the setup in a dialog instead of falling back to
`next_view_in_stack`. "Don't Ask Again" persists
`show_wayland_setup_dialog: false`. While the background probe is
still running, the dialog is not offered.

`plugin.ctrl_release` now exports a platform-neutral
`is_native_wayland` that delegates to the Linux implementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Explain that Linux uses the `tabstack-keystate` helper when it works and
falls back to X11 otherwise, how to set the helper up with the
`TabStack: Set Up Ctrl Detection for Wayland` command, and the build,
install, grant and uninstall commands.

Also document the `keystate_helper_path` and `show_wayland_setup_dialog`
settings, the security implications of `cap_dac_read_search` and of
`input` group membership, and that Flatpak and Snap builds are
unsupported. `AGENTS.md` gains the setup command, the dialog and how
the helper is built and tested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`plugin/ctrl_release/linux/` now holds one module per backend:
`x11.py` for the X11 poller and `keystate_helper.py` for the helper.
Its `__init__.py` keeps the backend selection (`create_poller`,
`probe`) and `is_native_wayland`. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`sublime-package.json` contributes a schema for
`TabStack.sublime-settings`, so LSP-json offers completions, hover
documentation and validation when editing the settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Preferences: TabStack Settings` opens the default and user settings
side by side via `edit_settings`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menu mirrors the Command Palette entries: opening the settings
and setting up Ctrl detection for Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sublime Text imports packages from `Packages/`, so `main.py` is loaded
as `TabStack.main`. Point ty's first-party root at the parent directory
so that `from .plugin import *` resolves as it does at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Require `from __future__ import annotations` in every Python file
through Ruff's isort `required-imports`
and stop ignoring `UP045`.
Annotations are no longer evaluated at runtime,
so `Optional[X]` is rewritten to `X | None`
despite the Python 3.8 target.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Re-lock with `uv lock --upgrade`:
`ruff` 0.15.17 to 0.17.0,
`basedpyright` 1.39.9 to 1.39.10
and `nodejs-wheel-binaries` 24.16.0 to 24.19.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The setup command now selects the instructions view and the extracted
`tabstack-keystate.c` together, so Sublime Text shows them side by
side with the instructions on the left. The instructions view is moved
in front of the source tab, since the source may already be open,
possibly in another group.

Multi-sheet selection exists since build 4083, below the package's
minimum of 4100, so no new-window fallback is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running the setup command repeatedly used to open another instructions
view each time. The view is now marked with the
`tab_stack_setup_instructions` setting, so later runs locate it in any
window, replace its contents via `tab_stack_replace_contents`, and show
it next to the source again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing an evdev device waits for an RCU grace period, so opening and
closing every non-keyboard device delayed the initial state report by
about 250 ms on a machine with 19 input devices. A Ctrl release was
only noticed after that, which postponed the tab switch.

The helper now reads each device's key capabilities from
`/sys/class/input/<name>/device/capabilities/key` and opens only the
devices with a Ctrl key, falling back to opening a device when sysfs
cannot be read. A full run now takes about 26 ms instead of 258 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The helper poller assumed Ctrl held until the helper reported the
initial state. When that took longer than the panel delay, a quick
Ctrl+Tab showed the panel and closed it again right away.

`is_ctrl_down` now returns `None` until the state is known, and the
pending session keeps waiting for it, up to one second, before it
opens the panel. A release reported meanwhile commits the session
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`actions/setup-python@v5` still runs on Node 20, which triggers a
deprecation warning. Update all actions to their latest releases, which
run on Node 24, and pin them by commit SHA with the version as a comment.

Add a Dependabot configuration that updates all actions weekly in a
single grouped pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Session callback races, stale helper availability, and modifier-remapping behavior remain unresolved.

3 open findings
What changed in this PR

Adds Linux Ctrl-release detection for Wayland through a user-installed helper, integrated with TabStack’s existing session handling.

Changes:

  • Adds an evdev helper, asynchronous probing, and X11 fallback selection.
  • Provides setup instructions, settings, and menu commands.
  • Adds helper smoke tests and CI coverage; updates annotations and development tooling.
File Description
uv.lock Updates development dependencies.
tests/​keystate_smoke.py Tests helper output and capability handling.
TabStack.sublime-settings Defines helper settings.
sublime-package.json Adds settings schema.
README.md Documents setup, security, and limitations.
pyproject.toml Updates annotation and tooling configuration.
plugin/​state.py Extends the poller interface.
plugin/​sheets.py Modernizes optional annotations.
plugin/​setup.py Implements guided helper setup.
plugin/​session.py Handles delayed initial Ctrl state.
plugin/​mru.py Modernizes optional annotations.
plugin/​models.py Modernizes model annotations.
plugin/​listener.py Modernizes listener annotations.
plugin/​history.py Modernizes history annotations.
plugin/​ctrl_release/​windows.py Modernizes Windows annotations.
plugin/​ctrl_release/​macos.py Modernizes macOS annotations.
plugin/​ctrl_release/​linux/​x11.py Relocates and renames X11 detection.
plugin/​ctrl_release/​linux/​keystate_helper.py Probes and manages helper processes.
plugin/​ctrl_release/​linux/​__init__.py Selects Linux detection backend.
plugin/​ctrl_release/​_availability.py Modernizes cached-state annotation.
plugin/​ctrl_release/​__init__.py Coordinates probing and settings changes.
plugin/​constants.py Adds postponed annotations.
plugin/​commands.py Offers Wayland setup when unavailable.
plugin/​__init__.py Exports setup and initializes detection.
Main.sublime-menu Adds settings and setup menus.
main.py Adds postponed annotations.
keystate/​tabstack-keystate.c Implements capability-limited evdev helper.
Default.sublime-commands Adds settings and setup commands.
AGENTS.md Documents helper architecture and verification.
.github/​workflows/​ci.yml Adds helper CI and pins actions.
.github/​dependabot.yml Enables grouped action updates.
.gitattributes Excludes tests from package exports.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugin/session.py
Comment thread keystate/tabstack-keystate.c Outdated
Comment thread plugin/ctrl_release/linux/keystate_helper.py Outdated
FichteFoll and others added 5 commits October 15, 2026 01:21
A poller queues its release callback on the main thread. If the panel
timer commits the session first and Ctrl+Tab starts a new one before
the queued callback runs, the stale callback would commit the new
session. Act only while the reporting poller is still the session's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The helper was only probed on plugin load and on a change of
`keystate_helper_path`. A helper that stopped working afterwards, for
example after a reinstall dropped its file capability, stayed cached as
available, so every later Ctrl+Tab committed immediately.

A failing poller now invalidates the probe it was created from, unless
a newer probe superseded it, which refreshes the availability. Later
sessions then fall back to X11 or offer the setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The helper only watched the physical Ctrl keys, so with XKB options such
as `ctrl:nocaps` or `ctrl:swapcaps` it reported Ctrl as released while
the remapped key was still held, and every session committed at once.

The helper now takes the keys that count as Ctrl as arguments. They
default to both Ctrl keys and are restricted to the modifiers that the
XKB `ctrl:*` options remap. The helper runs with its capability for any
local user, so accepting arbitrary keys would turn it into a keylogger.

Bump the protocol to version 2, so that an older helper, which ignores
the arguments, is reported as outdated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pass the keys that count as Ctrl to the helper, so that layouts which
remap Ctrl, such as `ctrl:nocaps` or `ctrl:swapcaps`, keep the panel
open while the remapped key is held. The probe records the keys along
with the path, the poller runs exactly the probed command, and a change
of either setting triggers a new probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the setup dialog surfaced a helper that is installed but does not
work, for example an outdated one after a package update, and only on
native Wayland. Under X11 the fallback took over silently. Print the
probe result for any failure other than a missing helper, which is the
normal state without a setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread plugin/ctrl_release/linux/__init__.py
@FichteFoll FichteFoll changed the title Add support for Wayland by polling /dev/input* Add support for Wayland by polling /dev/input Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fast Ctrl+Tab sometimes does nothing

2 participants