diff --git a/README.md b/README.md index 694d049..6f20bbe 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@ # ai-typewriter -Application Python qui capture le texte sélectionné avec un raccourci global, l'envoie à un modèle IA, puis remplace chaque pression de touche suivante par le caractère suivant de la réponse. +Application Python qui lit la dernière entrée texte du presse-papier avec un raccourci global, l'envoie à un modèle IA, puis remplace chaque pression de touche suivante par le caractère suivant de la réponse. ## Fonctionnement -1. Raccourci global par défaut : `Ctrl+Alt+A`. -2. L'application simule `Ctrl+C` et lit le presse-papier. -3. Le texte capturé est envoyé à Ollama ou Gemini avec le system prompt strict : +1. L'utilisateur copie manuellement le texte à envoyer à l'IA. +2. Raccourci global par défaut : `Ctrl+Alt+A`. +3. L'application lit directement la dernière entrée du presse-papier, sans simuler `Ctrl+C`. +4. Le texte capturé est journalisé puis envoyé à Ollama ou Gemini avec le system prompt strict : > Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute. -4. Quand la réponse arrive, le mode dactylographie s'active : chaque touche physique est interceptée et remplacée par le prochain caractère de la réponse. -5. Le hook clavier est libéré automatiquement après le dernier caractère. +5. Le temps de génération de la réponse est journalisé. +6. Quand la réponse arrive, le premier caractère est écrit automatiquement. +7. Le mode dactylographie s'active ensuite : chaque touche physique est interceptée et remplacée par le prochain caractère de la réponse. +8. Le hook clavier est libéré automatiquement après le dernier caractère. ## Installation depuis les sources diff --git a/config.json.template b/config.json.template index 963d118..9f0fb76 100644 --- a/config.json.template +++ b/config.json.template @@ -5,8 +5,6 @@ "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", "request_timeout_seconds": 120, - "copy_wait_seconds": 1, "type_delay_seconds": 0, - "restore_clipboard": true, "system_prompt": "Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute." } diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index d40c741..54a97e0 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -3,12 +3,13 @@ from __future__ import annotations import argparse import logging import sys +import time from threading import Lock import keyboard from .ai_client import AIClientError, ask_ai -from .clipboard_capture import capture_selection +from .clipboard_capture import capture_clipboard from .config import AppConfig, load_config from .key_stepper import KeyStepper @@ -37,10 +38,18 @@ class AITypewriterApp: def _capture_ask_and_step(self) -> None: try: - selected = capture_selection(self.config.copy_wait_seconds, self.config.restore_clipboard) - LOG.info("Texte capturé: %d caractères.", len(selected)) + selected = capture_clipboard() + LOG.info("Texte lu depuis le presse-papier: %d caractères.", len(selected)) + LOG.info("Texte capturé: %r", selected) + started_at = time.perf_counter() answer = ask_ai(selected, self.config) - LOG.info("Réponse reçue: %d caractères. Mode dactylographie actif.", len(answer)) + elapsed = time.perf_counter() - started_at + LOG.info( + "Réponse reçue en %.2f s: %d caractères. Premier caractère écrit automatiquement, %d restants au clavier.", + elapsed, + len(answer), + max(len(answer) - 1, 0), + ) if self._stepper is not None: self._stepper.stop() self._stepper = KeyStepper(answer, delay=self.config.type_delay_seconds) @@ -73,7 +82,11 @@ def main(argv: list[str] | None = None) -> int: if args.ask is not None: try: - print(ask_ai(args.ask, config)) + started_at = time.perf_counter() + answer = ask_ai(args.ask, config) + elapsed = time.perf_counter() - started_at + LOG.info("Réponse générée en %.2f s: %d caractères.", elapsed, len(answer)) + print(answer) return 0 except AIClientError as exc: LOG.error("%s", exc) diff --git a/src/ai_typewriter/clipboard_capture.py b/src/ai_typewriter/clipboard_capture.py index b12ea37..626f827 100644 --- a/src/ai_typewriter/clipboard_capture.py +++ b/src/ai_typewriter/clipboard_capture.py @@ -1,29 +1,13 @@ from __future__ import annotations -import time - -import keyboard import pyperclip +def capture_clipboard() -> str: + """Return the current clipboard text without modifying it.""" + return pyperclip.paste() or "" + + +# Backward-compatible alias for older imports/tests. def capture_selection(copy_wait_seconds: float = 1.0, restore_clipboard: bool = True) -> str: - previous = pyperclip.paste() - keyboard.send("ctrl+c") - - deadline = time.monotonic() + max(copy_wait_seconds, 0.05) - captured = previous - while time.monotonic() < deadline: - current = pyperclip.paste() - if current != previous: - captured = current - break - time.sleep(0.03) - else: - captured = pyperclip.paste() - - if restore_clipboard: - try: - pyperclip.copy(previous) - except pyperclip.PyperclipException: - pass - return captured or "" + return capture_clipboard() diff --git a/src/ai_typewriter/key_stepper.py b/src/ai_typewriter/key_stepper.py index f35db1e..6d8668f 100644 --- a/src/ai_typewriter/key_stepper.py +++ b/src/ai_typewriter/key_stepper.py @@ -21,19 +21,17 @@ class KeyStepper: return if self._hook is not None: return + self.type_next_character() + if self._index >= len(self.text): + return # suppress=True blocks the physical key while the callback types the next AI character. self._hook = keyboard.hook(self._on_event, suppress=True) - def stop(self) -> None: - hook = self._hook - self._hook = None - if hook is not None: - keyboard.unhook(hook) - - def _on_event(self, event: keyboard.KeyboardEvent) -> None: - if self._injecting or event.event_type != keyboard.KEY_DOWN: - return + @property + def remaining_characters(self) -> int: + return max(len(self.text) - self._index, 0) + def type_next_character(self) -> None: with self._lock: if self._index >= len(self.text): self.stop() @@ -50,6 +48,17 @@ class KeyStepper: if self._index >= len(self.text): self.stop() + def stop(self) -> None: + hook = self._hook + self._hook = None + if hook is not None: + keyboard.unhook(hook) + + def _on_event(self, event: keyboard.KeyboardEvent) -> None: + if self._injecting or event.event_type != keyboard.KEY_DOWN: + return + self.type_next_character() + def _type_char(self, char: str) -> None: if char == "\n": keyboard.send("enter") diff --git a/tests/test_clipboard_capture.py b/tests/test_clipboard_capture.py new file mode 100644 index 0000000..e4f66cc --- /dev/null +++ b/tests/test_clipboard_capture.py @@ -0,0 +1,16 @@ +from ai_typewriter.clipboard_capture import capture_clipboard, capture_selection + + +def test_capture_clipboard_reads_current_clipboard(monkeypatch): + monkeypatch.setattr("ai_typewriter.clipboard_capture.pyperclip.paste", lambda: "phrase copiée") + + assert capture_clipboard() == "phrase copiée" + + +def test_legacy_capture_selection_does_not_copy_or_modify_clipboard(monkeypatch): + calls = [] + monkeypatch.setattr("ai_typewriter.clipboard_capture.pyperclip.paste", lambda: "déjà copié") + monkeypatch.setattr("ai_typewriter.clipboard_capture.pyperclip.copy", lambda value: calls.append(value), raising=False) + + assert capture_selection() == "déjà copié" + assert calls == [] diff --git a/tests/test_key_stepper.py b/tests/test_key_stepper.py new file mode 100644 index 0000000..cf531d8 --- /dev/null +++ b/tests/test_key_stepper.py @@ -0,0 +1,49 @@ +from types import SimpleNamespace + +from ai_typewriter.key_stepper import KeyStepper + + +def test_start_types_first_character_immediately_then_hooks_remaining(monkeypatch): + typed = [] + hooked = [] + + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hooked.append((callback, suppress)) or "hook") + + stepper = KeyStepper("abc") + stepper.start() + + assert typed == ["a"] + assert hooked and hooked[0][1] is True + assert stepper.remaining_characters == 2 + + +def test_single_character_response_does_not_install_hook(monkeypatch): + typed = [] + hooks = [] + + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hooks.append(callback)) + + stepper = KeyStepper("x") + stepper.start() + + assert typed == ["x"] + assert hooks == [] + assert stepper.remaining_characters == 0 + + +def test_key_down_types_next_character_after_auto_first(monkeypatch): + typed = [] + hook = {} + + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char)) + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.setdefault("callback", callback) or "hook") + monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None) + + stepper = KeyStepper("ab") + stepper.start() + hook["callback"](SimpleNamespace(event_type="down")) + + assert typed == ["a", "b"] + assert stepper.remaining_characters == 0