Improve clipboard workflow and logging
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user