Improve clipboard workflow and logging

This commit is contained in:
Hermes Agent
2026-09-11 13:24:39 +02:00
parent e779bd3423
commit 9320217e0f
7 changed files with 117 additions and 45 deletions
+9 -6
View File
@@ -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
-2
View File
@@ -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."
}
+18 -5
View File
@@ -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)
+7 -23
View File
@@ -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()
+18 -9
View File
@@ -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")
+16
View File
@@ -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 == []
+49
View File
@@ -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