Return the keyboard on Remote shortcuts and announce focus changes

During remote focus a Remote shortcut now ends capture, like F11,
instead of running its command, which would race with the focus change.
The reader announces when the keyboard starts and stops controlling the
remote computer, and the consent wording names the F11 escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent c234cb82a8
commit 477d860fb8
5 files changed
+43 -25

No files matched your search

+8 -6
View File
@@ -61,10 +61,11 @@ Each native transition passes through `RemoteRepeatNormalizer` and
local chord. Keys outside the Remote vocabulary are consumed locally.
Only two things stay local. The core escape, F11 with no modifiers, ends capture
on the X thread without waiting for a Tentacle or the network. Commands whose
IDs start with the requesting Tentacle's `tentacle/<session>/` prefix, as
resolved from current bindings including remaps, are dispatched through the
normal command queue. Lock, secure input, XTerm handoff, learn mode, OCR review,
on the X thread without waiting for a Tentacle or the network. Shortcuts for
commands whose IDs start with the requesting Tentacle's `tentacle/<session>/`
prefix, as resolved from current bindings including remaps, also return the
keyboard here (reason `requested`) without running the command; running it
would race with the focus change. Pressed again under local focus they run. Lock, secure input, XTerm handoff, learn mode, OCR review,
a device hierarchy change, an inconsistent stream or a full 512-event queue end
capture with reason `local`.
@@ -81,8 +82,9 @@ handed off and showing no reader UI. Revocation of the lease ends capture.
A private Xvfb test drives XTEST keys into the real owner with a focused `xev`
window as the local application: plain keys, Shift+Tab and a reader-modifier
chord are forwarded with correct names and pairing; the Remote shortcut stays
local and withdraws its prefix; unsupported keys and F11 are consumed; and the
chord are forwarded with correct names and pairing; the Remote shortcut
withdraws its prefix and ends capture without running; unsupported keys and F11
are consumed; and the
window receives none of the captured keys but does receive input afterwards.
Lock ends capture with a held modifier released remotely. Five repeated runs
passed. App tests with a synthetic owner and a real Go SDK child cover focus
+3
View File
@@ -105,8 +105,10 @@ func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, sessio
id, stream, err := capture.BeginRemoteCapture(request.Context, prefix)
if err != nil {
_ = session.PublishRemoteFocus(request.Lease, tentaclewire.RemoteFocusLocal, "unavailable")
runtime.report("Remote keyboard control is unavailable.")
continue
}
runtime.report("Keyboard controls the remote computer. Press F11 to return.")
active, lease, events, leaseDone = id, request.Lease, stream, request.Context.Done()
if session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusRemote, "requested") != nil {
end()
@@ -118,6 +120,7 @@ func (runtime *tentacleRuntime) start_remote_capture(ctx context.Context, sessio
}
if event.Ended != "" {
_ = session.PublishRemoteFocus(lease, tentaclewire.RemoteFocusLocal, event.Ended)
runtime.report("Keyboard returned to this computer.")
continue
}
key := tentaclewire.RemoteCapturedKey{Code: int(event.Key.Code), Name: event.Key.Name, Pressed: event.Key.Pressed, Repeat: event.Key.Repeat}
+1 -1
View File
@@ -118,7 +118,7 @@ func remote_consent_form(id, role string, grants []string) interaction.Form {
labels := map[string]string{
"output-observe": "Share local reader output with the remote controller",
"remote-present": "Present output from the controlled computer",
"keyboard-capture": "Forward local keyboard input to the controlled computer",
"keyboard-capture": "Forward local keyboard input to the controlled computer while remote focus is on. Press F11 to return to this computer",
"keyboard-inject": "Allow the remote controller to type on this computer",
"clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request",
"braille-stream": "Exchange remote braille cells and supported display gestures",
+10 -11
View File
@@ -60,8 +60,8 @@ func (listener *CommandListener) RemoteCaptureActive() bool {
// BeginRemoteCapture takes an exclusive keyboard grab and returns the session
// and its event stream. Only the core escape (F11) and commands whose IDs start
// with localPrefix stay local; everything else is forwarded, including Cthulhu
// modifier chords. It waits until no key is held. It grants no consent: the
// with localPrefix stay local: either returns the keyboard to this computer.
// Everything else is forwarded, including Cthulhu modifier chords. It waits until no key is held. It grants no consent: the
// caller must hold a live keyboard-capture lease and end capture on revocation.
func (listener *CommandListener) BeginRemoteCapture(ctx context.Context, localPrefix string) (uint64, <-chan input.RemoteCaptureEvent, error) {
if !strings.HasPrefix(localPrefix, "tentacle/") || !strings.HasSuffix(localPrefix, "/") {
@@ -174,7 +174,7 @@ func (owner *commandOwner) end_remote_capture(reason string) {
}
capture.ending = true
for _, transition := range capture.normalizer.Retire() {
owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}, 0)
owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode})
}
for _, transition := range capture.router.Retire().Remote {
owner.send_remote(input.RemoteCaptureEvent{Key: &input.RemoteCaptureTransition{Code: transition.Keycode}})
@@ -208,7 +208,7 @@ func (owner *commandOwner) send_remote(event input.RemoteCaptureEvent) bool {
func (owner *commandOwner) flush_remote(now time.Time) {
for _, transition := range owner.remote.normalizer.Flush(now) {
owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode}, 0)
owner.route_remote(input.RemoteCaptureKey{Keycode: transition.Keycode})
}
}
@@ -231,14 +231,14 @@ func (owner *commandOwner) handle_remote_key(key C.CommandKey) {
capture.names[transition.Keycode] = name
routed.Unsupported = name == ""
}
owner.route_remote(routed, uint32(key.timestamp))
owner.route_remote(routed)
}
if err != nil && owner.remote != nil {
owner.end_remote_capture("local")
}
}
func (owner *commandOwner) route_remote(key input.RemoteCaptureKey, timestamp uint32) {
func (owner *commandOwner) route_remote(key input.RemoteCaptureKey) {
capture := owner.remote
if capture == nil {
return
@@ -264,10 +264,9 @@ func (owner *commandOwner) route_remote(key input.RemoteCaptureKey, timestamp ui
owner.end_remote_capture("escape")
return
}
if result.Local != "" && owner.workerContext.Err() == nil {
chord := input.CommandChord{Symbol: key.Symbol}
if !owner.listener.enqueueSecureCommand(input.CommandEvent{Command: result.Local, Timestamp: timestamp, Chord: chord}) {
owner.end_remote_capture("local")
}
// A Remote shortcut returns the keyboard here instead of running: running it
// would race with the focus change. Press it again under local focus.
if result.Local != "" {
owner.end_remote_capture("requested")
}
}
@@ -113,23 +113,37 @@ func TestNativeRemoteCaptureForwardsBypassesAndEscapes(t *testing.T) {
expect("t", true, true)
expect("", false, false)
expect("", false, false)
// Remote's own shortcut stays local and withdraws the forwarded prefix.
// Unsupported keys are consumed locally, not forwarded.
run("key", "XF86AudioMute")
// Remote's own shortcut stays local: it withdraws the forwarded prefix and
// returns the keyboard here without running the command.
run("key", "Insert+ctrl+r")
insert := expect("Insert", true, true)
control := expect("Control_L", true, true)
if expect("", false, false) != control || expect("", false, false) != insert {
t.Fatal("prefix not withdrawn newest first")
}
if event := next(); event.Ended != "requested" {
t.Fatalf("shortcut end event = %+v", event)
}
select {
case event := <-listener.Commands():
if event.Command != toggle {
t.Fatalf("local command = %s", event.Command)
t.Fatalf("shortcut ran %s during remote focus", event.Command)
default:
}
if _, ok := <-events; ok {
t.Fatal("capture stream stayed open after shortcut")
}
for listener.RemoteCaptureActive() {
if ctx.Err() != nil {
t.Fatal("shortcut did not end capture")
}
case <-ctx.Done():
t.Fatal("local Remote shortcut did not dispatch")
time.Sleep(time.Millisecond)
}
session, events, err = listener.BeginRemoteCapture(ctx, "tentacle/remote-1/")
if err != nil {
t.Fatal(err)
}
// Unsupported keys are consumed locally, not forwarded.
run("key", "XF86AudioMute")
run("key", "F11")
if event := next(); event.Ended != "escape" {
t.Fatalf("escape event = %+v", event)