Skip to content

Commit 91fe837

Browse files
committed
Update 2026-03-13
1 parent 6e9b92d commit 91fe837

8 files changed

Lines changed: 1060 additions & 12 deletions

File tree

docs/assistant-key-detector.html

Lines changed: 849 additions & 0 deletions
Large diffs are not rendered by default.

tests/test_app.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,56 @@ def test_rear_button_state_matrix(self) -> None:
226226
self.assertEqual(worker_calls, [])
227227
self.assertEqual(send_enter_calls, ["enter"])
228228

229+
def test_recording_submit_press_stops_recording_and_routes_to_openclaw(self) -> None:
230+
subject = self._make_subject()
231+
recording = SimpleNamespace(duration_s=0.7, path=Path("/tmp/submit.wav"))
232+
setattr(
233+
subject,
234+
"_recorder",
235+
SimpleNamespace(is_recording=True, stop_and_save=lambda: recording),
236+
)
237+
setattr(subject, "_config", SimpleNamespace(enter_mode="enter"))
238+
239+
status_values: list[bool] = []
240+
worker_calls: list[tuple[object, str]] = []
241+
send_enter_calls: list[str] = []
242+
setattr(
243+
subject, "_set_recording_status", lambda value: status_values.append(value)
244+
)
245+
setattr(
246+
subject,
247+
"_start_transcription_worker",
248+
lambda rec, *, output_target: worker_calls.append((rec, output_target)),
249+
)
250+
setattr(
251+
subject,
252+
"_output",
253+
SimpleNamespace(send_enter=lambda mode: send_enter_calls.append(mode)),
254+
)
255+
256+
on_submit = cast(
257+
Callable[[], None], getattr(subject, "_on_recording_submit_press")
258+
)
259+
on_submit()
260+
261+
self.assertEqual(status_values, [False])
262+
self.assertEqual(worker_calls, [(recording, "openclaw")])
263+
self.assertEqual(send_enter_calls, [])
264+
265+
def test_recording_submit_press_is_ignored_when_idle(self) -> None:
266+
subject = self._make_subject()
267+
setattr(subject, "_recorder", SimpleNamespace(is_recording=False))
268+
269+
rear_calls: list[bool] = []
270+
setattr(subject, "_on_rear_press", lambda: rear_calls.append(True))
271+
272+
on_submit = cast(
273+
Callable[[], None], getattr(subject, "_on_recording_submit_press")
274+
)
275+
on_submit()
276+
277+
self.assertEqual(rear_calls, [])
278+
229279
def test_transcribe_and_output_openclaw_uses_openclaw_sender(self) -> None:
230280
subject = self._make_subject()
231281
recording = SimpleNamespace(duration_s=1.0, path=Path("/tmp/transcribe.wav"))

tests/test_config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def test_defaults_disable_trust_remote_code(self) -> None:
3636
self.assertEqual(config.front_button, "x1")
3737
self.assertEqual(config.rear_button, "x2")
3838
self.assertEqual(config.record_hotkey_keycodes, (42, 125, 193))
39+
self.assertIsNone(config.recording_submit_keycode)
3940

4041
def test_record_hotkey_keycodes_can_be_configured(self) -> None:
4142
with patch.dict(
@@ -67,6 +68,26 @@ def test_duplicate_record_hotkey_keycodes_are_rejected(self) -> None:
6768
):
6869
_ = load_config()
6970

71+
def test_recording_submit_keycode_can_be_configured(self) -> None:
72+
with patch.dict(
73+
os.environ,
74+
{"VIBEMOUSE_RECORDING_SUBMIT_KEYCODE": "28"},
75+
clear=True,
76+
):
77+
config = load_config()
78+
79+
self.assertEqual(config.recording_submit_keycode, 28)
80+
81+
def test_blank_recording_submit_keycode_disables_listener(self) -> None:
82+
with patch.dict(
83+
os.environ,
84+
{"VIBEMOUSE_RECORDING_SUBMIT_KEYCODE": ""},
85+
clear=True,
86+
):
87+
config = load_config()
88+
89+
self.assertIsNone(config.recording_submit_keycode)
90+
7091
def test_trust_remote_code_can_be_enabled(self) -> None:
7192
with patch.dict(
7293
os.environ, {"VIBEMOUSE_TRUST_REMOTE_CODE": "true"}, clear=True

tests/test_mouse_listener.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,7 @@ def _mark_gesture(direction: str) -> None:
10411041
finish_capture.assert_not_called()
10421042

10431043

1044-
def test_front_click_path_skips_button_suppress_grab(self) -> None:
1044+
def test_front_click_path_does_not_use_button_suppress_grab(self) -> None:
10451045
class _FakeEvent:
10461046
def __init__(self, event_type: int, code: int, value: int) -> None:
10471047
self.type = event_type

tests/test_output.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@
1111

1212

1313
class _FakeKeyboardController:
14-
def __init__(self, *, fail_on_press: bool = False) -> None:
14+
def __init__(
15+
self,
16+
*,
17+
fail_on_press: bool = False,
18+
fail_on_press_key: object | None = None,
19+
) -> None:
1520
self.events: list[tuple[str, object]] = []
1621
self._fail_on_press: bool = fail_on_press
22+
self._fail_on_press_key: object | None = fail_on_press_key
1723

1824
def press(self, key: object) -> None:
19-
if self._fail_on_press:
25+
if self._fail_on_press or key == self._fail_on_press_key:
2026
raise RuntimeError("press failed")
2127
self.events.append(("press", key))
2228

@@ -614,6 +620,39 @@ def test_auto_paste_non_hyprland_shortcut_failure_falls_back_to_ctrl_v(
614620
],
615621
)
616622

