Assistant IA : se rabattre sur l'ecran visible si rien n'est selectionne
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled

Signale par l'utilisateur : "Expliquer cette sortie" n'avait aucun effet
utilisable, le menu restait grise. Cause reelle : ces actions exigeaient une
selection prealable dans le terminal, alors que l'attente naturelle ("explique
ce que j'ai a l'ecran") est de pouvoir les utiliser directement sur ce qui est
affiche - confirme avec l'utilisateur avant d'implementer (deux options
proposees : rabattre sur l'ecran visible, ou juste clarifier le blocage).

app/terminal_widget.py : nouvelle methode visible_text() (l'ecran actuellement
affiche, sans l'historique de scroll - au contraire de buffer_text() qui sert
a la detection de commande ssh et remonte l'historique proche).

app/ui/tabs.py : Expliquer/Diagnostiquer/Proposer une solution/Poser une
question utilisent desormais _ai_source_text() = la selection si elle existe
(pour cibler precisement), sinon l'ecran visible. Les 3 premieres actions du
menu ne sont plus desactivees des qu'il n'y a pas de selection, mais
seulement si vraiment rien n'est affiche ; "Poser une question" reste
utilisable meme un terminal vide (contexte optionnel, comme le bouton
"Nouvelle question" du panneau).

tests/test_ai_tab_integration.py : reecriture des tests 1 et 2 (etats
d'activation du menu) + 2 nouveaux tests verifiant explicitement le repli
(sans selection, le texte envoye est bien celui de l'ecran) et sa priorite
inverse (une selection existante prime toujours sur l'ecran visible).
tests/test_clipboard.py : couverture directe de visible_text() (ecran complet,
lignes vides de fin retirees). Suite complete revalidee (34/34), ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 08:37:05 +02:00
parent add1073e37
commit a65b86e25b
5 changed files with 99 additions and 28 deletions

View File

@@ -116,11 +116,13 @@ n'est jamais executee automatiquement**.
autre LLM local...). Le modele, lui, reste toujours en saisie libre (pas de liste figee, chaque autre LLM local...). Le modele, lui, reste toujours en saisie libre (pas de liste figee, chaque
installation Ollama/LM Studio ayant ses propres modeles telecharges). Bouton "Tester la installation Ollama/LM Studio ayant ses propres modeles telecharges). Bouton "Tester la
connexion" pour verifier la configuration avant de l'utiliser connexion" pour verifier la configuration avant de l'utiliser
- Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet, sur la selection courante : - Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet : **Expliquer cette sortie**,
**Expliquer cette sortie**, **Diagnostiquer cette erreur**, **Proposer une solution**, ou **Diagnostiquer cette erreur**, **Proposer une solution**, ou **Poser une question**. Ces
**Poser une question** en y ajoutant la selection comme contexte. Les reponses s'affichent actions portent sur le texte **selectionne** s'il y en a un (pour cibler precisement une
dans un panneau lateral (conversation en memoire uniquement, jamais journalisee ni ecrite sur partie d'une sortie longue), et se rabattent sinon automatiquement sur **tout l'ecran visible
le disque) du terminal** — inutile de selectionner quoi que ce soit pour un simple "explique-moi ce que
j'ai sous les yeux". Les reponses s'affichent dans un panneau lateral (conversation en memoire
uniquement, jamais journalisee ni ecrite sur le disque)
- **Generer une commande** (a partir d'une description en langage naturel, sans selection - **Generer une commande** (a partir d'une description en langage naturel, sans selection
necessaire) affiche toujours la commande proposee avant tout usage, avec trois choix : la necessaire) affiche toujours la commande proposee avant tout usage, avec trois choix : la
**copier**, l'**inserer dans le terminal** (le texte est ecrit, jamais valide automatiquement), **copier**, l'**inserer dans le terminal** (le texte est ecrit, jamais valide automatiquement),

View File

@@ -286,6 +286,16 @@ class TerminalWidget(QWidget):
payload = text.replace("\r\n", "\r").replace("\n", "\r") payload = text.replace("\r\n", "\r").replace("\n", "\r")
self.data_to_send.emit(payload.encode("utf-8", errors="ignore")) self.data_to_send.emit(payload.encode("utf-8", errors="ignore"))
def visible_text(self) -> str:
"""Ce qui est actuellement affiche a l'ecran (sans l'historique de
defilement) : repli de l'assistant IA quand rien n'est selectionne -
"expliquer cette sortie" doit alors porter sur ce que l'utilisateur a
reellement sous les yeux, pas sur tout le scrollback."""
lines = [line.rstrip() for line in self.screen.display]
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)
def buffer_text(self) -> str: def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape """Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main). (utilise pour deduire l'hote d'une commande ssh saisie a la main).

View File

@@ -301,30 +301,43 @@ class BaseTerminalTab(QWidget):
menu = self._build_ai_menu() menu = self._build_ai_menu()
menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft())) menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft()))
def _ai_source_text(self) -> str:
"""Texte selectionne s'il y en a un (pour cibler precisement une
partie de la sortie), sinon tout l'ecran actuellement visible - pour
que "Expliquer cette sortie" porte par defaut sur ce que l'utilisateur
a reellement sous les yeux, sans obliger a selectionner au prealable."""
selection = self.terminal.selected_text()
return selection if selection else self.terminal.visible_text()
def _build_ai_menu(self) -> QMenu: def _build_ai_menu(self) -> QMenu:
menu = QMenu(self) menu = QMenu(self)
has_selection = self.terminal.has_selection() has_content = bool(self._ai_source_text().strip())
tooltip = (
"Utilise le texte selectionne, ou tout l'ecran visible si rien n'est selectionne."
if has_content else "Rien n'est encore affiche dans ce terminal."
)
explain_action = menu.addAction("Expliquer cette sortie") explain_action = menu.addAction("Expliquer cette sortie")
explain_action.setEnabled(has_selection) explain_action.setEnabled(has_content)
explain_action.setToolTip(tooltip)
explain_action.triggered.connect(self._ai_explain) explain_action.triggered.connect(self._ai_explain)
diagnose_action = menu.addAction("Diagnostiquer cette erreur") diagnose_action = menu.addAction("Diagnostiquer cette erreur")
diagnose_action.setEnabled(has_selection) diagnose_action.setEnabled(has_content)
diagnose_action.setToolTip(tooltip)
diagnose_action.triggered.connect(self._ai_diagnose) diagnose_action.triggered.connect(self._ai_diagnose)
solution_action = menu.addAction("Proposer une solution") solution_action = menu.addAction("Proposer une solution")
solution_action.setEnabled(has_selection) solution_action.setEnabled(has_content)
solution_action.setToolTip(tooltip)
solution_action.triggered.connect(self._ai_solution) solution_action.triggered.connect(self._ai_solution)
ask_action = menu.addAction("Poser une question...") ask_action = menu.addAction("Poser une question...")
ask_action.setEnabled(has_selection) ask_action.setToolTip(
"La selection (ou l'ecran visible a defaut) est jointe comme contexte a la question."
)
ask_action.triggered.connect(self._ai_ask) ask_action.triggered.connect(self._ai_ask)
if not has_selection:
for action in (explain_action, diagnose_action, solution_action, ask_action):
action.setToolTip("Selectionnez d'abord du texte dans le terminal.")
menu.addSeparator() menu.addSeparator()
generate_action = menu.addAction("Generer une commande...") generate_action = menu.addAction("Generer une commande...")
generate_action.triggered.connect(self._ai_generate_command) generate_action.triggered.connect(self._ai_generate_command)
@@ -367,18 +380,15 @@ class BaseTerminalTab(QWidget):
self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution") self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution")
def _run_ai_selection_action(self, kind: str, label: str) -> None: def _run_ai_selection_action(self, kind: str, label: str) -> None:
selection = self.terminal.selected_text() text = self._ai_source_text()
if not selection: if not text.strip():
return return
panel = self._ensure_ai_panel() panel = self._ensure_ai_panel()
panel.run_request(kind, selection, label=label) panel.run_request(kind, text, label=label)
def _ai_ask(self) -> None: def _ai_ask(self) -> None:
selection = self.terminal.selected_text()
if not selection:
return
panel = self._ensure_ai_panel() panel = self._ensure_ai_panel()
panel.prompt_question(context=selection) panel.prompt_question(context=self._ai_source_text())
def _ai_generate_command(self) -> None: def _ai_generate_command(self) -> None:
if self._ai_command_worker is not None and self._ai_command_worker.isRunning(): if self._ai_command_worker is not None and self._ai_command_worker.isRunning():

View File

@@ -49,34 +49,74 @@ def make_tab(ai_provider_factory=None):
return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory) return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory)
print("=== 1. Menu IA : actions de selection desactivees sans selection ===") print("=== 1. Menu IA : actions desactivees sur un terminal vraiment vide (rien affiche, rien selectionne) ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None): with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab() tab = make_tab()
assert not tab.terminal.has_selection() assert not tab.terminal.has_selection()
assert tab.terminal.visible_text() == ""
menu = tab._build_ai_menu() menu = tab._build_ai_menu()
labels = [a.text() for a in menu.actions() if a.text()] labels = [a.text() for a in menu.actions() if a.text()]
print(" actions:", labels) print(" actions:", labels)
assert "Expliquer cette sortie" in labels assert "Expliquer cette sortie" in labels
assert "Generer une commande..." in labels assert "Generer une commande..." in labels
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."): for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution"):
action = next(a for a in menu.actions() if a.text() == text) action = next(a for a in menu.actions() if a.text() == text)
assert not action.isEnabled(), f"{text} devrait etre desactive sans selection" assert not action.isEnabled(), f"{text} devrait etre desactive : rien a analyser"
# "Poser une question" reste utilisable sans rien : le contexte est optionnel
ask_action = next(a for a in menu.actions() if a.text() == "Poser une question...")
assert ask_action.isEnabled()
generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...") generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...")
assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection" assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection"
tab.close_connection() tab.close_connection()
print(" OK") print(" OK")
print() print()
print("=== 2. Menu IA : actions de selection activees des qu'il y a une selection ===") print("=== 2. Menu IA : actions activees des qu'il y a du texte visible, meme sans selection ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None): with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab() tab = make_tab()
tab.terminal.feed(b"erreur : connection refused\r\n") tab.terminal.feed(b"erreur : connection refused\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0) assert not tab.terminal.has_selection()
assert tab.terminal.has_selection() assert tab.terminal.visible_text().strip()
menu = tab._build_ai_menu() menu = tab._build_ai_menu()
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."): for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
action = next(a for a in menu.actions() if a.text() == text) action = next(a for a in menu.actions() if a.text() == text)
assert action.isEnabled(), f"{text} devrait etre active avec une selection" assert action.isEnabled(), f"{text} devrait etre active : l'ecran visible sert de repli"
tab.close_connection()
print(" OK")
print()
print("=== 2bis. 'Expliquer cette sortie' sans selection : se rabat sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"erreur : connection refused\r\n")
assert not tab.terminal.has_selection(), "ce test verifie precisement le cas SANS selection"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
assert isinstance(tab.ai_panel, AIPanel)
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "connection refused" in conversation.lower() # le texte envoye est bien celui de l'ecran
assert "connexion refusee" in conversation.lower() # et la reponse simulee est bien affichee
tab.close_connection()
print(" OK")
print()
print("=== 2ter. Une selection existante reste prioritaire sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="reponse")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"ligne 1 sans rapport\r\nerreur precise\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 1), (13, 1)
assert tab.terminal.selected_text() == "erreur precise"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "erreur precise" in conversation.lower()
assert "ligne 1 sans rapport" not in conversation.lower()
tab.close_connection() tab.close_connection()
print(" OK") print(" OK")

View File

@@ -36,6 +36,15 @@ print(" lignes:", lignes)
assert lignes[0].startswith("root@srv"), lignes assert lignes[0].startswith("root@srv"), lignes
assert lignes[1] == "serveur-test-01", lignes assert lignes[1] == "serveur-test-01", lignes
print()
print("=== 3bis. visible_text() : tout l'ecran, sans les lignes vides de fin ===")
visible = t.visible_text()
print(" ecran visible:", repr(visible))
lignes_visibles = visible.split("\n")
assert lignes_visibles[0].startswith("root@srv"), lignes_visibles
assert lignes_visibles[1] == "serveur-test-01", lignes_visibles
assert lignes_visibles[-1].startswith("root@srv"), "le prompt courant doit etre la derniere ligne non vide"
print() print()
print("=== 4. Collage (le presse-papiers part vers le shell) ===") print("=== 4. Collage (le presse-papiers part vers le shell) ===")
envoye = [] envoye = []