623+
def test_auto_paste_ctrl_v_failure_releases_ctrl_and_falls_back_to_clipboard(
624+
self,
625+
) -> None:
626+
subject = self._make_subject()
627+
keyboard = _FakeKeyboardController(fail_on_press_key="v")
628+
self._bind_keyboard(subject, keyboard)
629+
setattr(subject, "_is_text_input_focused", self._not_focused)
630+
setattr(subject, "_hyprland_session", False)
631+
setattr(
632+
subject,
633+
"_system_integration",
634+
SimpleNamespace(
635+
send_shortcut=lambda mod, key: False,
636+
is_terminal_window_active=lambda: False,
637+
paste_shortcuts=lambda terminal_active: (),
638+
send_enter_via_accessibility=lambda: None,
639+
is_text_input_focused=lambda: None,
640+
),
641+
)
642+
643+
with patch("vibemouse.output.pyperclip.copy") as copy_mock:
644+
route = subject.inject_or_clipboard("hello", auto_paste=True)
645+
646+
self.assertEqual(route, "clipboard")
647+
self.assertEqual(copy_mock.call_count, 1)
648+
self.assertEqual(
649+
keyboard.events,
650+
[
651+
("press", "CTRL"),
652+
("release", "CTRL"),
653+
],
654+
)
655+
617656
def test_hyprland_terminal_detection_by_window_class(self) -> None:
618657
subject = self._make_subject()
619658
keyboard = _FakeKeyboardController()
@@ -827,6 +866,22 @@ def test_send_enter_supports_ctrl_enter(self) -> None:
827866
],
828867
)
829868

869+
def test_send_enter_ctrl_enter_failure_releases_modifier(self) -> None:
870+
subject = self._make_subject()
871+
keyboard = _FakeKeyboardController(fail_on_press_key="ENTER")
872+
self._bind_keyboard(subject, keyboard)
873+
874+
with self.assertRaisesRegex(RuntimeError, "press failed"):
875+
subject.send_enter(mode="ctrl_enter")
876+
877+
self.assertEqual(
878+
keyboard.events,
879+
[
880+
("press", "CTRL"),
881+
("release", "CTRL"),
882+
],
883+
)
884+
830885
def test_send_enter_supports_shift_enter(self) -> None:
831886
subject = self._make_subject()
832887
keyboard = _FakeKeyboardController()

vibemouse/app.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ def __init__(self, config: AppConfig) -> None:
6060
keycodes=config.record_hotkey_keycodes,
6161
debounce_s=config.button_debounce_ms / 1000.0,
6262
)
63+
self._recording_submit_listener: KeyboardHotkeyListener | None = None
64+
if config.recording_submit_keycode is not None:
65+
self._recording_submit_listener = KeyboardHotkeyListener(
66+
on_hotkey=self._on_recording_submit_press,
67+
keycodes=(config.recording_submit_keycode,),
68+
debounce_s=config.button_debounce_ms / 1000.0,
69+
)
6370
self._stop_event: threading.Event = threading.Event()
6471
self._transcribe_lock: threading.Lock = threading.Lock()
6572
self._workers_lock: threading.Lock = threading.Lock()
@@ -69,14 +76,18 @@ def __init__(self, config: AppConfig) -> None:
6976
def run(self) -> None:
7077
self._listener.start()
7178
self._keyboard_listener.start()
79+
if self._recording_submit_listener is not None:
80+
self._recording_submit_listener.start()
7281
self._set_recording_status(False)
82+
recording_submit_hotkey = self._config.recording_submit_keycode
7383
_LOG.info(
7484
"VibeMouse ready. "
7585
+ f"Model={self._config.model_name}, preferred_device={self._config.device}, "
7686
+ f"backend={self._config.transcriber_backend}, auto_paste={self._config.auto_paste}, "
7787
+ f"enter_mode={self._config.enter_mode}, debounce_ms={self._config.button_debounce_ms}, "
7888
+ f"front_button={self._config.front_button}, rear_button={self._config.rear_button}, "
7989
+ f"record_hotkey_keycodes={self._config.record_hotkey_keycodes}, "
90+
+ f"recording_submit_keycode={recording_submit_hotkey}, "
8091
+ f"gestures_enabled={self._config.gestures_enabled}, "
8192
+ f"gesture_trigger={self._config.gesture_trigger_button}, "
8293
+ f"gesture_threshold_px={self._config.gesture_threshold_px}, "
@@ -97,6 +108,8 @@ def run(self) -> None:
97108
def shutdown(self) -> None:
98109
self._listener.stop()
99110
self._keyboard_listener.stop()
111+
if self._recording_submit_listener is not None:
112+
self._recording_submit_listener.stop()
100113
self._recorder.cancel()
101114
self._set_recording_status(False)
102115
with self._workers_lock:
@@ -159,6 +172,12 @@ def _on_rear_press(self) -> None:
159172
except Exception as error:
160173
_LOG.exception("Failed to send Enter: %s", error)
161174

175+
def _on_recording_submit_press(self) -> None:
176+
if not self._recorder.is_recording:
177+
return
178+
_LOG.info("Recording submit hotkey pressed, routing to rear-button logic")
179+
self._on_rear_press()
180+
162181
def _on_gesture(self, direction: str) -> None:
163182
action = self._resolve_gesture_action(direction)
164183
if action == "noop":

vibemouse/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ def _read_int(name: str, default: int) -> int:
2323
raise ValueError(f"{name} must be an integer, got {raw!r}") from error
2424

2525

26+
def _read_optional_int(name: str) -> int | None:
27+
raw = os.getenv(name)
28+
if raw is None:
29+
return None
30+
normalized = raw.strip()
31+
if not normalized:
32+
return None
33+
try:
34+
return int(normalized)
35+
except ValueError as error:
36+
raise ValueError(f"{name} must be an integer, got {raw!r}") from error
37+
38+
2639
def _read_float(name: str, default: float) -> float:
2740
raw = os.getenv(name)
2841
if raw is None:
@@ -111,6 +124,7 @@ class AppConfig:
111124
front_button: str
112125
rear_button: str
113126
record_hotkey_keycodes: tuple[int, ...]
127+
recording_submit_keycode: int | None
114128
temp_dir: Path
115129

116130

@@ -157,6 +171,14 @@ def load_config() -> AppConfig:
157171
)
158172
if len(record_hotkey_keycodes) != 3:
159173
raise ValueError("VIBEMOUSE_RECORD_HOTKEY_CODE_1/2/3 must be distinct")
174+
recording_submit_keycode = _read_optional_int(
175+
"VIBEMOUSE_RECORDING_SUBMIT_KEYCODE"
176+
)
177+
if recording_submit_keycode is not None:
178+
recording_submit_keycode = _require_non_negative(
179+
"VIBEMOUSE_RECORDING_SUBMIT_KEYCODE",
180+
recording_submit_keycode,
181+
)
160182
if front_button == rear_button:
161183
raise ValueError("VIBEMOUSE_FRONT_BUTTON and VIBEMOUSE_REAR_BUTTON must differ")
162184
button_debounce_ms = _require_non_negative(
@@ -269,5 +291,6 @@ def load_config() -> AppConfig:
269291
front_button=front_button,
270292
rear_button=rear_button,
271293
record_hotkey_keycodes=record_hotkey_keycodes,
294+
recording_submit_keycode=recording_submit_keycode,
272295
temp_dir=temp_dir,
273296
)

vibemouse/output.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,27 @@ def _paste_clipboard(self) -> None:
179179
):
180180
return
181181

182-
self._kb.press(self._ctrl_key)
183-
self._kb.press("v")
184-
self._kb.release("v")
185-
self._kb.release(self._ctrl_key)
182+
self._send_ctrl_v_via_keyboard()
183+
184+
def _send_ctrl_v_via_keyboard(self) -> None:
185+
pressed_ctrl = False
186+
pressed_v = False
187+
try:
188+
self._kb.press(self._ctrl_key)
189+
pressed_ctrl = True
190+
self._kb.press("v")
191+
pressed_v = True
192+
finally:
193+
if pressed_v:
194+
try:
195+
self._kb.release("v")
196+
except Exception:
197+
pass
198+
if pressed_ctrl:
199+
try:
200+
self._kb.release(self._ctrl_key)
201+
except Exception:
202+
pass
186203

187204
def _send_ctrl_shift_v_via_keyboard(self) -> bool:
188205
pressed_ctrl = False
@@ -244,11 +261,25 @@ def _tap_key(self, key: object) -> None:
244261
self._kb.release(key)
245262

246263
def _tap_modified_key(self, modifier: object, key: object) -> None:
247-
self._kb.press(modifier)
248-
self._kb.press(key)
249-
time.sleep(0.012)
250-
self._kb.release(key)
251-
self._kb.release(modifier)
264+
pressed_modifier = False
265+
pressed_key = False
266+
try:
267+
self._kb.press(modifier)
268+
pressed_modifier = True
269+
self._kb.press(key)
270+
pressed_key = True
271+
time.sleep(0.012)
272+
finally:
273+
if pressed_key:
274+
try:
275+
self._kb.release(key)
276+
except Exception:
277+
pass
278+
if pressed_modifier:
279+
try:
280+
self._kb.release(modifier)
281+
except Exception:
282+
pass
252283

253284
def _send_enter_via_atspi(self) -> bool:
254285
try:

0 commit comments

Comments
 (0)